Add artmap polling to discover sACN unicast receivers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-30 13:03:35 -08:00
parent e3aa25d85f
commit 1618ef1b87
9 changed files with 176 additions and 15 deletions

View File

@@ -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 *)"

117
artmap.go Normal file
View File

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

View File

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

View File

@@ -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,9 +85,17 @@ 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 merge.SACNOutputs != nil {
if keep.SACNOutputs == nil {
keep.SACNOutputs = SACNUniverseSet{}
}
@@ -81,3 +105,4 @@ func (n *Nodes) mergeSACN(keep, merge *Node) {
}
}
}
}

View File

@@ -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);

View File

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

View File

@@ -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()]);

View File

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

View File

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