Add artmap polling to discover sACN unicast receivers
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
117
artmap.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
1
types.go
1
types.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user