add mdns hostname discovery and artnet universe tracking
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
366
artnet.go
Normal file
366
artnet.go
Normal file
@@ -0,0 +1,366 @@
|
||||
package tendrils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fvbommel/sortorder"
|
||||
)
|
||||
|
||||
const (
|
||||
artNetPort = 6454
|
||||
artNetID = "Art-Net\x00"
|
||||
opPoll = 0x2000
|
||||
opPollReply = 0x2100
|
||||
protocolVersion = 14
|
||||
)
|
||||
|
||||
type ArtNetNode struct {
|
||||
IP net.IP
|
||||
MAC net.HardwareAddr
|
||||
ShortName string
|
||||
LongName string
|
||||
Inputs []int
|
||||
Outputs []int
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
func (t *Tendrils) listenArtNet(ctx context.Context, iface net.Interface) {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var srcIP net.IP
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
||||
srcIP = ipnet.IP.To4()
|
||||
break
|
||||
}
|
||||
}
|
||||
if srcIP == nil {
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: srcIP, Port: artNetPort})
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] failed to listen artnet on %s: %v", iface.Name, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
go t.runArtNetPoller(ctx, iface, conn)
|
||||
|
||||
buf := make([]byte, 65536)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||
n, src, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
t.handleArtNetPacket(iface.Name, src.IP, buf[:n])
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tendrils) handleArtNetPacket(ifaceName string, srcIP net.IP, data []byte) {
|
||||
if len(data) < 12 {
|
||||
return
|
||||
}
|
||||
|
||||
if string(data[:8]) != artNetID {
|
||||
return
|
||||
}
|
||||
|
||||
opcode := binary.LittleEndian.Uint16(data[8:10])
|
||||
|
||||
switch opcode {
|
||||
case opPollReply:
|
||||
t.handleArtPollReply(ifaceName, srcIP, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tendrils) handleArtPollReply(ifaceName string, srcIP net.IP, data []byte) {
|
||||
if len(data) < 207 {
|
||||
return
|
||||
}
|
||||
|
||||
ip := net.IPv4(data[10], data[11], data[12], data[13])
|
||||
|
||||
var mac net.HardwareAddr
|
||||
if len(data) >= 207 {
|
||||
mac = net.HardwareAddr(data[201:207])
|
||||
}
|
||||
|
||||
shortName := strings.TrimRight(string(data[26:44]), "\x00")
|
||||
longName := strings.TrimRight(string(data[44:108]), "\x00")
|
||||
|
||||
netSwitch := int(data[18])
|
||||
subSwitch := int(data[19])
|
||||
|
||||
numPorts := int(data[173])
|
||||
if numPorts > 4 {
|
||||
numPorts = 4
|
||||
}
|
||||
|
||||
var inputs, outputs []int
|
||||
for i := 0; i < numPorts; i++ {
|
||||
portType := data[174+i]
|
||||
swIn := int(data[186+i])
|
||||
swOut := int(data[190+i])
|
||||
|
||||
universe := netSwitch<<8 | subSwitch<<4
|
||||
|
||||
if portType&0x40 != 0 {
|
||||
inputs = append(inputs, universe|swIn)
|
||||
}
|
||||
if portType&0x80 != 0 {
|
||||
outputs = append(outputs, universe|swOut)
|
||||
}
|
||||
}
|
||||
|
||||
if t.DebugArtNet {
|
||||
log.Printf("[artnet] %s: %s %s short=%q long=%q numPorts=%d portTypes=%v in=%v out=%v", ifaceName, ip, mac, shortName, longName, numPorts, data[174:178], inputs, outputs)
|
||||
}
|
||||
|
||||
node := &ArtNetNode{
|
||||
IP: ip,
|
||||
MAC: mac,
|
||||
ShortName: shortName,
|
||||
LongName: longName,
|
||||
Inputs: inputs,
|
||||
Outputs: outputs,
|
||||
LastSeen: time.Now(),
|
||||
}
|
||||
|
||||
t.nodes.UpdateArtNet(node)
|
||||
|
||||
name := longName
|
||||
if name == "" {
|
||||
name = shortName
|
||||
}
|
||||
if name != "" {
|
||||
t.nodes.Update(nil, mac, []net.IP{ip}, "", name, "artnet")
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tendrils) runArtNetPoller(ctx context.Context, iface net.Interface, conn *net.UDPConn) {
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var broadcast net.IP
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && ipnet.IP.To4() != nil {
|
||||
ip := ipnet.IP.To4()
|
||||
mask := ipnet.Mask
|
||||
broadcast = make(net.IP, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
broadcast[i] = ip[i] | ^mask[i]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if broadcast == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
t.sendArtPoll(conn, broadcast, iface.Name)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
t.sendArtPoll(conn, broadcast, iface.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Tendrils) sendArtPoll(conn *net.UDPConn, broadcast net.IP, ifaceName string) {
|
||||
packet := make([]byte, 14)
|
||||
copy(packet[0:8], artNetID)
|
||||
binary.LittleEndian.PutUint16(packet[8:10], opPoll)
|
||||
binary.LittleEndian.PutUint16(packet[10:12], protocolVersion)
|
||||
packet[12] = 0x00
|
||||
packet[13] = 0x00
|
||||
|
||||
_, err := conn.WriteToUDP(packet, &net.UDPAddr{IP: broadcast, Port: artNetPort})
|
||||
if err != nil {
|
||||
if t.DebugArtNet {
|
||||
log.Printf("[artnet] %s: failed to send poll: %v", ifaceName, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if t.DebugArtNet {
|
||||
log.Printf("[artnet] %s: sent poll to %s", ifaceName, broadcast)
|
||||
}
|
||||
}
|
||||
|
||||
type ArtNetNodes struct {
|
||||
mu sync.RWMutex
|
||||
nodes map[string]*ArtNetNode
|
||||
}
|
||||
|
||||
func NewArtNetNodes() *ArtNetNodes {
|
||||
return &ArtNetNodes{
|
||||
nodes: map[string]*ArtNetNode{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtNetNodes) Update(node *ArtNetNode) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
key := node.IP.String()
|
||||
|
||||
existing, exists := a.nodes[key]
|
||||
if exists {
|
||||
for _, u := range node.Inputs {
|
||||
if !containsInt(existing.Inputs, u) {
|
||||
existing.Inputs = append(existing.Inputs, u)
|
||||
}
|
||||
}
|
||||
for _, u := range node.Outputs {
|
||||
if !containsInt(existing.Outputs, u) {
|
||||
existing.Outputs = append(existing.Outputs, u)
|
||||
}
|
||||
}
|
||||
existing.LastSeen = node.LastSeen
|
||||
if node.ShortName != "" {
|
||||
existing.ShortName = node.ShortName
|
||||
}
|
||||
if node.LongName != "" {
|
||||
existing.LongName = node.LongName
|
||||
}
|
||||
} else {
|
||||
a.nodes[key] = node
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(slice []int, val int) bool {
|
||||
for _, v := range slice {
|
||||
if v == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ArtNetNodes) Expire() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
expireTime := time.Now().Add(-60 * time.Second)
|
||||
for key, node := range a.nodes {
|
||||
if node.LastSeen.Before(expireTime) {
|
||||
delete(a.nodes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtNetNodes) GetAll() []*ArtNetNode {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
result := make([]*ArtNetNode, 0, len(a.nodes))
|
||||
for _, node := range a.nodes {
|
||||
result = append(result, node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *ArtNetNodes) LogAll() {
|
||||
a.Expire()
|
||||
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
if len(a.nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var nodes []*ArtNetNode
|
||||
for _, node := range a.nodes {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
return sortorder.NaturalLess(nodes[i].LongName, nodes[j].LongName)
|
||||
})
|
||||
|
||||
inputUniverses := map[int][]string{}
|
||||
outputUniverses := map[int][]string{}
|
||||
|
||||
for _, node := range nodes {
|
||||
name := node.LongName
|
||||
if name == "" {
|
||||
name = node.ShortName
|
||||
}
|
||||
if name == "" {
|
||||
name = node.IP.String()
|
||||
}
|
||||
for _, u := range node.Inputs {
|
||||
inputUniverses[u] = append(inputUniverses[u], name)
|
||||
}
|
||||
for _, u := range node.Outputs {
|
||||
outputUniverses[u] = append(outputUniverses[u], name)
|
||||
}
|
||||
}
|
||||
|
||||
var allUniverses []int
|
||||
seen := map[int]bool{}
|
||||
for u := range inputUniverses {
|
||||
if !seen[u] {
|
||||
allUniverses = append(allUniverses, u)
|
||||
seen[u] = true
|
||||
}
|
||||
}
|
||||
for u := range outputUniverses {
|
||||
if !seen[u] {
|
||||
allUniverses = append(allUniverses, u)
|
||||
seen[u] = true
|
||||
}
|
||||
}
|
||||
sort.Ints(allUniverses)
|
||||
|
||||
log.Printf("[sigusr1] ================ %d artnet universes ================", len(allUniverses))
|
||||
for _, u := range allUniverses {
|
||||
ins := inputUniverses[u]
|
||||
outs := outputUniverses[u]
|
||||
var parts []string
|
||||
if len(ins) > 0 {
|
||||
sort.Slice(ins, func(i, j int) bool { return sortorder.NaturalLess(ins[i], ins[j]) })
|
||||
parts = append(parts, fmt.Sprintf("in:%v", ins))
|
||||
}
|
||||
if len(outs) > 0 {
|
||||
sort.Slice(outs, func(i, j int) bool { return sortorder.NaturalLess(outs[i], outs[j]) })
|
||||
parts = append(parts, fmt.Sprintf("out:%v", outs))
|
||||
}
|
||||
net := (u >> 8) & 0x7f
|
||||
subnet := (u >> 4) & 0x0f
|
||||
universe := u & 0x0f
|
||||
log.Printf("[sigusr1] artnet:%d (%d/%d/%d) %s", u, net, subnet, universe, strings.Join(parts, " "))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Nodes) UpdateArtNet(artNode *ArtNetNode) {
|
||||
n.t.artnet.Update(artNode)
|
||||
}
|
||||
Reference in New Issue
Block a user