Config changes: - Use protocol prefixes in addresses: "sacn:64:361-450" -> "artnet:0.0.0:1" - Remove separate from_proto/proto fields - Targets now include protocol: universe = "artnet:0.0.0" CLI changes: - Rename --listen to --artnet-listen (empty to disable) - Fix --debug help text Logging changes: - Use [<-proto] and [->proto] prefixes for direction - Consistent lowercase key=value format - Refactor duplicate send code into sendOutputs() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
230 lines
4.7 KiB
Go
230 lines
4.7 KiB
Go
package artnet
|
|
|
|
import (
|
|
"log"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Node represents a discovered ArtNet node
|
|
type Node struct {
|
|
IP net.IP
|
|
Port uint16
|
|
ShortName string
|
|
LongName string
|
|
Universes []Universe
|
|
LastSeen time.Time
|
|
CanTransmit bool
|
|
}
|
|
|
|
// Discovery handles ArtNet node discovery
|
|
type Discovery struct {
|
|
sender *Sender
|
|
nodes map[string]*Node // keyed by IP string
|
|
nodesMu sync.RWMutex
|
|
localIP [4]byte
|
|
shortName string
|
|
longName string
|
|
universes []Universe
|
|
pollTargets []*net.UDPAddr
|
|
done chan struct{}
|
|
}
|
|
|
|
// NewDiscovery creates a new discovery handler
|
|
func NewDiscovery(sender *Sender, shortName, longName string, universes []Universe, pollTargets []*net.UDPAddr) *Discovery {
|
|
return &Discovery{
|
|
sender: sender,
|
|
nodes: make(map[string]*Node),
|
|
shortName: shortName,
|
|
longName: longName,
|
|
universes: universes,
|
|
pollTargets: pollTargets,
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins periodic discovery
|
|
func (d *Discovery) Start() {
|
|
// Get local IP
|
|
d.localIP = d.getLocalIP()
|
|
|
|
// Start periodic poll
|
|
go d.pollLoop()
|
|
}
|
|
|
|
// Stop stops discovery
|
|
func (d *Discovery) Stop() {
|
|
close(d.done)
|
|
}
|
|
|
|
func (d *Discovery) pollLoop() {
|
|
// Send initial poll to all targets
|
|
d.sendPolls()
|
|
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
cleanupTicker := time.NewTicker(30 * time.Second)
|
|
defer cleanupTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-d.done:
|
|
return
|
|
case <-ticker.C:
|
|
d.sendPolls()
|
|
case <-cleanupTicker.C:
|
|
d.cleanup()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Discovery) sendPolls() {
|
|
for _, target := range d.pollTargets {
|
|
if err := d.sender.SendPoll(target); err != nil {
|
|
log.Printf("[->artnet] poll error: dst=%s err=%v", target.IP, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Discovery) cleanup() {
|
|
d.nodesMu.Lock()
|
|
defer d.nodesMu.Unlock()
|
|
|
|
cutoff := time.Now().Add(-60 * time.Second)
|
|
for ip, node := range d.nodes {
|
|
if node.LastSeen.Before(cutoff) {
|
|
log.Printf("discovery timeout: ip=%s name=%s", ip, node.ShortName)
|
|
delete(d.nodes, ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandlePollReply processes an incoming ArtPollReply
|
|
func (d *Discovery) HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) {
|
|
d.nodesMu.Lock()
|
|
defer d.nodesMu.Unlock()
|
|
|
|
ip := src.IP.String()
|
|
|
|
// Skip our own replies
|
|
localIP := net.IP(d.localIP[:])
|
|
if src.IP.Equal(localIP) {
|
|
return
|
|
}
|
|
|
|
// Parse universes from SwOut
|
|
var universes []Universe
|
|
numPorts := int(pkt.NumPortsLo)
|
|
if numPorts > 4 {
|
|
numPorts = 4
|
|
}
|
|
|
|
for i := 0; i < numPorts; i++ {
|
|
// Check if port can output DMX
|
|
if pkt.PortTypes[i]&0x80 != 0 {
|
|
u := NewUniverse(pkt.NetSwitch, pkt.SubSwitch, pkt.SwOut[i])
|
|
universes = append(universes, u)
|
|
}
|
|
}
|
|
|
|
shortName := string(pkt.ShortName[:])
|
|
// Trim null bytes
|
|
for i, b := range pkt.ShortName {
|
|
if b == 0 {
|
|
shortName = string(pkt.ShortName[:i])
|
|
break
|
|
}
|
|
}
|
|
|
|
longName := string(pkt.LongName[:])
|
|
for i, b := range pkt.LongName {
|
|
if b == 0 {
|
|
longName = string(pkt.LongName[:i])
|
|
break
|
|
}
|
|
}
|
|
|
|
node, exists := d.nodes[ip]
|
|
if !exists {
|
|
node = &Node{
|
|
IP: src.IP,
|
|
Port: uint16(src.Port),
|
|
}
|
|
d.nodes[ip] = node
|
|
log.Printf("discovery found: ip=%s name=%s universes=%v", ip, shortName, universes)
|
|
}
|
|
|
|
node.ShortName = shortName
|
|
node.LongName = longName
|
|
node.Universes = universes
|
|
node.LastSeen = time.Now()
|
|
node.CanTransmit = true
|
|
}
|
|
|
|
// HandlePoll processes an incoming ArtPoll and responds
|
|
func (d *Discovery) HandlePoll(src *net.UDPAddr) {
|
|
// Respond with our info
|
|
err := d.sender.SendPollReply(src, d.localIP, d.shortName, d.longName, d.universes)
|
|
if err != nil {
|
|
log.Printf("[->artnet] pollreply error: dst=%s err=%v", src.IP, err)
|
|
}
|
|
}
|
|
|
|
// GetNodesForUniverse returns nodes that support a given universe
|
|
func (d *Discovery) GetNodesForUniverse(universe Universe) []*Node {
|
|
d.nodesMu.RLock()
|
|
defer d.nodesMu.RUnlock()
|
|
|
|
var result []*Node
|
|
for _, node := range d.nodes {
|
|
for _, u := range node.Universes {
|
|
if u == universe {
|
|
result = append(result, node)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetAllNodes returns all discovered nodes
|
|
func (d *Discovery) GetAllNodes() []*Node {
|
|
d.nodesMu.RLock()
|
|
defer d.nodesMu.RUnlock()
|
|
|
|
result := make([]*Node, 0, len(d.nodes))
|
|
for _, node := range d.nodes {
|
|
result = append(result, node)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (d *Discovery) getLocalIP() [4]byte {
|
|
var result [4]byte
|
|
|
|
addrs, err := net.InterfaceAddrs()
|
|
if err != nil {
|
|
return result
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
|
if ip4 := ipnet.IP.To4(); ip4 != nil {
|
|
copy(result[:], ip4)
|
|
return result
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// SetLocalIP sets the local IP for PollReply responses
|
|
func (d *Discovery) SetLocalIP(ip net.IP) {
|
|
if ip4 := ip.To4(); ip4 != nil {
|
|
copy(d.localIP[:], ip4)
|
|
}
|
|
}
|