commit e02ba0cd5e3753c8d065b02940cbd89c6571227a Author: Ian Gulliver Date: Wed Jan 28 10:27:25 2026 -0800 Initial Art-Net protocol library diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..0527283 --- /dev/null +++ b/discovery.go @@ -0,0 +1,261 @@ +package artnet + +import ( + "net" + "sync" + "time" +) + +type Node struct { + IP net.IP + Port uint16 + MAC net.HardwareAddr + ShortName string + LongName string + Inputs []Universe + Outputs []Universe + LastSeen time.Time +} + +type Discovery struct { + sender *Sender + receiver *Receiver + nodes map[string]*Node + nodesMu sync.RWMutex + localIP [4]byte + localMAC [6]byte + broadcast net.IP + shortName string + longName string + inputUnivs []Universe + outputUnivs []Universe + pollTargets []*net.UDPAddr + done chan struct{} + onChange func(*Node) +} + +func NewDiscovery(sender *Sender, shortName, longName string, inputUnivs, outputUnivs []Universe, pollTargets []*net.UDPAddr) *Discovery { + return &Discovery{ + sender: sender, + nodes: map[string]*Node{}, + shortName: shortName, + longName: longName, + inputUnivs: inputUnivs, + outputUnivs: outputUnivs, + pollTargets: pollTargets, + done: make(chan struct{}), + } +} + +func (d *Discovery) Start() { + d.detectInterface() + go d.pollLoop() +} + +func (d *Discovery) Stop() { + close(d.done) +} + +func (d *Discovery) SetReceiver(r *Receiver) { + d.receiver = r +} + +func (d *Discovery) SetOnChange(fn func(*Node)) { + d.onChange = fn +} + +func (d *Discovery) pollLoop() { + 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 { + d.sender.SendPoll(target) + } +} + +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) { + delete(d.nodes, ip) + } + } +} + +func (d *Discovery) HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) { + d.nodesMu.Lock() + defer d.nodesMu.Unlock() + + ip := src.IP.String() + + localIP := net.IP(d.localIP[:]) + if src.IP.Equal(localIP) { + return + } + + node, exists := d.nodes[ip] + if !exists { + node = &Node{ + IP: src.IP, + Port: pkt.Port, + } + d.nodes[ip] = node + } + + node.ShortName = pkt.GetShortName() + node.LongName = pkt.GetLongName() + node.MAC = pkt.MACAddr() + node.LastSeen = time.Now() + + for _, u := range pkt.InputUniverses() { + if !containsUniverse(node.Inputs, u) { + node.Inputs = append(node.Inputs, u) + } + } + for _, u := range pkt.OutputUniverses() { + if !containsUniverse(node.Outputs, u) { + node.Outputs = append(node.Outputs, u) + } + } + + if d.onChange != nil { + d.onChange(node) + } +} + +func (d *Discovery) HandlePoll(src *net.UDPAddr) { + if d.receiver == nil { + return + } + 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 := 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) + d.receiver.SendTo(pkt, dst) + } + } +} + +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.Outputs { + if u == universe { + result = append(result, node) + break + } + } + } + return result +} + +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) SetLocalIP(ip net.IP) { + if ip4 := ip.To4(); ip4 != nil { + copy(d.localIP[:], ip4) + } +} + +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 + } + } +} + +func containsUniverse(slice []Universe, val Universe) bool { + for _, v := range slice { + if v == val { + return true + } + } + return false +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..50f9db7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/gopatchy/artnet + +go 1.25.6 diff --git a/poller.go b/poller.go new file mode 100644 index 0000000..390a69c --- /dev/null +++ b/poller.go @@ -0,0 +1,104 @@ +package artnet + +import ( + "net" + "time" +) + +type Poller struct { + sender *Sender + targets []*net.UDPAddr + interval time.Duration + done chan struct{} +} + +func NewPoller(sender *Sender, targets []*net.UDPAddr, interval time.Duration) *Poller { + return &Poller{ + sender: sender, + targets: targets, + interval: interval, + done: make(chan struct{}), + } +} + +func NewBroadcastPoller(sender *Sender, interval time.Duration) *Poller { + return &Poller{ + sender: sender, + targets: []*net.UDPAddr{{IP: net.IPv4bcast, Port: Port}}, + interval: interval, + done: make(chan struct{}), + } +} + +func (p *Poller) Start() { + go p.loop() +} + +func (p *Poller) Stop() { + close(p.done) +} + +func (p *Poller) Poll() { + for _, target := range p.targets { + p.sender.SendPoll(target) + } +} + +func (p *Poller) loop() { + p.Poll() + + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + for { + select { + case <-p.done: + return + case <-ticker.C: + p.Poll() + } + } +} + +func (p *Poller) SetTargets(targets []*net.UDPAddr) { + p.targets = targets +} + +func (p *Poller) AddTarget(target *net.UDPAddr) { + p.targets = append(p.targets, target) +} + +func BroadcastAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4bcast, Port: Port} +} + +func UnicastAddr(ip net.IP) *net.UDPAddr { + return &net.UDPAddr{IP: ip, Port: Port} +} + +func InterfaceBroadcast(iface net.Interface) *net.UDPAddr { + addrs, err := iface.Addrs() + if err != nil { + return BroadcastAddr() + } + + for _, addr := range addrs { + ipnet, ok := addr.(*net.IPNet) + if !ok { + continue + } + + ip4 := ipnet.IP.To4() + if ip4 == nil { + continue + } + + bcast := make(net.IP, 4) + for i := 0; i < 4; i++ { + bcast[i] = ip4[i] | ^ipnet.Mask[i] + } + return &net.UDPAddr{IP: bcast, Port: Port} + } + + return BroadcastAddr() +} diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..77ace56 --- /dev/null +++ b/protocol.go @@ -0,0 +1,322 @@ +package artnet + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "net" + "strings" +) + +const ( + Port = 6454 + ProtocolVersion = 14 + + OpPoll uint16 = 0x2000 + OpPollReply uint16 = 0x2100 + OpDmx uint16 = 0x5000 + OpSync uint16 = 0x5200 + OpAddress uint16 = 0x6000 + OpInput uint16 = 0x7000 + OpTodData uint16 = 0x8100 + OpTodControl uint16 = 0x8200 + OpRdm uint16 = 0x8300 + + PortTypeOutput uint8 = 0x80 + PortTypeInput uint8 = 0x40 + + GoodOutputDataTransmitted uint8 = 0x80 + GoodInputDataReceived uint8 = 0x80 + + StyleNode uint8 = 0x00 + StyleController uint8 = 0x01 + StyleMedia uint8 = 0x02 + StyleRoute uint8 = 0x03 + StyleBackup uint8 = 0x04 + StyleConfig uint8 = 0x05 + StyleVisual uint8 = 0x06 +) + +var ( + ID = [8]byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00} + + ErrInvalidHeader = errors.New("invalid Art-Net header") + ErrPacketTooShort = errors.New("packet too short") +) + +type Universe uint16 + +func NewUniverse(netVal, subnet, universe uint8) Universe { + return Universe((uint16(netVal&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()) } + +type DMXPacket struct { + ProtocolVersion uint16 + Sequence uint8 + Physical uint8 + Universe Universe + Length uint16 + Data [512]byte +} + +type PollPacket struct { + ProtocolVersion uint16 + Flags uint8 + DiagPriority uint8 +} + +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 + Style uint8 + MAC [6]byte + BindIP [4]byte + BindIndex uint8 + Status2 uint8 +} + +func (p *PollReplyPacket) IP() net.IP { + return net.IPv4(p.IPAddress[0], p.IPAddress[1], p.IPAddress[2], p.IPAddress[3]) +} + +func (p *PollReplyPacket) MACAddr() net.HardwareAddr { + return net.HardwareAddr(p.MAC[:]) +} + +func (p *PollReplyPacket) GetShortName() string { + return strings.TrimRight(string(p.ShortName[:]), "\x00") +} + +func (p *PollReplyPacket) GetLongName() string { + return strings.TrimRight(string(p.LongName[:]), "\x00") +} + +func (p *PollReplyPacket) NumPorts() int { + n := int(p.NumPortsLo) + if n > 4 { + n = 4 + } + return n +} + +func (p *PollReplyPacket) InputUniverses() []Universe { + var result []Universe + for i := 0; i < p.NumPorts(); i++ { + if p.PortTypes[i]&PortTypeInput != 0 { + u := NewUniverse(p.NetSwitch, p.SubSwitch, p.SwIn[i]) + result = append(result, u) + } + } + return result +} + +func (p *PollReplyPacket) OutputUniverses() []Universe { + var result []Universe + for i := 0; i < p.NumPorts(); i++ { + if p.PortTypes[i]&PortTypeOutput != 0 { + u := NewUniverse(p.NetSwitch, p.SubSwitch, p.SwOut[i]) + result = append(result, u) + } + } + return result +} + +func ParsePacket(data []byte) (uint16, interface{}, error) { + if len(data) < 10 { + return 0, nil, ErrPacketTooShort + } + + if !bytes.Equal(data[:8], ID[:]) { + 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 + } +} + +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 +} + +func BuildDMXPacket(universe Universe, sequence uint8, data []byte) []byte { + dataLen := len(data) + if dataLen > 512 { + dataLen = 512 + } + if dataLen%2 != 0 { + dataLen++ + } + + buf := make([]byte, 18+dataLen) + copy(buf[0:8], ID[:]) + binary.LittleEndian.PutUint16(buf[8:10], OpDmx) + binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion) + buf[12] = sequence + buf[13] = 0 + binary.LittleEndian.PutUint16(buf[14:16], uint16(universe)) + binary.BigEndian.PutUint16(buf[16:18], uint16(dataLen)) + copy(buf[18:], data[:dataLen]) + + return buf +} + +func BuildPollPacket() []byte { + buf := make([]byte, 14) + copy(buf[0:8], ID[:]) + binary.LittleEndian.PutUint16(buf[8:10], OpPoll) + binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion) + buf[12] = 0x00 + buf[13] = 0x00 + return buf +} + +func BuildPollReplyPacket(ip [4]byte, mac [6]byte, shortName, longName string, universes []Universe, isInput bool) []byte { + buf := make([]byte, 240) + copy(buf[0:8], ID[:]) + 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] = PortTypeInput + buf[178+i] = GoodInputDataReceived + buf[186+i] = universes[i].Universe() + } else { + buf[174+i] = PortTypeOutput + buf[182+i] = GoodOutputDataTransmitted + 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/receiver.go b/receiver.go new file mode 100644 index 0000000..c213af2 --- /dev/null +++ b/receiver.go @@ -0,0 +1,107 @@ +package artnet + +import ( + "net" + "time" +) + +type Handler interface { + HandleDMX(src *net.UDPAddr, pkt *DMXPacket) + HandlePoll(src *net.UDPAddr, pkt *PollPacket) + HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) +} + +type Receiver struct { + conn *net.UDPConn + handler Handler + done chan struct{} +} + +func NewReceiver(addr *net.UDPAddr, handler Handler) (*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 +} + +func NewDefaultReceiver(handler Handler) (*Receiver, error) { + return NewReceiver(&net.UDPAddr{Port: Port}, handler) +} + +func (r *Receiver) Start() { + go r.loop() +} + +func (r *Receiver) Stop() { + close(r.done) + r.conn.Close() +} + +func (r *Receiver) Conn() *net.UDPConn { + return r.conn +} + +func (r *Receiver) LocalAddr() net.Addr { + return r.conn.LocalAddr() +} + +func (r *Receiver) SendTo(data []byte, addr *net.UDPAddr) error { + _, err := r.conn.WriteToUDP(data, addr) + return err +} + +func (r *Receiver) loop() { + buf := make([]byte, 1024) + + for { + select { + case <-r.done: + return + default: + } + + r.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, src, err := r.conn.ReadFromUDP(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + continue + } + } + + r.handle(src, buf[:n]) + } +} + +func (r *Receiver) handle(src *net.UDPAddr, data []byte) { + opCode, pkt, err := ParsePacket(data) + if err != nil { + 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) + } + } +} diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..7475ed1 --- /dev/null +++ b/sender.go @@ -0,0 +1,71 @@ +package artnet + +import ( + "net" + "sync" +) + +type Sender struct { + conn *net.UDPConn + sequences map[Universe]uint8 + seqMu sync.Mutex +} + +func NewSender() (*Sender, error) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + return nil, err + } + + return &Sender{ + conn: conn, + sequences: map[Universe]uint8{}, + }, nil +} + +func NewSenderFromConn(conn *net.UDPConn) *Sender { + return &Sender{ + conn: conn, + sequences: map[Universe]uint8{}, + } +} + +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 + } + s.sequences[universe] = seq + s.seqMu.Unlock() + + pkt := BuildDMXPacket(universe, seq, data) + _, err := s.conn.WriteToUDP(pkt, addr) + return err +} + +func (s *Sender) SendPoll(addr *net.UDPAddr) error { + pkt := BuildPollPacket() + _, err := s.conn.WriteToUDP(pkt, addr) + return err +} + +func (s *Sender) SendPollReply(addr *net.UDPAddr, localIP [4]byte, localMAC [6]byte, shortName, longName string, universes []Universe, isInput bool) error { + pkt := BuildPollReplyPacket(localIP, localMAC, shortName, longName, universes, isInput) + _, err := s.conn.WriteToUDP(pkt, addr) + return err +} + +func (s *Sender) SendRaw(addr *net.UDPAddr, data []byte) error { + _, err := s.conn.WriteToUDP(data, addr) + return err +} + +func (s *Sender) Close() error { + return s.conn.Close() +} + +func (s *Sender) LocalAddr() net.Addr { + return s.conn.LocalAddr() +}