From d5ca321a4c1ba960d0ff8fb79e71d88479a6e757 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 28 Jan 2026 21:36:34 -0800 Subject: [PATCH] Initial sACN library with protocol, sender, receiver, and discovery Co-Authored-By: Claude Opus 4.5 --- discovery.go | 124 +++++++++++++++++++++++++ fuzz_test.go | 107 +++++++++++++++++++++ go.mod | 7 ++ go.sum | 4 + protocol.go | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++ receiver.go | 100 ++++++++++++++++++++ sender.go | 145 +++++++++++++++++++++++++++++ 7 files changed, 742 insertions(+) create mode 100644 discovery.go create mode 100644 fuzz_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 protocol.go create mode 100644 receiver.go create mode 100644 sender.go diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..33288c5 --- /dev/null +++ b/discovery.go @@ -0,0 +1,124 @@ +package sacn + +import ( + "net" + "sync" + "time" +) + +type Source struct { + CID [16]byte + SourceName string + IP net.IP + Universes []uint16 + LastSeen time.Time +} + +type Discovery struct { + sources map[string]*Source + mu sync.RWMutex + onChange func(*Source) + done chan struct{} +} + +func NewDiscovery() *Discovery { + return &Discovery{ + sources: map[string]*Source{}, + done: make(chan struct{}), + } +} + +func (d *Discovery) SetOnChange(fn func(*Source)) { + d.onChange = fn +} + +func (d *Discovery) HandleDiscoveryPacket(src *net.UDPAddr, pkt *DiscoveryPacket) { + d.mu.Lock() + defer d.mu.Unlock() + + cidStr := FormatCID(pkt.CID) + + source, exists := d.sources[cidStr] + if !exists { + source = &Source{ + CID: pkt.CID, + } + d.sources[cidStr] = source + } + + source.SourceName = pkt.SourceName + source.IP = src.IP + source.Universes = pkt.Universes + source.LastSeen = time.Now() + + if d.onChange != nil { + d.onChange(source) + } +} + +func (d *Discovery) GetSource(cid string) *Source { + d.mu.RLock() + defer d.mu.RUnlock() + return d.sources[cid] +} + +func (d *Discovery) GetSourceByIP(ip net.IP) *Source { + d.mu.RLock() + defer d.mu.RUnlock() + + for _, source := range d.sources { + if source.IP != nil && source.IP.Equal(ip) { + return source + } + } + return nil +} + +func (d *Discovery) GetAllSources() []*Source { + d.mu.RLock() + defer d.mu.RUnlock() + + result := make([]*Source, 0, len(d.sources)) + for _, source := range d.sources { + result = append(result, source) + } + return result +} + +func (d *Discovery) Expire() { + d.mu.Lock() + defer d.mu.Unlock() + + cutoff := time.Now().Add(-60 * time.Second) + for cid, source := range d.sources { + if source.LastSeen.Before(cutoff) { + delete(d.sources, cid) + } + } +} + +func (d *Discovery) StartCleanup() { + go d.cleanupLoop() +} + +func (d *Discovery) Stop() { + select { + case <-d.done: + default: + close(d.done) + } +} + +func (d *Discovery) cleanupLoop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-d.done: + return + case <-ticker.C: + d.Expire() + } + } +} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..29c0999 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,107 @@ +package sacn + +import ( + "bytes" + "testing" +) + +func FuzzParsePacket(f *testing.F) { + cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + validPacket := BuildDataPacket(1, 0, "test", cid, make([]byte, 512)) + f.Add(validPacket) + f.Add(BuildDataPacket(1, 0, "test", cid, make([]byte, 100))) + f.Add(BuildDataPacket(63999, 255, "long source name here", cid, make([]byte, 512))) + f.Add(BuildDiscoveryPacket("test", cid, 0, 0, []uint16{1, 2, 3})) + f.Add([]byte{}) + f.Add(make([]byte, 125)) + f.Add(make([]byte, 126)) + f.Add(make([]byte, 638)) + + f.Fuzz(func(t *testing.T, data []byte) { + pkt, err := ParsePacket(data) + if err != nil { + return + } + switch p := pkt.(type) { + case *DataPacket: + if p.DataLen > 512 { + t.Fatalf("data length should be <= 512, got %d", p.DataLen) + } + case *DiscoveryPacket: + for _, u := range p.Universes { + if u < 1 || u > 63999 { + t.Fatalf("universe out of range: %d", u) + } + } + } + }) +} + +func FuzzBuildParseRoundtrip(f *testing.F) { + f.Add(uint16(1), uint8(0), "test", make([]byte, 512)) + f.Add(uint16(63999), uint8(255), "source", make([]byte, 100)) + f.Add(uint16(100), uint8(128), "", make([]byte, 0)) + f.Add(uint16(1), uint8(0), "a very long source name that exceeds normal limits", make([]byte, 512)) + + f.Fuzz(func(t *testing.T, universe uint16, seq uint8, sourceName string, dmxInput []byte) { + if universe < 1 || universe > 63999 { + return + } + cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + packet := BuildDataPacket(universe, seq, sourceName, cid, dmxInput) + pkt, err := ParsePacket(packet) + if err != nil { + t.Fatalf("failed to parse packet we just built: %v", err) + } + dataPkt, ok := pkt.(*DataPacket) + if !ok { + t.Fatalf("expected DataPacket, got %T", pkt) + } + if dataPkt.Universe != universe { + t.Fatalf("universe mismatch: sent %d, got %d", universe, dataPkt.Universe) + } + expectedLen := len(dmxInput) + if expectedLen > 512 { + expectedLen = 512 + } + if !bytes.Equal(dataPkt.Data[:expectedLen], dmxInput[:expectedLen]) { + t.Fatalf("dmx data mismatch") + } + }) +} + +func FuzzDiscoveryRoundtrip(f *testing.F) { + f.Add("test", uint8(0), uint8(0), []byte{0, 1, 0, 2, 0, 3}) + + f.Fuzz(func(t *testing.T, sourceName string, page, lastPage uint8, universeBytes []byte) { + universes := make([]uint16, 0, len(universeBytes)/2) + for i := 0; i+1 < len(universeBytes); i += 2 { + u := uint16(universeBytes[i])<<8 | uint16(universeBytes[i+1]) + if u >= 1 && u <= 63999 { + universes = append(universes, u) + } + } + if len(universes) == 0 { + return + } + cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + packet := BuildDiscoveryPacket(sourceName, cid, page, lastPage, universes) + pkt, err := ParsePacket(packet) + if err != nil { + t.Fatalf("failed to parse discovery packet: %v", err) + } + discPkt, ok := pkt.(*DiscoveryPacket) + if !ok { + t.Fatalf("expected DiscoveryPacket, got %T", pkt) + } + if discPkt.Page != page { + t.Fatalf("page mismatch: sent %d, got %d", page, discPkt.Page) + } + if discPkt.LastPage != lastPage { + t.Fatalf("lastPage mismatch: sent %d, got %d", lastPage, discPkt.LastPage) + } + if len(discPkt.Universes) != len(universes) { + t.Fatalf("universe count mismatch: sent %d, got %d", len(universes), len(discPkt.Universes)) + } + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6909034 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/gopatchy/sacn + +go 1.25.6 + +require golang.org/x/net v0.49.0 + +require golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e1ed068 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..c16b232 --- /dev/null +++ b/protocol.go @@ -0,0 +1,255 @@ +package sacn + +import ( + "encoding/binary" + "errors" + "net" + "strings" +) + +const ( + Port = 5568 + + VectorRootE131Data = 0x00000004 + VectorRootE131Extended = 0x00000008 + VectorE131DataPacket = 0x00000002 + VectorE131Discovery = 0x00000002 + VectorDMPSetProperty = 0x02 + VectorUniverseDiscovery = 0x00000001 +) + +var ( + PacketIdentifier = [12]byte{ + 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, + } + + DiscoveryAddr = &net.UDPAddr{ + IP: net.IPv4(239, 255, 250, 214), + Port: Port, + } + + ErrInvalidHeader = errors.New("invalid sACN header") + ErrPacketTooShort = errors.New("packet too short") + ErrInvalidVector = errors.New("invalid vector") +) + +type DataPacket struct { + CID [16]byte + SourceName string + Priority uint8 + Sequence uint8 + Universe uint16 + Data [512]byte + DataLen int +} + +type DiscoveryPacket struct { + CID [16]byte + SourceName string + Page uint8 + LastPage uint8 + Universes []uint16 +} + +func MulticastAddr(universe uint16) *net.UDPAddr { + return &net.UDPAddr{ + IP: net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff)), + Port: Port, + } +} + +func ParsePacket(data []byte) (interface{}, error) { + if len(data) < 22 { + return nil, ErrPacketTooShort + } + + if data[4] != PacketIdentifier[0] || data[5] != PacketIdentifier[1] || + data[6] != PacketIdentifier[2] || data[7] != PacketIdentifier[3] { + return nil, ErrInvalidHeader + } + + rootVector := binary.BigEndian.Uint32(data[18:22]) + + switch rootVector { + case VectorRootE131Data: + return parseDataPacket(data) + case VectorRootE131Extended: + return parseExtendedPacket(data) + default: + return nil, ErrInvalidVector + } +} + +func parseDataPacket(data []byte) (*DataPacket, error) { + if len(data) < 126 { + return nil, ErrPacketTooShort + } + + framingVector := binary.BigEndian.Uint32(data[40:44]) + if framingVector != VectorE131DataPacket { + return nil, ErrInvalidVector + } + + if data[117] != VectorDMPSetProperty { + return nil, ErrInvalidVector + } + + propCount := binary.BigEndian.Uint16(data[123:125]) + if propCount < 1 { + return nil, ErrPacketTooShort + } + + dmxLen := int(propCount) - 1 + if dmxLen > 512 { + dmxLen = 512 + } + + if len(data) < 126+dmxLen { + return nil, ErrPacketTooShort + } + + pkt := &DataPacket{ + SourceName: strings.TrimRight(string(data[44:108]), "\x00"), + Priority: data[108], + Sequence: data[111], + Universe: binary.BigEndian.Uint16(data[113:115]), + DataLen: dmxLen, + } + copy(pkt.CID[:], data[22:38]) + copy(pkt.Data[:], data[126:126+dmxLen]) + + return pkt, nil +} + +func parseExtendedPacket(data []byte) (interface{}, error) { + if len(data) < 118 { + return nil, ErrPacketTooShort + } + + framingVector := binary.BigEndian.Uint32(data[40:44]) + if framingVector != VectorE131Discovery { + return nil, ErrInvalidVector + } + + if len(data) < 120 { + return nil, ErrPacketTooShort + } + + discoveryVector := binary.BigEndian.Uint32(data[114:118]) + if discoveryVector != VectorUniverseDiscovery { + return nil, ErrInvalidVector + } + + pkt := &DiscoveryPacket{ + SourceName: strings.TrimRight(string(data[44:108]), "\x00"), + Page: data[118], + LastPage: data[119], + } + copy(pkt.CID[:], data[22:38]) + + universeCount := (len(data) - 120) / 2 + pkt.Universes = make([]uint16, 0, universeCount) + for i := 0; i < universeCount; i++ { + u := binary.BigEndian.Uint16(data[120+i*2 : 122+i*2]) + if u >= 1 && u <= 63999 { + pkt.Universes = append(pkt.Universes, u) + } + } + + return pkt, nil +} + +func BuildDataPacket(universe uint16, sequence uint8, sourceName string, cid [16]byte, data []byte) []byte { + dataLen := len(data) + if dataLen > 512 { + dataLen = 512 + } + + pktLen := 126 + dataLen + buf := make([]byte, pktLen) + + binary.BigEndian.PutUint16(buf[0:2], 0x0010) + binary.BigEndian.PutUint16(buf[2:4], 0x0000) + copy(buf[4:16], PacketIdentifier[:]) + rootLen := pktLen - 16 + binary.BigEndian.PutUint16(buf[16:18], 0x7000|uint16(rootLen)) + binary.BigEndian.PutUint32(buf[18:22], VectorRootE131Data) + copy(buf[22:38], cid[:]) + + framingLen := pktLen - 38 + binary.BigEndian.PutUint16(buf[38:40], 0x7000|uint16(framingLen)) + binary.BigEndian.PutUint32(buf[40:44], VectorE131DataPacket) + copy(buf[44:108], sourceName) + buf[108] = 100 + binary.BigEndian.PutUint16(buf[109:111], 0) + buf[111] = sequence + buf[112] = 0 + binary.BigEndian.PutUint16(buf[113:115], universe) + + dmpLen := 11 + dataLen + binary.BigEndian.PutUint16(buf[115:117], 0x7000|uint16(dmpLen)) + buf[117] = VectorDMPSetProperty + buf[118] = 0xa1 + binary.BigEndian.PutUint16(buf[119:121], 0) + binary.BigEndian.PutUint16(buf[121:123], 1) + binary.BigEndian.PutUint16(buf[123:125], uint16(dataLen+1)) + buf[125] = 0 + copy(buf[126:], data[:dataLen]) + + return buf +} + +func BuildDiscoveryPacket(sourceName string, cid [16]byte, page, lastPage uint8, universes []uint16) []byte { + universeCount := len(universes) + if universeCount > 512 { + universeCount = 512 + } + + pktLen := 120 + universeCount*2 + buf := make([]byte, pktLen) + + binary.BigEndian.PutUint16(buf[0:2], 0x0010) + binary.BigEndian.PutUint16(buf[2:4], 0x0000) + copy(buf[4:16], PacketIdentifier[:]) + rootLen := pktLen - 16 + binary.BigEndian.PutUint16(buf[16:18], 0x7000|uint16(rootLen)) + binary.BigEndian.PutUint32(buf[18:22], VectorRootE131Extended) + copy(buf[22:38], cid[:]) + + framingLen := pktLen - 38 + binary.BigEndian.PutUint16(buf[38:40], 0x7000|uint16(framingLen)) + binary.BigEndian.PutUint32(buf[40:44], VectorE131Discovery) + copy(buf[44:108], sourceName) + binary.BigEndian.PutUint32(buf[108:112], 0) + + discoveryLen := pktLen - 112 + binary.BigEndian.PutUint16(buf[112:114], 0x7000|uint16(discoveryLen)) + binary.BigEndian.PutUint32(buf[114:118], VectorUniverseDiscovery) + buf[118] = page + buf[119] = lastPage + for i := 0; i < universeCount; i++ { + binary.BigEndian.PutUint16(buf[120+i*2:122+i*2], universes[i]) + } + + return buf +} + +func FormatCID(cid [16]byte) string { + return strings.ToLower(formatUUID(cid)) +} + +func formatUUID(b [16]byte) string { + const hexChars = "0123456789ABCDEF" + result := make([]byte, 36) + idx := 0 + for i, v := range b { + if i == 4 || i == 6 || i == 8 || i == 10 { + result[idx] = '-' + idx++ + } + result[idx] = hexChars[v>>4] + result[idx+1] = hexChars[v&0x0f] + idx += 2 + } + return string(result) +} diff --git a/receiver.go b/receiver.go new file mode 100644 index 0000000..9c8d2f9 --- /dev/null +++ b/receiver.go @@ -0,0 +1,100 @@ +package sacn + +import ( + "net" + "time" + + "golang.org/x/net/ipv4" +) + +type Receiver struct { + conn *ipv4.PacketConn + rawConn net.PacketConn + handler func(src *net.UDPAddr, pkt interface{}) + done chan struct{} +} + +func NewReceiver(ifaceName string) (*Receiver, error) { + c, err := net.ListenPacket("udp4", ":5568") + if err != nil { + return nil, err + } + + p := ipv4.NewPacketConn(c) + + if ifaceName != "" { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + c.Close() + return nil, err + } + p.SetMulticastInterface(iface) + } + + return &Receiver{ + conn: p, + rawConn: c, + done: make(chan struct{}), + }, nil +} + +func (r *Receiver) JoinUniverse(iface *net.Interface, universe uint16) error { + group := net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff)) + return r.conn.JoinGroup(iface, &net.UDPAddr{IP: group}) +} + +func (r *Receiver) JoinDiscovery(iface *net.Interface) error { + return r.conn.JoinGroup(iface, DiscoveryAddr) +} + +func (r *Receiver) SetHandler(fn func(src *net.UDPAddr, pkt interface{})) { + r.handler = fn +} + +func (r *Receiver) Start() { + go r.receiveLoop() +} + +func (r *Receiver) Stop() { + select { + case <-r.done: + default: + close(r.done) + } + r.rawConn.Close() +} + +func (r *Receiver) receiveLoop() { + buf := make([]byte, 638) + + for { + select { + case <-r.done: + return + default: + } + + r.rawConn.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, _, src, err := r.conn.ReadFrom(buf) + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + continue + } + select { + case <-r.done: + return + default: + continue + } + } + + pkt, err := ParsePacket(buf[:n]) + if err != nil { + continue + } + + if r.handler != nil { + r.handler(src.(*net.UDPAddr), pkt) + } + } +} diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..05488b5 --- /dev/null +++ b/sender.go @@ -0,0 +1,145 @@ +package sacn + +import ( + "crypto/rand" + "net" + "sort" + "sync" + "time" + + "golang.org/x/net/ipv4" +) + +type Sender struct { + conn *net.UDPConn + sourceName string + cid [16]byte + sequences map[uint16]uint8 + seqMu sync.Mutex + universes map[uint16]bool + done chan struct{} +} + +func NewSender(sourceName string, ifaceName string) (*Sender, error) { + conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) + if err != nil { + return nil, err + } + + if ifaceName != "" { + iface, err := net.InterfaceByName(ifaceName) + if err != nil { + conn.Close() + return nil, err + } + p := ipv4.NewPacketConn(conn) + if err := p.SetMulticastInterface(iface); err != nil { + conn.Close() + return nil, err + } + } + + var cid [16]byte + rand.Read(cid[:]) + + return &Sender{ + conn: conn, + sourceName: sourceName, + cid: cid, + sequences: map[uint16]uint8{}, + universes: map[uint16]bool{}, + done: make(chan struct{}), + }, nil +} + +func (s *Sender) CID() [16]byte { + return s.cid +} + +func (s *Sender) SendDMX(universe uint16, data []byte) error { + s.seqMu.Lock() + seq := s.sequences[universe] + s.sequences[universe] = seq + 1 + s.seqMu.Unlock() + + pkt := BuildDataPacket(universe, seq, s.sourceName, s.cid, data) + addr := MulticastAddr(universe) + + _, err := s.conn.WriteToUDP(pkt, addr) + return err +} + +func (s *Sender) SendDMXUnicast(addr *net.UDPAddr, universe uint16, data []byte) error { + s.seqMu.Lock() + seq := s.sequences[universe] + s.sequences[universe] = seq + 1 + s.seqMu.Unlock() + + pkt := BuildDataPacket(universe, seq, s.sourceName, s.cid, data) + + _, err := s.conn.WriteToUDP(pkt, addr) + return err +} + +func (s *Sender) RegisterUniverse(universe uint16) { + s.seqMu.Lock() + s.universes[universe] = true + s.seqMu.Unlock() +} + +func (s *Sender) StartDiscovery() { + go s.discoveryLoop() +} + +func (s *Sender) Close() error { + select { + case <-s.done: + default: + close(s.done) + } + return s.conn.Close() +} + +func (s *Sender) discoveryLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + s.sendDiscovery() + + for { + select { + case <-s.done: + return + case <-ticker.C: + s.sendDiscovery() + } + } +} + +func (s *Sender) sendDiscovery() { + s.seqMu.Lock() + universes := make([]uint16, 0, len(s.universes)) + for u := range s.universes { + universes = append(universes, u) + } + s.seqMu.Unlock() + + if len(universes) == 0 { + return + } + + sort.Slice(universes, func(i, j int) bool { return universes[i] < universes[j] }) + + const maxPerPage = 512 + totalPages := (len(universes) + maxPerPage - 1) / maxPerPage + + for page := 0; page < totalPages; page++ { + start := page * maxPerPage + end := start + maxPerPage + if end > len(universes) { + end = len(universes) + } + pkt := BuildDiscoveryPacket(s.sourceName, s.cid, uint8(page), uint8(totalPages-1), universes[start:end]) + s.conn.WriteToUDP(pkt, DiscoveryAddr) + } +}