From 1618ef1b875a527862bb291869540345d0fd2622 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 30 Jan 2026 13:03:35 -0800 Subject: [PATCH] Add artmap polling to discover sACN unicast receivers Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 4 +- artmap.go | 117 ++++++++++++++++++++++++++++++++++++ cmd/tendrils/main.go | 2 + sacn_discovery.go | 41 ++++++++++--- static/js/flow.js | 3 +- static/js/render.js | 12 ++-- static/js/table.js | 6 ++ tendrils.go | 5 ++ types.go | 1 + 9 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 artmap.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dfdcd16..14ef1c6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -87,7 +87,9 @@ "Bash(git -C /home/flamingcow/tendrils diff --name-only)", "Bash(git -C /home/flamingcow/artmap diff --name-only)", "Bash(git -C /home/flamingcow/sacn diff --name-only)", - "Bash(git -C /home/flamingcow/multicast diff --name-only)" + "Bash(git -C /home/flamingcow/multicast diff --name-only)", + "Bash(du:*)", + "Bash(tree:*)" ], "ask": [ "Bash(rm *)" diff --git a/artmap.go b/artmap.go new file mode 100644 index 0000000..b3f385b --- /dev/null +++ b/artmap.go @@ -0,0 +1,117 @@ +package tendrils + +import ( + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "strings" + "time" +) + +type artmapConfig struct { + Targets []artmapTarget `json:"targets"` + Mappings []artmapMapping `json:"mappings"` +} + +type artmapTarget struct { + Universe artmapUniverse `json:"universe"` + Address string `json:"address"` +} + +type artmapMapping struct { + From artmapFromAddr `json:"from"` + To artmapToAddr `json:"to"` +} + +type artmapUniverse struct { + Protocol string `json:"protocol"` + Number uint16 `json:"number"` +} + +type artmapFromAddr struct { + Universe artmapUniverse `json:"universe"` + ChannelStart int `json:"channel_start"` + ChannelEnd int `json:"channel_end"` +} + +type artmapToAddr struct { + Universe artmapUniverse `json:"universe"` + ChannelStart int `json:"channel_start"` +} + +var artmapClient = &http.Client{Timeout: 2 * time.Second} + +func (t *Tendrils) probeArtmap(ip net.IP) { + url := fmt.Sprintf("http://%s:8080/api/config", ip) + + resp, err := artmapClient.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + + server := resp.Header.Get("Server") + if server != "artmap" { + if server != "" { + log.Printf("[artmap] unexpected server header ip=%s server=%q", ip, server) + } + return + } + + var cfg artmapConfig + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + log.Printf("[artmap] decode error ip=%s: %v", ip, err) + return + } + + log.Printf("[artmap] found ip=%s targets=%d mappings=%d", ip, len(cfg.Targets), len(cfg.Mappings)) + + t.processArtmapConfig(&cfg) +} + +func (t *Tendrils) processArtmapConfig(cfg *artmapConfig) { + updated := false + for _, target := range cfg.Targets { + ip := parseTargetIP(target.Address) + if ip == nil { + log.Printf("[artmap] failed to parse target address %q", target.Address) + continue + } + + node := t.nodes.GetByIP(ip) + if node == nil { + log.Printf("[artmap] target ip=%s not found as node", ip) + continue + } + + universe := int(target.Universe.Number) + switch target.Universe.Protocol { + case "artnet": + t.nodes.UpdateArtNet(node, []int{universe}, nil) + log.Printf("[artmap] marked %s (%s) as artnet input for universe %d", node.DisplayName(), ip, universe) + case "sacn": + t.nodes.UpdateSACNUnicastInputs(node, []int{universe}) + log.Printf("[artmap] marked %s (%s) as sacn input for universe %d", node.DisplayName(), ip, universe) + default: + log.Printf("[artmap] unknown protocol %q for target %s", target.Universe.Protocol, target.Address) + continue + } + updated = true + } + if updated { + t.NotifyUpdate() + } +} + +func parseTargetIP(addr string) net.IP { + host := addr + if idx := strings.LastIndex(addr, ":"); idx != -1 { + h, _, err := net.SplitHostPort(addr) + if err == nil { + host = h + } + } + return net.ParseIP(host) +} diff --git a/cmd/tendrils/main.go b/cmd/tendrils/main.go index 5a3ecfa..389b0dd 100644 --- a/cmd/tendrils/main.go +++ b/cmd/tendrils/main.go @@ -34,6 +34,7 @@ func main() { debugShure := flag.Bool("debug-shure", false, "debug Shure discovery") debugYamaha := flag.Bool("debug-yamaha", false, "debug Yamaha discovery") debugBroadcast := flag.Bool("debug-broadcast", false, "debug broadcast traffic monitoring") + debugArtmap := flag.Bool("debug-artmap", false, "debug artmap polling") flag.Parse() t := tendrils.New() @@ -64,5 +65,6 @@ func main() { t.DebugShure = *debugShure t.DebugYamaha = *debugYamaha t.DebugBroadcast = *debugBroadcast + t.DebugArtmap = *debugArtmap t.Run() } diff --git a/sacn_discovery.go b/sacn_discovery.go index ad66f77..6af0054 100644 --- a/sacn_discovery.go +++ b/sacn_discovery.go @@ -60,8 +60,24 @@ func (n *Nodes) UpdateSACN(node *Node, outputs []int) { } } +func (n *Nodes) UpdateSACNUnicastInputs(node *Node, inputs []int) { + n.mu.Lock() + defer n.mu.Unlock() + + if node.SACNUnicastInputs == nil { + node.SACNUnicastInputs = SACNUniverseSet{} + } + + for _, u := range inputs { + node.SACNUnicastInputs.Add(SACNUniverse(u)) + } +} + func (n *Nodes) expireSACN() { for _, node := range n.nodes { + if node.SACNUnicastInputs != nil { + node.SACNUnicastInputs.Expire(60 * time.Second) + } if node.SACNOutputs != nil { node.SACNOutputs.Expire(60 * time.Second) } @@ -69,15 +85,24 @@ func (n *Nodes) expireSACN() { } func (n *Nodes) mergeSACN(keep, merge *Node) { - if merge.SACNOutputs == nil { - return + if merge.SACNUnicastInputs != nil { + if keep.SACNUnicastInputs == nil { + keep.SACNUnicastInputs = SACNUniverseSet{} + } + for u, lastSeen := range merge.SACNUnicastInputs { + if existing, ok := keep.SACNUnicastInputs[u]; !ok || lastSeen.After(existing) { + keep.SACNUnicastInputs[u] = lastSeen + } + } } - if keep.SACNOutputs == nil { - keep.SACNOutputs = SACNUniverseSet{} - } - for u, lastSeen := range merge.SACNOutputs { - if existing, ok := keep.SACNOutputs[u]; !ok || lastSeen.After(existing) { - keep.SACNOutputs[u] = lastSeen + if merge.SACNOutputs != nil { + if keep.SACNOutputs == nil { + keep.SACNOutputs = SACNUniverseSet{} + } + for u, lastSeen := range merge.SACNOutputs { + if existing, ok := keep.SACNOutputs[u]; !ok || lastSeen.After(existing) { + keep.SACNOutputs[u] = lastSeen + } } } } diff --git a/static/js/flow.js b/static/js/flow.js index 5a43e50..c81499e 100644 --- a/static/js/flow.js +++ b/static/js/flow.js @@ -131,7 +131,8 @@ export function showFlowView(flowSpec) { if (protocol === 'sacn') { if ((node.sacn_outputs || []).includes(universe)) sourceIds.push(node.id); const groups = node.multicast_groups || []; - if (groups.some(g => g === 'sacn:' + universe)) destIds.push(node.id); + const unicastInputs = node.sacn_unicast_inputs || []; + if (groups.some(g => g === 'sacn:' + universe) || unicastInputs.includes(universe)) destIds.push(node.id); } else { if ((node.artnet_outputs || []).includes(universe)) sourceIds.push(node.id); if ((node.artnet_inputs || []).includes(universe)) destIds.push(node.id); diff --git a/static/js/render.js b/static/js/render.js index 2dab224..7b74cc3 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -210,21 +210,23 @@ export function render(data, config) { const sacnUniverseInputs = new Map(); const sacnUniverseOutputs = new Map(); - function getSacnInputsFromMulticast(node) { - const groups = node.multicast_groups || []; + function getSacnInputs(node) { const inputs = []; - groups.forEach(g => { + (node.multicast_groups || []).forEach(g => { if (typeof g === 'string' && g.startsWith('sacn:')) { const u = parseInt(g.substring(5), 10); if (!isNaN(u)) inputs.push(u); } }); + (node.sacn_unicast_inputs || []).forEach(u => { + if (!inputs.includes(u)) inputs.push(u); + }); return inputs; } nodes.forEach(node => { const name = getShortLabel(node); - getSacnInputsFromMulticast(node).forEach(u => { + getSacnInputs(node).forEach(u => { if (!sacnUniverseInputs.has(u)) sacnUniverseInputs.set(u, []); sacnUniverseInputs.get(u).push(name); }); @@ -242,7 +244,7 @@ export function render(data, config) { nodes.forEach(node => { const nodeId = node.id; - const sacnInputs = getSacnInputsFromMulticast(node); + const sacnInputs = getSacnInputs(node); const sacnOutputs = node.sacn_outputs || []; if (sacnInputs.length === 0 && sacnOutputs.length === 0) return; diff --git a/static/js/table.js b/static/js/table.js index 8e25804..0974ac0 100644 --- a/static/js/table.js +++ b/static/js/table.js @@ -338,6 +338,12 @@ export function renderSacnTable() { } } }); + (node.sacn_unicast_inputs || []).forEach(u => { + if (!rxByUniverse.has(u)) rxByUniverse.set(u, []); + if (!rxByUniverse.get(u).includes(name)) { + rxByUniverse.get(u).push(name); + } + }); }); const allUniverses = new Set([...txByUniverse.keys(), ...rxByUniverse.keys()]); diff --git a/tendrils.go b/tendrils.go index 55cc0c9..e981d52 100644 --- a/tendrils.go +++ b/tendrils.go @@ -69,6 +69,7 @@ type Tendrils struct { DebugShure bool DebugYamaha bool DebugBroadcast bool + DebugArtmap bool } func New() *Tendrils { @@ -346,4 +347,8 @@ func (t *Tendrils) pollNode(node *Node) { t.probeDanteDevice(ip) } } + + for _, ip := range ips { + t.probeArtmap(ip) + } } diff --git a/types.go b/types.go index c5c8b04..703a7b3 100644 --- a/types.go +++ b/types.go @@ -450,6 +450,7 @@ type Node struct { MulticastGroups MulticastMembershipSet `json:"multicast_groups,omitempty"` ArtNetInputs ArtNetUniverseSet `json:"artnet_inputs,omitempty"` ArtNetOutputs ArtNetUniverseSet `json:"artnet_outputs,omitempty"` + SACNUnicastInputs SACNUniverseSet `json:"sacn_unicast_inputs,omitempty"` SACNOutputs SACNUniverseSet `json:"sacn_outputs,omitempty"` Unreachable bool `json:"unreachable,omitempty"` errors *ErrorTracker