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 = '