From 7aac3c055988819f44e3911de80bbb368f439190 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 28 Jan 2026 21:27:35 -0800 Subject: [PATCH] Track sACN emitters and receivers with peer linking Co-Authored-By: Claude Opus 4.5 --- http.go | 2 - nodes.go | 4 ++ sacn.go | 60 +++++++++++++++++----- sacn_discovery.go | 60 +++++++++++----------- static/index.html | 127 +++++++++++++++++++++++++++++++++++++++------- 5 files changed, 190 insertions(+), 63 deletions(-) diff --git a/http.go b/http.go index e0a8a93..47b0699 100644 --- a/http.go +++ b/http.go @@ -31,7 +31,6 @@ type StatusResponse struct { MulticastGroups []*MulticastGroupMembers `json:"multicast_groups"` ArtNetNodes []*ArtNetNode `json:"artnet_nodes"` SACNNodes []*SACNNode `json:"sacn_nodes"` - SACNSources []*SACNSource `json:"sacn_sources"` DanteFlows []*DanteFlow `json:"dante_flows"` PortErrors []*PortError `json:"port_errors"` UnreachableNodes []string `json:"unreachable_nodes"` @@ -142,7 +141,6 @@ func (t *Tendrils) GetStatus() *StatusResponse { MulticastGroups: t.getMulticastGroups(), ArtNetNodes: t.getArtNetNodes(), SACNNodes: t.getSACNNodes(), - SACNSources: t.getSACNSources(), DanteFlows: t.getDanteFlows(), PortErrors: t.errors.GetErrors(), UnreachableNodes: t.errors.GetUnreachableNodes(), diff --git a/nodes.go b/nodes.go index b7ff7ad..295c72f 100644 --- a/nodes.go +++ b/nodes.go @@ -445,6 +445,10 @@ func (n *Nodes) GetByIP(ip net.IP) *Node { n.mu.RLock() defer n.mu.RUnlock() + return n.getByIPLocked(ip) +} + +func (n *Nodes) getByIPLocked(ip net.IP) *Node { if id, exists := n.ipIndex[ip.String()]; exists { return n.nodes[id] } diff --git a/sacn.go b/sacn.go index c155d4b..bfedeb4 100644 --- a/sacn.go +++ b/sacn.go @@ -9,9 +9,10 @@ import ( ) type SACNNode struct { - TypeID string `json:"typeid"` - Node *Node `json:"node"` - Universes []int `json:"universes"` + TypeID string `json:"typeid"` + Node *Node `json:"node"` + Inputs []int `json:"inputs,omitempty"` + Outputs []int `json:"outputs,omitempty"` } func (t *Tendrils) getSACNNodes() []*SACNNode { @@ -19,10 +20,13 @@ func (t *Tendrils) getSACNNodes() []*SACNNode { t.nodes.expireMulticastMemberships() t.nodes.mu.Unlock() + t.sacnSources.Expire() + t.nodes.mu.RLock() defer t.nodes.mu.RUnlock() - nodeUniverses := map[*Node][]int{} + nodeInputs := map[*Node][]int{} + nodeOutputs := map[*Node][]int{} for _, gm := range t.nodes.multicastGroups { if !strings.HasPrefix(gm.Group.Name, "sacn:") { @@ -38,20 +42,50 @@ func (t *Tendrils) getSACNNodes() []*SACNNode { if membership.Node == nil { continue } - universes := nodeUniverses[membership.Node] - if !containsInt(universes, universe) { - nodeUniverses[membership.Node] = append(universes, universe) + inputs := nodeInputs[membership.Node] + if !containsInt(inputs, universe) { + nodeInputs[membership.Node] = append(inputs, universe) } } } - result := make([]*SACNNode, 0, len(nodeUniverses)) - for node, universes := range nodeUniverses { - sort.Ints(universes) + t.sacnSources.mu.RLock() + for _, source := range t.sacnSources.sources { + if source.SrcIP == nil { + continue + } + node := t.nodes.getByIPLocked(source.SrcIP) + if node == nil { + continue + } + for _, u := range source.Universes { + outputs := nodeOutputs[node] + if !containsInt(outputs, u) { + nodeOutputs[node] = append(outputs, u) + } + } + } + t.sacnSources.mu.RUnlock() + + allNodes := map[*Node]bool{} + for node := range nodeInputs { + allNodes[node] = true + } + for node := range nodeOutputs { + allNodes[node] = true + } + + result := make([]*SACNNode, 0, len(allNodes)) + for node := range allNodes { + inputs := nodeInputs[node] + outputs := nodeOutputs[node] + sort.Ints(inputs) + sort.Ints(outputs) result = append(result, &SACNNode{ - TypeID: newTypeID("sacnnode"), - Node: node, - Universes: universes, + TypeID: newTypeID("sacnnode"), + Node: node, + Inputs: inputs, + Outputs: outputs, }) } diff --git a/sacn_discovery.go b/sacn_discovery.go index 21f77d0..9a4d5e2 100644 --- a/sacn_discovery.go +++ b/sacn_discovery.go @@ -5,12 +5,10 @@ import ( "encoding/binary" "log" "net" - "sort" "strings" "sync" "time" - "github.com/fvbommel/sortorder" "golang.org/x/net/ipv4" ) @@ -28,12 +26,11 @@ var sacnPacketIdentifier = [12]byte{ } type SACNSource struct { - TypeID string `json:"typeid"` - Node *Node `json:"node"` - SourceName string `json:"source_name"` - CID string `json:"cid"` - Universes []int `json:"universes"` - LastSeen time.Time `json:"last_seen"` + CID string + SourceName string + Universes []int + SrcIP net.IP + LastSeen time.Time } type SACNSources struct { @@ -56,25 +53,29 @@ func (s *SACNSources) Update(cid [16]byte, sourceName string, universes []int, s if exists { existing.SourceName = sourceName existing.Universes = universes + existing.SrcIP = srcIP existing.LastSeen = time.Now() } else { s.sources[cidStr] = &SACNSource{ - TypeID: newTypeID("sacnsource"), - SourceName: sourceName, CID: cidStr, + SourceName: sourceName, Universes: universes, + SrcIP: srcIP, LastSeen: time.Now(), } } } -func (s *SACNSources) SetNode(cid string, node *Node) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *SACNSources) GetByIP(ip net.IP) *SACNSource { + s.mu.RLock() + defer s.mu.RUnlock() - if source, exists := s.sources[cid]; exists { - source.Node = node + for _, source := range s.sources { + if source.SrcIP != nil && source.SrcIP.Equal(ip) { + return source + } } + return nil } func (s *SACNSources) Expire() { @@ -154,7 +155,7 @@ func (t *Tendrils) startSACNDiscoveryListener(ctx context.Context, iface net.Int } c.SetReadDeadline(time.Now().Add(1 * time.Second)) - n, _, err := c.ReadFrom(buf) + n, src, err := c.ReadFrom(buf) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { continue @@ -162,11 +163,16 @@ func (t *Tendrils) startSACNDiscoveryListener(ctx context.Context, iface net.Int continue } - t.handleSACNDiscoveryPacket(buf[:n]) + var srcIP net.IP + if udpAddr, ok := src.(*net.UDPAddr); ok { + srcIP = udpAddr.IP + } + + t.handleSACNDiscoveryPacket(buf[:n], srcIP) } } -func (t *Tendrils) handleSACNDiscoveryPacket(data []byte) { +func (t *Tendrils) handleSACNDiscoveryPacket(data []byte, srcIP net.IP) { if len(data) < 120 { return } @@ -206,20 +212,14 @@ func (t *Tendrils) handleSACNDiscoveryPacket(data []byte) { } if t.DebugSACN { - log.Printf("[sacn] discovery from %q cid=%s universes=%v", sourceName, formatCID(cid), universes) + log.Printf("[sacn] discovery from %q cid=%s ip=%s universes=%v", sourceName, formatCID(cid), srcIP, universes) } - t.sacnSources.Update(cid, sourceName, universes, nil) + if srcIP != nil && sourceName != "" { + t.nodes.Update(nil, nil, []net.IP{srcIP}, "", sourceName, "sacn") + } + + t.sacnSources.Update(cid, sourceName, universes, srcIP) t.NotifyUpdate() } -func (t *Tendrils) getSACNSources() []*SACNSource { - t.sacnSources.Expire() - - sources := t.sacnSources.GetAll() - sort.Slice(sources, func(i, j int) bool { - return sortorder.NaturalLess(sources[i].SourceName, sources[j].SourceName) - }) - - return sources -} diff --git a/static/index.html b/static/index.html index 08afd0a..96447d2 100644 --- a/static/index.html +++ b/static/index.html @@ -535,9 +535,18 @@ opacity: 0.3; } - body.sacn-mode .node.sacn-consumer { + body.sacn-mode .node.sacn-out { opacity: 1; - background: #26d; + background: #287; + } + + body.sacn-mode .node.sacn-in { + opacity: 1; + background: #268; + } + + body.sacn-mode .node.sacn-out.sacn-in { + background: linear-gradient(135deg, #287 50%, #268 50%); } body.sacn-mode .node .switch-port, @@ -553,13 +562,20 @@ padding: 1px 6px; border-radius: 8px; white-space: nowrap; - background: #468; color: #fff; max-width: 114px; overflow: hidden; text-overflow: ellipsis; } + .node .sacn-info.out-info { + background: #254; + } + + .node .sacn-info.in-info { + background: #245; + } + .node .sacn-hover { position: absolute; top: -8px; @@ -607,10 +623,23 @@ will-change: transform; } - body.sacn-mode .node.sacn-consumer .sacn-info { + body.sacn-mode .node.sacn-out .sacn-info, + body.sacn-mode .node.sacn-in .sacn-info { display: block; } + body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover { + top: auto; + bottom: -8px; + } + + body.sacn-mode .node.sacn-out.sacn-in .sacn-in-hover .sacn-detail-wrapper { + bottom: auto; + top: 100%; + padding-bottom: 0; + padding-top: 8px; + } + .sacn-info .lbl, .sacn-detail .lbl { color: #888; @@ -673,7 +702,7 @@ display: none; } - body.sacn-mode .node:not(.sacn-consumer):hover .node-info-wrapper { + body.sacn-mode .node:not(.sacn-out):not(.sacn-in):hover .node-info-wrapper { display: none; } @@ -1226,7 +1255,8 @@ if (danteInfo?.isRx) div.classList.add('dante-rx'); if (artnetInfo?.isOut) div.classList.add('artnet-out'); if (artnetInfo?.isIn) div.classList.add('artnet-in'); - if (sacnInfo?.isConsumer) div.classList.add('sacn-consumer'); + if (sacnInfo?.isOut) div.classList.add('sacn-out'); + if (sacnInfo?.isIn) div.classList.add('sacn-in'); // Switch port connection if (!isSwitch(node) && switchConnection) { @@ -1463,24 +1493,49 @@ if (container) container.remove(); } - // sACN - if (sacnInfo?.isConsumer) { - let container = div.querySelector(':scope > .sacn-hover'); + // sACN out + if (sacnInfo?.isOut) { + let container = div.querySelector(':scope > .sacn-out-hover'); if (!container) { container = document.createElement('div'); - container.className = 'sacn-hover'; - container.innerHTML = '
'; + container.className = 'sacn-hover sacn-out-hover'; + container.innerHTML = '
'; div.appendChild(container); } const textEl = container.querySelector('.sacn-pill-text'); - const sacnMore = sacnInfo.universes.length > 1 ? ', ...' : ''; - textEl.textContent = sacnInfo.universes[0] + sacnMore; + const firstOut = sacnInfo.outputs[0]; + const outLabel = firstOut.firstTarget || firstOut.display; + const outMore = sacnInfo.outputs.length > 1 ? ', ...' : ''; + textEl.textContent = outLabel + outMore; const detail = container.querySelector('.sacn-detail'); detail.innerHTML = ''; - buildClickableList(detail, sacnInfo.universes, '←', (l, v) => l + ' ' + v); + buildClickableList(detail, sacnInfo.outputs.map(o => o.display), '←', (l, v) => l + ' ' + v); } else { - const container = div.querySelector(':scope > .sacn-hover'); + const container = div.querySelector(':scope > .sacn-out-hover'); + if (container) container.remove(); + } + + // sACN in + if (sacnInfo?.isIn) { + let container = div.querySelector(':scope > .sacn-in-hover'); + if (!container) { + container = document.createElement('div'); + container.className = 'sacn-hover sacn-in-hover'; + container.innerHTML = '
'; + div.appendChild(container); + } + const textEl = container.querySelector('.sacn-pill-text'); + const firstIn = sacnInfo.inputs[0]; + const inLabel = firstIn.firstTarget || firstIn.display; + const inMore = sacnInfo.inputs.length > 1 ? ', ...' : ''; + textEl.textContent = inLabel + inMore; + + const detail = container.querySelector('.sacn-detail'); + detail.innerHTML = ''; + buildClickableList(detail, sacnInfo.inputs.map(i => i.display), '→', (l, v) => l + ' ' + v); + } else { + const container = div.querySelector(':scope > .sacn-in-hover'); if (container) container.remove(); } @@ -1944,15 +1999,51 @@ const sacnData = data.sacn_nodes || []; const sacnNodes = new Map(); + const sacnUniverseInputs = new Map(); + const sacnUniverseOutputs = new Map(); + + sacnData.forEach(sn => { + const name = getShortLabel(sn.node); + (sn.inputs || []).forEach(u => { + if (!sacnUniverseInputs.has(u)) sacnUniverseInputs.set(u, []); + sacnUniverseInputs.get(u).push(name); + }); + (sn.outputs || []).forEach(u => { + if (!sacnUniverseOutputs.has(u)) sacnUniverseOutputs.set(u, []); + sacnUniverseOutputs.get(u).push(name); + }); + }); + + const sacnCollapseNames = (names) => { + const counts = {}; + names.forEach(n => counts[n] = (counts[n] || 0) + 1); + return Object.entries(counts).map(([name, count]) => count > 1 ? name + ' x' + count : name); + }; + sacnData.forEach(sn => { const nodeId = sn.node?.typeid; if (!nodeId) return; - const universes = (sn.universes || []).slice().sort((a, b) => a - b).map(u => String(u)); + const inputs = (sn.inputs || []).slice().sort((a, b) => a - b).map(u => { + const sources = sacnCollapseNames(sacnUniverseOutputs.get(u) || []); + if (sources.length > 0) { + return { display: sources[0] + ' [' + u + ']', firstTarget: sources[0] }; + } + return { display: String(u), firstTarget: null }; + }); + const outputs = (sn.outputs || []).slice().sort((a, b) => a - b).map(u => { + const dests = sacnCollapseNames(sacnUniverseInputs.get(u) || []); + if (dests.length > 0) { + return { display: dests[0] + ' [' + u + ']', firstTarget: dests[0] }; + } + return { display: String(u), firstTarget: null }; + }); sacnNodes.set(nodeId, { - isConsumer: universes.length > 0, - universes: universes + isOut: outputs.length > 0, + isIn: inputs.length > 0, + outputs: outputs, + inputs: inputs }); });