From bb22e164606b00d86c871c31fd8191c7f43c6598 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 31 Jan 2026 09:52:51 -0800 Subject: [PATCH] Add structured node config with names/macs/ips and avoid flag Co-Authored-By: Claude Opus 4.5 --- config.example.yaml | 46 +++++---- config.go | 28 +++++- config.yaml | 224 ++++++++++++++++++++++++------------------ nodes.go | 95 +++++++++++++++--- ping.go | 4 + static/js/topology.js | 6 +- tendrils.go | 7 ++ types.go | 1 + 8 files changed, 276 insertions(+), 135 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 96431e0..63bb94a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,21 +1,27 @@ locations: - stage: - children: - upstage: - nodes: - - lighting-1 - downstage: - nodes: - - lighting-2 - house: - children: - house_left: - nodes: - - audio - house_right: - nodes: - - video - booth: - nodes: - - qlab - - satellite-1 +- name: Stage + children: + - name: Upstage + nodes: + - names: ["lighting-1"] + - name: Downstage + nodes: + - names: ["lighting-2"] + +- name: House + children: + - name: House Left + nodes: + - names: ["audio"] + - name: House Right + nodes: + - names: ["video"] + - name: Booth + nodes: + - names: ["qlab"] + - names: ["satellite-1"] + # Example with MAC and avoid flag: + # - names: ["problem-device"] + # macs: ["00:11:22:33:44:55"] + # ips: ["10.0.1.100"] + # avoid: true diff --git a/config.go b/config.go index 0fd6432..5afccdc 100644 --- a/config.go +++ b/config.go @@ -12,10 +12,17 @@ type Config struct { } type Location struct { - Name string `yaml:"name" json:"name"` - Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` - Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"` - Children []*Location `yaml:"children,omitempty" json:"children,omitempty"` + Name string `yaml:"name" json:"name"` + Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` + Nodes []*NodeConfig `yaml:"nodes,omitempty" json:"nodes,omitempty"` + Children []*Location `yaml:"children,omitempty" json:"children,omitempty"` +} + +type NodeConfig struct { + Names []string `yaml:"names,omitempty" json:"names,omitempty"` + MACs []string `yaml:"macs,omitempty" json:"macs,omitempty"` + IPs []string `yaml:"ips,omitempty" json:"ips,omitempty"` + Avoid bool `yaml:"avoid,omitempty" json:"avoid,omitempty"` } func LoadConfig(path string) (*Config, error) { @@ -39,3 +46,16 @@ func LoadConfig(path string) (*Config, error) { func (c *Config) ToJSON() ([]byte, error) { return json.Marshal(c) } + +func (c *Config) AllNodeConfigs() []*NodeConfig { + var result []*NodeConfig + var visit func([]*Location) + visit = func(locs []*Location) { + for _, loc := range locs { + result = append(result, loc.Nodes...) + visit(loc.Children) + } + } + visit(c.Locations) + return result +} diff --git a/config.yaml b/config.yaml index e4766b1..aaa94db 100644 --- a/config.yaml +++ b/config.yaml @@ -4,168 +4,200 @@ locations: children: - name: LIGHTING-2 Rack nodes: - - lighting-2 - - "48:59:00:41:00:29" # Pixie Driver 8k Port 1 - - "48:59:00:28:00:27" # Pixie Driver 8k Port 2 - - "48:59:00:43:00:29" # Pixie Driver 8k Port 3 - - "48:59:00:42:00:29" # Pixie Driver 8k Port 4 - - "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 - - "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 - - "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 - - "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 - - DMXG # cyc1 - - DMXH # cyc2 - - DMXI # cyc3 - - DMXJ # cyc4 - - DMXK # cyc5 - - DMXL # cyc6 + - names: ["lighting-2"] + - names: ["Pixie Driver 8k 2 Port 1"] + macs: ["48:59:00:41:00:29"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 2"] + macs: ["48:59:00:28:00:27"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 3"] + macs: ["48:59:00:43:00:29"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 4"] + macs: ["48:59:00:42:00:29"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 5"] + macs: ["48:59:00:3c:00:3e"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 6"] + macs: ["48:59:00:3f:00:3e"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 7"] + macs: ["48:59:00:25:00:3e"] + avoid: true + - names: ["Pixie Driver 8k 2 Port 8"] + macs: ["48:59:00:41:00:3e"] + avoid: true + - names: ["DMXG"] # cyc1 + - names: ["DMXH"] # cyc2 + - names: ["DMXI"] # cyc3 + - names: ["DMXJ"] # cyc4 + - names: ["DMXK"] # cyc5 + - names: ["DMXL"] # cyc6 - direction: horizontal children: - nodes: - - DMXQ # r2x1 + - names: ["DMXQ"] # r2x1 - nodes: - - DMXR # r2x2 + - names: ["DMXR"] # r2x2 - direction: horizontal children: - nodes: - - "MON1-A" - - "MON1-B" + - names: ["MON1-A"] + - names: ["MON1-B"] - name: Under Apron direction: horizontal children: - name: AUDIO Rack nodes: - - audio - - "MICS-A" - - "00:0e:dd:a7:29:93" # MICS-A bridge interface - - "MICS-B" - - "00:0e:dd:a8:3e:b3" # MICS-B bridge interface - - "MICS-C" - - "00:0e:dd:a7:6f:55" # MICS-C bridge interface - - "MICS-D" - - "00:0e:dd:64:3d:51" # MICS-D bridge interface - - "MICS-E" - - "00:0e:dd:ac:fc:7d" # MICS-E bridge interface + - names: ["audio"] + - names: ["MICS-A"] + macs: ["00:0e:dd:a7:29:93"] + - names: ["MICS-B"] + macs: ["00:0e:dd:a8:3e:b3"] + - names: ["MICS-C"] + macs: ["00:0e:dd:a7:6f:55"] + - names: ["MICS-D"] + macs: ["00:0e:dd:64:3d:51"] + - names: ["MICS-E"] + macs: ["00:0e:dd:ac:fc:7d"] - name: LIGHTING-1 Rack nodes: - - lighting-1 - - "48:59:00:27:00:27" # Pixie Driver 8k Port 1 - - "48:59:00:37:00:27" # Pixie Driver 8k Port 2 - - "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 - - "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 - - "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 - - "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 - - "48:59:00:42:00:19" # Pixie Driver 8k Port 7 - - "48:59:00:44:00:19" # Pixie Driver 8k Port 8 + - names: ["lighting-1"] + - names: ["Pixie Driver 8k 1 Port 1"] + macs: ["48:59:00:27:00:27"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 2"] + macs: ["48:59:00:37:00:27"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 3"] + macs: ["48:59:00:3e:00:27"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 4"] + macs: ["48:59:00:3f:00:27"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 5"] + macs: ["48:59:00:47:00:1a"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 6"] + macs: ["48:59:00:44:00:1a"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 7"] + macs: ["48:59:00:42:00:19"] + avoid: true + - names: ["Pixie Driver 8k 1 Port 8"] + macs: ["48:59:00:44:00:19"] + avoid: true - name: VIDEO Rack nodes: - - video - - "ATEM 2 M/E Constellation 4K" - - "HyperDeck-Studio-4K-Pro" - - RX-QLAB-1 - - RX-QLAB-2 - - TX-PROJ-1 - - TX-PROJ-2 - - TX-M4 - - TX-M16 - - TX-MISC - - TX-PREVIEW + - names: ["video"] + - names: ["ATEM 2 M/E Constellation 4K"] + - names: ["HyperDeck-Studio-4K-Pro"] + - names: ["RX-QLAB-1"] + - names: ["RX-QLAB-2"] + - names: ["TX-PROJ-1"] + - names: ["TX-PROJ-2"] + - names: ["TX-M4"] + - names: ["TX-M16"] + - names: ["TX-MISC"] + - names: ["TX-PREVIEW"] - direction: horizontal children: - nodes: - - "Y001-MAIN1-L-d1e155" - - "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface + - names: ["Y001-MAIN1-L-d1e155"] + macs: ["ac:44:f2:4e:84:d6"] - name: Stage 2 nodes: - - "MON2" + - names: ["MON2"] - name: Stage 3 nodes: - - "MON3" + - names: ["MON3"] - nodes: - - "Y001-MAIN1-R-d1e194" - - "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface + - names: ["Y001-MAIN1-R-d1e194"] + macs: ["ac:44:f2:4e:84:d4"] - direction: horizontal children: - nodes: - - "RX-PROJ-1" + - names: ["RX-PROJ-1"] - nodes: - - "RX-PROJ-2" + - names: ["RX-PROJ-2"] - direction: horizontal children: - nodes: - - satellite-2 - - "Y001-MAIN2-L-d1e298" - - "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface - - DMXA # wash1 - - DMXB # wash2 - - DMXC # wash3 - - DMXM # focus{1,2} + - names: ["satellite-2"] + - names: ["Y001-MAIN2-L-d1e298"] + macs: ["ac:44:f2:4e:87:2a"] + - names: ["DMXA"] # wash1 + - names: ["DMXB"] # wash2 + - names: ["DMXC"] # wash3 + - names: ["DMXM"] # focus{1,2} - nodes: - - DMXN # focus{3,4} - - DMXP # r3x{1,2} + - names: ["DMXN"] # focus{3,4} + - names: ["DMXP"] # r3x{1,2} - nodes: - - satellite-3 - - "Y001-MAIN2-R-f0dd93" - - "ac:44:f2:4e:87:27" # MAIN2-R bridge interface - - DMXD # wash4 - - DMXE # wash5 - - DMXF # wash6 - - DMXO # focus{5,6} + - names: ["satellite-3"] + - names: ["Y001-MAIN2-R-f0dd93"] + macs: ["ac:44:f2:4e:87:27"] + - names: ["DMXD"] # wash4 + - names: ["DMXE"] # wash5 + - names: ["DMXF"] # wash6 + - names: ["DMXO"] # focus{5,6} - name: Booth direction: vertical children: - nodes: - - satellite-1 + - names: ["satellite-1"] - direction: horizontal children: - name: Lighting Control nodes: - - qlab - - TX-QLAB-1 - - TX-QLAB-2 - - "SK_PTZEXTREMEV2 [457081]" - - "SK_RACKPRO2 [452514]" - - pigeon - - showpi1 - - d8:3a:dd:e3:5b:db # showpi2/artmap - + - names: ["qlab"] + - names: ["TX-QLAB-1"] + - names: ["TX-QLAB-2"] + - names: ["SK_PTZEXTREMEV2 [457081]"] + - names: ["SK_RACKPRO2 [452514]"] + - names: ["pigeon"] + - names: ["showpi1"] + - macs: ["d8:3a:dd:e3:5b:db"] # showpi2/artmap + - name: Sound Control nodes: - - SQ-7 - - "00:04:c4:15:07:a4" # SQ-7 bridge port - - BT + - names: ["SQ-7"] + macs: ["00:04:c4:15:07:a4"] + - names: ["BT"] - name: Camera Control nodes: - - RX-CC-PREVIEW - - RX-CC-M16 - - "AtemPanel-7c2e0da86d22" - - "AtemPanel-7c2e0da86d4c" + - names: ["RX-CC-PREVIEW"] + - names: ["RX-CC-M16"] + - names: ["AtemPanel-7c2e0da86d22"] + - names: ["AtemPanel-7c2e0da86d4c"] - name: Video Control nodes: - - RX-VC-M4 - - RX-VC-M16 - - "ATEM-2-ME-Advanced-Panel-20" + - names: ["RX-VC-M4"] + - names: ["RX-VC-M16"] + - names: ["ATEM-2-ME-Advanced-Panel-20"] - name: Control nodes: - - sunset - - RX-CONTROL-1 + - names: ["sunset"] + - names: ["RX-CONTROL-1"] diff --git a/nodes.go b/nodes.go index 2b49eae..30bd6b1 100644 --- a/nodes.go +++ b/nodes.go @@ -40,19 +40,20 @@ func (n *Nodes) Shutdown() { n.cancelAll() } -func (n *Nodes) Update(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) { - changed := n.updateLocked(target, mac, ips, ifaceName, nodeName, source) +func (n *Nodes) Update(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) *Node { + node, changed := n.updateLocked(target, mac, ips, ifaceName, nodeName, source) if changed { n.t.NotifyUpdate() } + return node } -func (n *Nodes) updateLocked(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) bool { +func (n *Nodes) updateLocked(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) (*Node, bool) { n.mu.Lock() defer n.mu.Unlock() - if mac == nil && target == nil && len(ips) == 0 { - return false + if mac == nil && target == nil && len(ips) == 0 && nodeName == "" { + return nil, false } node, isNew := n.resolveTargetNode(target, mac, ips, nodeName) @@ -65,7 +66,7 @@ func (n *Nodes) updateLocked(target *Node, mac net.HardwareAddr, ips []net.IP, i n.triggerPoll(node) } - return isNew || len(added) > 0 + return node, isNew || len(added) > 0 } func (n *Nodes) resolveTargetNode(target *Node, mac net.HardwareAddr, ips []net.IP, nodeName string) (*Node, bool) { @@ -119,7 +120,7 @@ func (n *Nodes) findOrMergeByName(target *Node, nodeName string) *Node { if target == nil { return found } - if found != target && len(found.Interfaces) == 0 { + if found != target { n.mergeNodes(target, found) } return target @@ -368,7 +369,7 @@ func (n *Nodes) Merge(macs []net.HardwareAddr, source string) { } func (n *Nodes) mergeNodes(keep, merge *Node) { - if keep == nil || merge == nil { + if keep == nil || merge == nil || keep == merge { return } @@ -380,15 +381,21 @@ func (n *Nodes) mergeNodes(keep, merge *Node) { n.nameIndex[name] = keep } - for _, iface := range merge.Interfaces { - var ips []net.IP - for ipStr := range iface.IPs { - ips = append(ips, net.ParseIP(ipStr)) - } + for ifaceKey, iface := range merge.Interfaces { if iface.MAC != "" { - n.updateNodeInterface(keep, iface.MAC.Parse(), ips, iface.Name) n.macIndex[string(iface.MAC)] = keep } + for ipStr := range iface.IPs { + n.ipIndex[ipStr] = keep + } + if _, exists := keep.Interfaces[ifaceKey]; !exists { + keep.Interfaces[ifaceKey] = iface + } else { + keepIface := keep.Interfaces[ifaceKey] + for ipStr := range iface.IPs { + keepIface.IPs.Add(net.ParseIP(ipStr)) + } + } } for peerMAC, ifaceName := range merge.MACTable { @@ -620,3 +627,63 @@ func (n *Nodes) LogAll() { n.logArtNet() n.logDante() } + +func (n *Nodes) ApplyConfig(cfg *Config) { + if cfg == nil { + return + } + for _, nc := range cfg.AllNodeConfigs() { + n.applyNodeConfig(nc) + } +} + +func (n *Nodes) applyNodeConfig(nc *NodeConfig) { + if len(nc.Names) == 0 && len(nc.MACs) == 0 && len(nc.IPs) == 0 { + return + } + + var macs []net.HardwareAddr + for _, macStr := range nc.MACs { + if mac, err := net.ParseMAC(macStr); err == nil { + macs = append(macs, mac) + } + } + + var ips []net.IP + for _, ipStr := range nc.IPs { + if ip := net.ParseIP(ipStr); ip != nil { + ips = append(ips, ip) + } + } + + var firstMAC net.HardwareAddr + if len(macs) > 0 { + firstMAC = macs[0] + } + firstName := "" + if len(nc.Names) > 0 { + firstName = nc.Names[0] + } + + target := n.Update(nil, firstMAC, ips, "", firstName, "config") + if target == nil { + return + } + + for i := 1; i < len(macs); i++ { + n.Update(target, macs[i], nil, "", "", "config") + } + for i := 1; i < len(nc.Names); i++ { + n.Update(target, nil, nil, "", nc.Names[i], "config") + } + + if nc.Avoid { + n.setAvoid(target) + } +} + +func (n *Nodes) setAvoid(node *Node) { + n.mu.Lock() + defer n.mu.Unlock() + node.Avoid = true +} diff --git a/ping.go b/ping.go index bacc5a6..a75bbd8 100644 --- a/ping.go +++ b/ping.go @@ -140,6 +140,10 @@ func (pm *PingManager) Ping(ipStr string, timeout time.Duration) bool { func (t *Tendrils) pingNode(node *Node) { t.nodes.mu.RLock() + if node.Avoid { + t.nodes.mu.RUnlock() + return + } var ips []string nodeName := node.DisplayName() nodeID := node.ID diff --git a/static/js/topology.js b/static/js/topology.js index 6e3a5ac..e544148 100644 --- a/static/js/topology.js +++ b/static/js/topology.js @@ -107,7 +107,11 @@ export function buildLocationTree(locations, parent) { name: loc.name || '', anonymous: anonymous, direction: loc.direction || 'horizontal', - nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()), + nodeRefs: (loc.nodes || []).flatMap(n => [ + ...(n.names || []).map(name => name.toLowerCase()), + ...(n.macs || []).map(mac => mac.toLowerCase()), + ...(n.ips || []) + ]), parent: parent, children: [] }; diff --git a/tendrils.go b/tendrils.go index e981d52..d6e1abd 100644 --- a/tendrils.go +++ b/tendrils.go @@ -125,6 +125,7 @@ func (t *Tendrils) Run() { signal.Notify(sigUsr1Ch, syscall.SIGUSR1) go func() { for range sigUsr1Ch { + t.nodes.ApplyConfig(t.config) t.nodes.LogAll() } }() @@ -139,6 +140,7 @@ func (t *Tendrils) Run() { continue } t.config = cfg + t.nodes.ApplyConfig(cfg) log.Printf("reloaded config from %s", t.ConfigFile) t.NotifyUpdate() } @@ -149,6 +151,7 @@ func (t *Tendrils) Run() { log.Fatalf("[ERROR] failed to load config: %v", err) } t.config = cfg + t.nodes.ApplyConfig(cfg) t.populateLocalAddresses() t.startHTTPServer() @@ -312,6 +315,10 @@ func (t *Tendrils) startInterface(ctx context.Context, iface net.Interface) { func (t *Tendrils) pollNode(node *Node) { t.nodes.mu.RLock() + if node.Avoid { + t.nodes.mu.RUnlock() + return + } var ips []net.IP for _, iface := range node.Interfaces { for ipStr := range iface.IPs { diff --git a/types.go b/types.go index 8a4cd54..8ee0691 100644 --- a/types.go +++ b/types.go @@ -460,6 +460,7 @@ type Node struct { SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"` ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"` Unreachable bool `json:"unreachable,omitempty"` + Avoid bool `json:"avoid,omitempty"` errors *ErrorTracker pollTrigger chan struct{} cancelFunc context.CancelFunc