From 8e24dee91f7516933a3f2a63ce0eaa7052808165 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 28 Jan 2026 10:27:52 -0800 Subject: [PATCH] Use shared artnet library --- artnet/discovery.go | 312 -------------------------------------------- artnet/protocol.go | 299 ------------------------------------------ artnet/receiver.go | 104 --------------- artnet/sender.go | 61 --------- main.go | 2 +- 5 files changed, 1 insertion(+), 777 deletions(-) delete mode 100644 artnet/discovery.go delete mode 100644 artnet/protocol.go delete mode 100644 artnet/receiver.go delete mode 100644 artnet/sender.go diff --git a/artnet/discovery.go b/artnet/discovery.go deleted file mode 100644 index a037499..0000000 --- a/artnet/discovery.go +++ /dev/null @@ -1,312 +0,0 @@ -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 - receiver *Receiver - nodes map[string]*Node // keyed by IP string - nodesMu sync.RWMutex - localIP [4]byte - localMAC [6]byte - broadcast net.IP - shortName string - longName string - inputUnivs []Universe // universes we transmit TO (SwIn) - outputUnivs []Universe // universes we receive FROM (SwOut) - pollTargets []*net.UDPAddr - done chan struct{} -} - -// NewDiscovery creates a new discovery handler -func NewDiscovery(sender *Sender, shortName, longName string, inputUnivs, outputUnivs []Universe, pollTargets []*net.UDPAddr) *Discovery { - return &Discovery{ - sender: sender, - nodes: make(map[string]*Node), - shortName: shortName, - longName: longName, - inputUnivs: inputUnivs, - outputUnivs: outputUnivs, - pollTargets: pollTargets, - done: make(chan struct{}), - } -} - -// Start begins periodic discovery -func (d *Discovery) Start() { - d.detectInterface() - 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("[artnet] node 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: pkt.Port, // Use port from packet, not UDP source port - } - d.nodes[ip] = node - } - - node.ShortName = shortName - node.LongName = longName - node.LastSeen = time.Now() - node.CanTransmit = true - - // Accumulate universes from multiple ArtPollReply packets - // (multi-port devices send separate replies for each group of 4 ports) - prevLen := len(node.Universes) - for _, u := range universes { - found := false - for _, existing := range node.Universes { - if existing == u { - found = true - break - } - } - if !found { - node.Universes = append(node.Universes, u) - } - } - - if !exists { - log.Printf("[artnet] discovered ip=%s name=%s universes=%v", ip, shortName, node.Universes) - } else if len(node.Universes) != prevLen { - log.Printf("[artnet] updated ip=%s name=%s universes=%v", ip, shortName, node.Universes) - } -} - -// HandlePoll processes an incoming ArtPoll and responds -func (d *Discovery) HandlePoll(src *net.UDPAddr) { - dst := &net.UDPAddr{IP: d.broadcast, Port: Port} - d.sendPollReplies(dst, d.inputUnivs, true) - d.sendPollReplies(dst, d.outputUnivs, false) -} - -func (d *Discovery) sendPollReplies(dst *net.UDPAddr, universes []Universe, isInput bool) { - groups := make(map[uint16][]Universe) - for _, u := range universes { - key := uint16(u.Net())<<8 | uint16(u.SubNet())<<4 - groups[key] = append(groups[key], u) - } - - for _, univs := range groups { - for i := 0; i < len(univs); i += 4 { - end := i + 4 - if end > len(univs) { - end = len(univs) - } - chunk := univs[i:end] - - pkt := BuildPollReplyPacket(d.localIP, d.localMAC, d.shortName, d.longName, chunk, isInput) - err := d.receiver.SendTo(pkt, dst) - if err != nil { - log.Printf("[->artnet] pollreply error: dst=%s err=%v", dst.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 - } - } - } - - if len(result) == 0 && len(d.nodes) > 0 { - log.Printf("[artnet] no nodes for universe=%s, have %d nodes", universe, len(d.nodes)) - for ip, node := range d.nodes { - log.Printf("[artnet] node ip=%s universes=%v", ip, node.Universes) - } - } - - 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) detectInterface() { - d.broadcast = net.IPv4bcast - - ifaces, err := net.Interfaces() - if err != nil { - return - } - - for _, iface := range ifaces { - if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { - continue - } - - addrs, err := iface.Addrs() - if err != nil { - continue - } - - for _, addr := range addrs { - ipnet, ok := addr.(*net.IPNet) - if !ok { - continue - } - - ip4 := ipnet.IP.To4() - if ip4 == nil { - continue - } - - copy(d.localIP[:], ip4) - - if len(iface.HardwareAddr) == 6 { - copy(d.localMAC[:], iface.HardwareAddr) - } - - bcast := make(net.IP, 4) - for i := 0; i < 4; i++ { - bcast[i] = ip4[i] | ^ipnet.Mask[i] - } - d.broadcast = bcast - - return - } - } -} - -// 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) - } -} - -// SetReceiver sets the receiver for sending poll replies from port 6454 -func (d *Discovery) SetReceiver(r *Receiver) { - d.receiver = r -} diff --git a/artnet/protocol.go b/artnet/protocol.go deleted file mode 100644 index 60b9368..0000000 --- a/artnet/protocol.go +++ /dev/null @@ -1,299 +0,0 @@ -package artnet - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" -) - -const ( - Port = 6454 - - // OpCodes - OpPoll = 0x2000 - OpPollReply = 0x2100 - OpDmx = 0x5000 - - // Protocol - ProtocolVersion = 14 -) - -var ( - ArtNetID = [8]byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00} - - ErrInvalidPacket = errors.New("invalid ArtNet packet") - ErrInvalidHeader = errors.New("invalid ArtNet header") - ErrUnknownOpCode = errors.New("unknown OpCode") - ErrPacketTooShort = errors.New("packet too short") -) - -// Universe represents an ArtNet universe address (15-bit) -// Bits 14-8: Net (0-127) -// Bits 7-4: SubNet (0-15) -// Bits 3-0: Universe (0-15) -type Universe uint16 - -func NewUniverse(net, subnet, universe uint8) Universe { - return Universe((uint16(net&0x7F) << 8) | (uint16(subnet&0x0F) << 4) | uint16(universe&0x0F)) -} - -func (u Universe) Net() uint8 { - return uint8((u >> 8) & 0x7F) -} - -func (u Universe) SubNet() uint8 { - return uint8((u >> 4) & 0x0F) -} - -func (u Universe) Universe() uint8 { - return uint8(u & 0x0F) -} - -func (u Universe) String() string { - return fmt.Sprintf("%d.%d.%d", u.Net(), u.SubNet(), u.Universe()) -} - -// Header is the common ArtNet packet header -type Header struct { - ID [8]byte - OpCode uint16 -} - -// DMXPacket represents an ArtDmx packet (OpCode 0x5000) -type DMXPacket struct { - ProtocolVersion uint16 // High byte first - Sequence uint8 // 0x00 to disable, 0x01-0xFF sequence - Physical uint8 // Physical input port - Universe Universe // Universe address (low byte first in wire format) - Length uint16 // Data length (high byte first), 2-512, even - Data [512]byte // DMX data -} - -// PollPacket represents an ArtPoll packet (OpCode 0x2000) -type PollPacket struct { - ProtocolVersion uint16 - Flags uint8 - DiagPriority uint8 -} - -// PollReplyPacket represents an ArtPollReply packet (OpCode 0x2100) -type PollReplyPacket struct { - IPAddress [4]byte - Port uint16 - VersionInfo uint16 - NetSwitch uint8 - SubSwitch uint8 - OemHi uint8 - Oem uint8 - UbeaVersion uint8 - Status1 uint8 - EstaMan uint16 - ShortName [18]byte - LongName [64]byte - NodeReport [64]byte - NumPortsHi uint8 - NumPortsLo uint8 - PortTypes [4]byte - GoodInput [4]byte - GoodOutput [4]byte - SwIn [4]byte - SwOut [4]byte - SwVideo uint8 - SwMacro uint8 - SwRemote uint8 - Spare [3]byte - Style uint8 - MAC [6]byte - BindIP [4]byte - BindIndex uint8 - Status2 uint8 - Filler [26]byte -} - -// ParsePacket parses a raw ArtNet packet and returns the OpCode and parsed data -func ParsePacket(data []byte) (uint16, interface{}, error) { - if len(data) < 10 { - return 0, nil, ErrPacketTooShort - } - - // Check header - if !bytes.Equal(data[:8], ArtNetID[:]) { - return 0, nil, ErrInvalidHeader - } - - opCode := binary.LittleEndian.Uint16(data[8:10]) - - switch opCode { - case OpDmx: - pkt, err := parseDMXPacket(data) - return opCode, pkt, err - case OpPoll: - pkt, err := parsePollPacket(data) - return opCode, pkt, err - case OpPollReply: - pkt, err := parsePollReplyPacket(data) - return opCode, pkt, err - default: - return opCode, nil, nil // Unknown but valid packet - } -} - -func parseDMXPacket(data []byte) (*DMXPacket, error) { - if len(data) < 18 { - return nil, ErrPacketTooShort - } - - pkt := &DMXPacket{ - ProtocolVersion: binary.BigEndian.Uint16(data[10:12]), - Sequence: data[12], - Physical: data[13], - Universe: Universe(binary.LittleEndian.Uint16(data[14:16])), - Length: binary.BigEndian.Uint16(data[16:18]), - } - - dataLen := int(pkt.Length) - if dataLen > 512 { - dataLen = 512 - } - if len(data) >= 18+dataLen { - copy(pkt.Data[:], data[18:18+dataLen]) - } - - return pkt, nil -} - -func parsePollPacket(data []byte) (*PollPacket, error) { - if len(data) < 14 { - return nil, ErrPacketTooShort - } - - return &PollPacket{ - ProtocolVersion: binary.BigEndian.Uint16(data[10:12]), - Flags: data[12], - DiagPriority: data[13], - }, nil -} - -func parsePollReplyPacket(data []byte) (*PollReplyPacket, error) { - if len(data) < 207 { - return nil, ErrPacketTooShort - } - - pkt := &PollReplyPacket{ - Port: binary.LittleEndian.Uint16(data[14:16]), - VersionInfo: binary.BigEndian.Uint16(data[16:18]), - NetSwitch: data[18], - SubSwitch: data[19], - OemHi: data[20], - Oem: data[21], - UbeaVersion: data[22], - Status1: data[23], - EstaMan: binary.LittleEndian.Uint16(data[24:26]), - NumPortsHi: data[172], - NumPortsLo: data[173], - Style: data[200], - BindIndex: data[212], - Status2: data[213], - } - - copy(pkt.IPAddress[:], data[10:14]) - copy(pkt.ShortName[:], data[26:44]) - copy(pkt.LongName[:], data[44:108]) - copy(pkt.NodeReport[:], data[108:172]) - copy(pkt.PortTypes[:], data[174:178]) - copy(pkt.GoodInput[:], data[178:182]) - copy(pkt.GoodOutput[:], data[182:186]) - copy(pkt.SwIn[:], data[186:190]) - copy(pkt.SwOut[:], data[190:194]) - copy(pkt.MAC[:], data[201:207]) - copy(pkt.BindIP[:], data[207:211]) - - return pkt, nil -} - -// BuildDMXPacket creates a raw ArtDmx packet -func BuildDMXPacket(universe Universe, sequence uint8, data []byte) []byte { - dataLen := len(data) - if dataLen > 512 { - dataLen = 512 - } - // Length must be even - if dataLen%2 != 0 { - dataLen++ - } - - buf := make([]byte, 18+dataLen) - - // Header - copy(buf[0:8], ArtNetID[:]) - binary.LittleEndian.PutUint16(buf[8:10], OpDmx) - - // DMX packet fields - binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion) - buf[12] = sequence - buf[13] = 0 // Physical - binary.LittleEndian.PutUint16(buf[14:16], uint16(universe)) - binary.BigEndian.PutUint16(buf[16:18], uint16(dataLen)) - copy(buf[18:], data[:dataLen]) - - return buf -} - -// BuildPollPacket creates an ArtPoll packet -func BuildPollPacket() []byte { - buf := make([]byte, 14) - - copy(buf[0:8], ArtNetID[:]) - binary.LittleEndian.PutUint16(buf[8:10], OpPoll) - binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion) - buf[12] = 0x00 // Flags - buf[13] = 0x00 // DiagPriority - - return buf -} - -// BuildPollReplyPacket creates an ArtPollReply packet -// isInput: true = we transmit to network (SwIn), false = we receive from network (SwOut) -func BuildPollReplyPacket(ip [4]byte, mac [6]byte, shortName, longName string, universes []Universe, isInput bool) []byte { - buf := make([]byte, 240) - - copy(buf[0:8], ArtNetID[:]) - binary.LittleEndian.PutUint16(buf[8:10], OpPollReply) - copy(buf[10:14], ip[:]) - binary.LittleEndian.PutUint16(buf[14:16], Port) - binary.BigEndian.PutUint16(buf[16:18], ProtocolVersion) - - if len(universes) > 0 { - buf[18] = universes[0].Net() - buf[19] = universes[0].SubNet() - } - - copy(buf[26:44], shortName) - copy(buf[44:108], longName) - - numPorts := len(universes) - if numPorts > 4 { - numPorts = 4 - } - buf[173] = byte(numPorts) - - for i := 0; i < numPorts; i++ { - if isInput { - buf[174+i] = 0x40 - buf[178+i] = 0x80 - buf[186+i] = universes[i].Universe() - } else { - buf[174+i] = 0x80 - buf[182+i] = 0x80 - buf[190+i] = universes[i].Universe() - } - } - - copy(buf[201:207], mac[:]) - copy(buf[207:211], ip[:]) - buf[211] = 1 - buf[212] = 0x08 - - return buf -} diff --git a/artnet/receiver.go b/artnet/receiver.go deleted file mode 100644 index e16ef83..0000000 --- a/artnet/receiver.go +++ /dev/null @@ -1,104 +0,0 @@ -package artnet - -import ( - "log" - "net" -) - -// PacketHandler is called when a packet is received -type PacketHandler interface { - HandleDMX(src *net.UDPAddr, pkt *DMXPacket) - HandlePoll(src *net.UDPAddr, pkt *PollPacket) - HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) -} - -// Receiver listens for ArtNet packets -type Receiver struct { - conn *net.UDPConn - handler PacketHandler - done chan struct{} -} - -// NewReceiver creates a new ArtNet receiver -func NewReceiver(addr *net.UDPAddr, handler PacketHandler) (*Receiver, error) { - conn, err := net.ListenUDP("udp4", addr) - if err != nil { - return nil, err - } - - return &Receiver{ - conn: conn, - handler: handler, - done: make(chan struct{}), - }, nil -} - -// Start begins receiving packets -func (r *Receiver) Start() { - go r.receiveLoop() -} - -// Stop stops the receiver -func (r *Receiver) Stop() { - close(r.done) - r.conn.Close() -} - -func (r *Receiver) receiveLoop() { - buf := make([]byte, 1024) - - for { - select { - case <-r.done: - return - default: - } - - n, src, err := r.conn.ReadFromUDP(buf) - if err != nil { - select { - case <-r.done: - return - default: - log.Printf("read error: %v", err) - continue - } - } - - r.handlePacket(src, buf[:n]) - } -} - -func (r *Receiver) handlePacket(src *net.UDPAddr, data []byte) { - opCode, pkt, err := ParsePacket(data) - if err != nil { - // Silently ignore invalid packets - return - } - - switch opCode { - case OpDmx: - if dmx, ok := pkt.(*DMXPacket); ok { - r.handler.HandleDMX(src, dmx) - } - case OpPoll: - if poll, ok := pkt.(*PollPacket); ok { - r.handler.HandlePoll(src, poll) - } - case OpPollReply: - if reply, ok := pkt.(*PollReplyPacket); ok { - r.handler.HandlePollReply(src, reply) - } - } -} - -// LocalAddr returns the local address the receiver is bound to -func (r *Receiver) LocalAddr() net.Addr { - return r.conn.LocalAddr() -} - -// SendTo sends a raw packet through the receiver's socket (port 6454) -func (r *Receiver) SendTo(data []byte, addr *net.UDPAddr) error { - _, err := r.conn.WriteToUDP(data, addr) - return err -} diff --git a/artnet/sender.go b/artnet/sender.go deleted file mode 100644 index 672cd2d..0000000 --- a/artnet/sender.go +++ /dev/null @@ -1,61 +0,0 @@ -package artnet - -import ( - "net" - "sync" -) - -// Sender sends ArtNet packets -type Sender struct { - conn *net.UDPConn - sequences map[Universe]uint8 - seqMu sync.Mutex -} - -// NewSender creates a new ArtNet sender -func NewSender() (*Sender, error) { - // Create a UDP socket for sending - conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) - if err != nil { - return nil, err - } - - // Enable broadcast - if err := conn.SetWriteBuffer(65536); err != nil { - conn.Close() - return nil, err - } - - return &Sender{ - conn: conn, - sequences: make(map[Universe]uint8), - }, nil -} - -// SendDMX sends a DMX packet to a specific address -func (s *Sender) SendDMX(addr *net.UDPAddr, universe Universe, data []byte) error { - s.seqMu.Lock() - seq := s.sequences[universe] - seq++ - if seq == 0 { - seq = 1 // Skip 0 - } - s.sequences[universe] = seq - s.seqMu.Unlock() - - pkt := BuildDMXPacket(universe, seq, data) - _, err := s.conn.WriteToUDP(pkt, addr) - return err -} - -// SendPoll sends an ArtPoll packet to the specified address -func (s *Sender) SendPoll(addr *net.UDPAddr) error { - pkt := BuildPollPacket() - _, err := s.conn.WriteToUDP(pkt, addr) - return err -} - -// Close closes the sender -func (s *Sender) Close() error { - return s.conn.Close() -} diff --git a/main.go b/main.go index acc0d05..0db69c4 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,7 @@ import ( "strings" "syscall" - "github.com/gopatchy/artmap/artnet" + "github.com/gopatchy/artnet" "github.com/gopatchy/artmap/config" "github.com/gopatchy/artmap/remap" "github.com/gopatchy/artmap/sacn"