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

@@ -14,10 +14,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"`
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) {
if path == "" {
return &Config{}, nil
@@ -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
}

View File

@@ -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"]

View File

@@ -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
}

View File

@@ -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

View File

@@ -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: []
};

View File

@@ -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 {

View File

@@ -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