Add structured node config with names/macs/ips and avoid flag

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-31 09:52:51 -08:00
parent 3b8005a28c
commit bb22e16460
8 changed files with 276 additions and 135 deletions

View File

@@ -1,21 +1,27 @@
locations: locations:
stage: - name: Stage
children: children:
upstage: - name: Upstage
nodes: nodes:
- lighting-1 - names: ["lighting-1"]
downstage: - name: Downstage
nodes: nodes:
- lighting-2 - names: ["lighting-2"]
house:
children: - name: House
house_left: children:
nodes: - name: House Left
- audio nodes:
house_right: - names: ["audio"]
nodes: - name: House Right
- video nodes:
booth: - names: ["video"]
nodes: - name: Booth
- qlab nodes:
- satellite-1 - 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

View File

@@ -12,10 +12,17 @@ type Config struct {
} }
type Location struct { type Location struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Direction string `yaml:"direction,omitempty" json:"direction,omitempty"` Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"` Nodes []*NodeConfig `yaml:"nodes,omitempty" json:"nodes,omitempty"`
Children []*Location `yaml:"children,omitempty" json:"children,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) { func LoadConfig(path string) (*Config, error) {
@@ -39,3 +46,16 @@ func LoadConfig(path string) (*Config, error) {
func (c *Config) ToJSON() ([]byte, error) { func (c *Config) ToJSON() ([]byte, error) {
return json.Marshal(c) 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
}

View File

@@ -4,168 +4,200 @@ locations:
children: children:
- name: LIGHTING-2 Rack - name: LIGHTING-2 Rack
nodes: nodes:
- lighting-2 - names: ["lighting-2"]
- "48:59:00:41:00:29" # Pixie Driver 8k Port 1 - names: ["Pixie Driver 8k 2 Port 1"]
- "48:59:00:28:00:27" # Pixie Driver 8k Port 2 macs: ["48:59:00:41:00:29"]
- "48:59:00:43:00:29" # Pixie Driver 8k Port 3 avoid: true
- "48:59:00:42:00:29" # Pixie Driver 8k Port 4 - names: ["Pixie Driver 8k 2 Port 2"]
- "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 macs: ["48:59:00:28:00:27"]
- "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 avoid: true
- "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 - names: ["Pixie Driver 8k 2 Port 3"]
- "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 macs: ["48:59:00:43:00:29"]
- DMXG # cyc1 avoid: true
- DMXH # cyc2 - names: ["Pixie Driver 8k 2 Port 4"]
- DMXI # cyc3 macs: ["48:59:00:42:00:29"]
- DMXJ # cyc4 avoid: true
- DMXK # cyc5 - names: ["Pixie Driver 8k 2 Port 5"]
- DMXL # cyc6 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 - direction: horizontal
children: children:
- nodes: - nodes:
- DMXQ # r2x1 - names: ["DMXQ"] # r2x1
- nodes: - nodes:
- DMXR # r2x2 - names: ["DMXR"] # r2x2
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "MON1-A" - names: ["MON1-A"]
- "MON1-B" - names: ["MON1-B"]
- name: Under Apron - name: Under Apron
direction: horizontal direction: horizontal
children: children:
- name: AUDIO Rack - name: AUDIO Rack
nodes: nodes:
- audio - names: ["audio"]
- "MICS-A" - names: ["MICS-A"]
- "00:0e:dd:a7:29:93" # MICS-A bridge interface macs: ["00:0e:dd:a7:29:93"]
- "MICS-B" - names: ["MICS-B"]
- "00:0e:dd:a8:3e:b3" # MICS-B bridge interface macs: ["00:0e:dd:a8:3e:b3"]
- "MICS-C" - names: ["MICS-C"]
- "00:0e:dd:a7:6f:55" # MICS-C bridge interface macs: ["00:0e:dd:a7:6f:55"]
- "MICS-D" - names: ["MICS-D"]
- "00:0e:dd:64:3d:51" # MICS-D bridge interface macs: ["00:0e:dd:64:3d:51"]
- "MICS-E" - names: ["MICS-E"]
- "00:0e:dd:ac:fc:7d" # MICS-E bridge interface macs: ["00:0e:dd:ac:fc:7d"]
- name: LIGHTING-1 Rack - name: LIGHTING-1 Rack
nodes: nodes:
- lighting-1 - names: ["lighting-1"]
- "48:59:00:27:00:27" # Pixie Driver 8k Port 1 - names: ["Pixie Driver 8k 1 Port 1"]
- "48:59:00:37:00:27" # Pixie Driver 8k Port 2 macs: ["48:59:00:27:00:27"]
- "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 avoid: true
- "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 - names: ["Pixie Driver 8k 1 Port 2"]
- "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 macs: ["48:59:00:37:00:27"]
- "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 avoid: true
- "48:59:00:42:00:19" # Pixie Driver 8k Port 7 - names: ["Pixie Driver 8k 1 Port 3"]
- "48:59:00:44:00:19" # Pixie Driver 8k Port 8 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 - name: VIDEO Rack
nodes: nodes:
- video - names: ["video"]
- "ATEM 2 M/E Constellation 4K" - names: ["ATEM 2 M/E Constellation 4K"]
- "HyperDeck-Studio-4K-Pro" - names: ["HyperDeck-Studio-4K-Pro"]
- RX-QLAB-1 - names: ["RX-QLAB-1"]
- RX-QLAB-2 - names: ["RX-QLAB-2"]
- TX-PROJ-1 - names: ["TX-PROJ-1"]
- TX-PROJ-2 - names: ["TX-PROJ-2"]
- TX-M4 - names: ["TX-M4"]
- TX-M16 - names: ["TX-M16"]
- TX-MISC - names: ["TX-MISC"]
- TX-PREVIEW - names: ["TX-PREVIEW"]
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "Y001-MAIN1-L-d1e155" - names: ["Y001-MAIN1-L-d1e155"]
- "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface macs: ["ac:44:f2:4e:84:d6"]
- name: Stage 2 - name: Stage 2
nodes: nodes:
- "MON2" - names: ["MON2"]
- name: Stage 3 - name: Stage 3
nodes: nodes:
- "MON3" - names: ["MON3"]
- nodes: - nodes:
- "Y001-MAIN1-R-d1e194" - names: ["Y001-MAIN1-R-d1e194"]
- "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface macs: ["ac:44:f2:4e:84:d4"]
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- "RX-PROJ-1" - names: ["RX-PROJ-1"]
- nodes: - nodes:
- "RX-PROJ-2" - names: ["RX-PROJ-2"]
- direction: horizontal - direction: horizontal
children: children:
- nodes: - nodes:
- satellite-2 - names: ["satellite-2"]
- "Y001-MAIN2-L-d1e298" - names: ["Y001-MAIN2-L-d1e298"]
- "ac:44:f2:4e:87:2a" # MAIN2-L bridge interface macs: ["ac:44:f2:4e:87:2a"]
- DMXA # wash1 - names: ["DMXA"] # wash1
- DMXB # wash2 - names: ["DMXB"] # wash2
- DMXC # wash3 - names: ["DMXC"] # wash3
- DMXM # focus{1,2} - names: ["DMXM"] # focus{1,2}
- nodes: - nodes:
- DMXN # focus{3,4} - names: ["DMXN"] # focus{3,4}
- DMXP # r3x{1,2} - names: ["DMXP"] # r3x{1,2}
- nodes: - nodes:
- satellite-3 - names: ["satellite-3"]
- "Y001-MAIN2-R-f0dd93" - names: ["Y001-MAIN2-R-f0dd93"]
- "ac:44:f2:4e:87:27" # MAIN2-R bridge interface macs: ["ac:44:f2:4e:87:27"]
- DMXD # wash4 - names: ["DMXD"] # wash4
- DMXE # wash5 - names: ["DMXE"] # wash5
- DMXF # wash6 - names: ["DMXF"] # wash6
- DMXO # focus{5,6} - names: ["DMXO"] # focus{5,6}
- name: Booth - name: Booth
direction: vertical direction: vertical
children: children:
- nodes: - nodes:
- satellite-1 - names: ["satellite-1"]
- direction: horizontal - direction: horizontal
children: children:
- name: Lighting Control - name: Lighting Control
nodes: nodes:
- qlab - names: ["qlab"]
- TX-QLAB-1 - names: ["TX-QLAB-1"]
- TX-QLAB-2 - names: ["TX-QLAB-2"]
- "SK_PTZEXTREMEV2 [457081]" - names: ["SK_PTZEXTREMEV2 [457081]"]
- "SK_RACKPRO2 [452514]" - names: ["SK_RACKPRO2 [452514]"]
- pigeon - names: ["pigeon"]
- showpi1 - names: ["showpi1"]
- d8:3a:dd:e3:5b:db # showpi2/artmap - macs: ["d8:3a:dd:e3:5b:db"] # showpi2/artmap
- name: Sound Control - name: Sound Control
nodes: nodes:
- SQ-7 - names: ["SQ-7"]
- "00:04:c4:15:07:a4" # SQ-7 bridge port macs: ["00:04:c4:15:07:a4"]
- BT - names: ["BT"]
- name: Camera Control - name: Camera Control
nodes: nodes:
- RX-CC-PREVIEW - names: ["RX-CC-PREVIEW"]
- RX-CC-M16 - names: ["RX-CC-M16"]
- "AtemPanel-7c2e0da86d22" - names: ["AtemPanel-7c2e0da86d22"]
- "AtemPanel-7c2e0da86d4c" - names: ["AtemPanel-7c2e0da86d4c"]
- name: Video Control - name: Video Control
nodes: nodes:
- RX-VC-M4 - names: ["RX-VC-M4"]
- RX-VC-M16 - names: ["RX-VC-M16"]
- "ATEM-2-ME-Advanced-Panel-20" - names: ["ATEM-2-ME-Advanced-Panel-20"]
- name: Control - name: Control
nodes: nodes:
- sunset - names: ["sunset"]
- RX-CONTROL-1 - names: ["RX-CONTROL-1"]

View File

@@ -40,19 +40,20 @@ func (n *Nodes) Shutdown() {
n.cancelAll() n.cancelAll()
} }
func (n *Nodes) Update(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) { func (n *Nodes) Update(target *Node, mac net.HardwareAddr, ips []net.IP, ifaceName, nodeName, source string) *Node {
changed := n.updateLocked(target, mac, ips, ifaceName, nodeName, source) node, changed := n.updateLocked(target, mac, ips, ifaceName, nodeName, source)
if changed { if changed {
n.t.NotifyUpdate() 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() n.mu.Lock()
defer n.mu.Unlock() defer n.mu.Unlock()
if mac == nil && target == nil && len(ips) == 0 { if mac == nil && target == nil && len(ips) == 0 && nodeName == "" {
return false return nil, false
} }
node, isNew := n.resolveTargetNode(target, mac, ips, nodeName) 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) 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) { 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 { if target == nil {
return found return found
} }
if found != target && len(found.Interfaces) == 0 { if found != target {
n.mergeNodes(target, found) n.mergeNodes(target, found)
} }
return target return target
@@ -368,7 +369,7 @@ func (n *Nodes) Merge(macs []net.HardwareAddr, source string) {
} }
func (n *Nodes) mergeNodes(keep, merge *Node) { func (n *Nodes) mergeNodes(keep, merge *Node) {
if keep == nil || merge == nil { if keep == nil || merge == nil || keep == merge {
return return
} }
@@ -380,15 +381,21 @@ func (n *Nodes) mergeNodes(keep, merge *Node) {
n.nameIndex[name] = keep n.nameIndex[name] = keep
} }
for _, iface := range merge.Interfaces { for ifaceKey, iface := range merge.Interfaces {
var ips []net.IP
for ipStr := range iface.IPs {
ips = append(ips, net.ParseIP(ipStr))
}
if iface.MAC != "" { if iface.MAC != "" {
n.updateNodeInterface(keep, iface.MAC.Parse(), ips, iface.Name)
n.macIndex[string(iface.MAC)] = keep 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 { for peerMAC, ifaceName := range merge.MACTable {
@@ -620,3 +627,63 @@ func (n *Nodes) LogAll() {
n.logArtNet() n.logArtNet()
n.logDante() 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
}

View File

@@ -140,6 +140,10 @@ func (pm *PingManager) Ping(ipStr string, timeout time.Duration) bool {
func (t *Tendrils) pingNode(node *Node) { func (t *Tendrils) pingNode(node *Node) {
t.nodes.mu.RLock() t.nodes.mu.RLock()
if node.Avoid {
t.nodes.mu.RUnlock()
return
}
var ips []string var ips []string
nodeName := node.DisplayName() nodeName := node.DisplayName()
nodeID := node.ID nodeID := node.ID

View File

@@ -107,7 +107,11 @@ export function buildLocationTree(locations, parent) {
name: loc.name || '', name: loc.name || '',
anonymous: anonymous, anonymous: anonymous,
direction: loc.direction || 'horizontal', 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, parent: parent,
children: [] children: []
}; };

View File

@@ -125,6 +125,7 @@ func (t *Tendrils) Run() {
signal.Notify(sigUsr1Ch, syscall.SIGUSR1) signal.Notify(sigUsr1Ch, syscall.SIGUSR1)
go func() { go func() {
for range sigUsr1Ch { for range sigUsr1Ch {
t.nodes.ApplyConfig(t.config)
t.nodes.LogAll() t.nodes.LogAll()
} }
}() }()
@@ -139,6 +140,7 @@ func (t *Tendrils) Run() {
continue continue
} }
t.config = cfg t.config = cfg
t.nodes.ApplyConfig(cfg)
log.Printf("reloaded config from %s", t.ConfigFile) log.Printf("reloaded config from %s", t.ConfigFile)
t.NotifyUpdate() t.NotifyUpdate()
} }
@@ -149,6 +151,7 @@ func (t *Tendrils) Run() {
log.Fatalf("[ERROR] failed to load config: %v", err) log.Fatalf("[ERROR] failed to load config: %v", err)
} }
t.config = cfg t.config = cfg
t.nodes.ApplyConfig(cfg)
t.populateLocalAddresses() t.populateLocalAddresses()
t.startHTTPServer() t.startHTTPServer()
@@ -312,6 +315,10 @@ func (t *Tendrils) startInterface(ctx context.Context, iface net.Interface) {
func (t *Tendrils) pollNode(node *Node) { func (t *Tendrils) pollNode(node *Node) {
t.nodes.mu.RLock() t.nodes.mu.RLock()
if node.Avoid {
t.nodes.mu.RUnlock()
return
}
var ips []net.IP var ips []net.IP
for _, iface := range node.Interfaces { for _, iface := range node.Interfaces {
for ipStr := range iface.IPs { for ipStr := range iface.IPs {

View File

@@ -460,6 +460,7 @@ type Node struct {
SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"` SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"`
ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"` ArtmapMappings []ArtmapMapping `json:"artmap_mappings,omitempty"`
Unreachable bool `json:"unreachable,omitempty"` Unreachable bool `json:"unreachable,omitempty"`
Avoid bool `json:"avoid,omitempty"`
errors *ErrorTracker errors *ErrorTracker
pollTrigger chan struct{} pollTrigger chan struct{}
cancelFunc context.CancelFunc cancelFunc context.CancelFunc