Track sACN emitters and receivers with peer linking
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
http.go
2
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(),
|
||||
|
||||
4
nodes.go
4
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]
|
||||
}
|
||||
|
||||
60
sacn.go
60
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user