Track sACN emitters and receivers with peer linking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-28 21:27:35 -08:00
parent c6109c28f0
commit 7aac3c0559
5 changed files with 190 additions and 63 deletions

View File

@@ -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(),

View File

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

52
sacn.go
View File

@@ -11,7 +11,8 @@ import (
type SACNNode struct {
TypeID string `json:"typeid"`
Node *Node `json:"node"`
Universes []int `json:"universes"`
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,
Inputs: inputs,
Outputs: outputs,
})
}

View File

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

View File

@@ -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 = '<div class="sacn-info"><span class="lbl">←</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
container.className = 'sacn-hover sacn-out-hover';
container.innerHTML = '<div class="sacn-info out-info"><span class="lbl">←</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
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 class="sacn-info in-info"><span class="lbl">→</span> <span class="sacn-pill-text"></span></div><div class="sacn-detail-wrapper"><div class="sacn-detail"></div></div>';
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
});
});