diff --git a/main.go b/main.go index d44cfd5..71627db 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,7 @@ import ( "github.com/gopatchy/artnet" "github.com/gopatchy/artmap/config" "github.com/gopatchy/artmap/remap" - "github.com/gopatchy/artmap/sacn" + "github.com/gopatchy/sacn" ) type App struct { @@ -162,10 +162,24 @@ func main() { // Create sACN receiver if needed sacnUniverses := cfg.SACNSourceUniverses() if len(sacnUniverses) > 0 { - sacnReceiver, err := sacn.NewReceiver(sacnUniverses, *sacnInterface, app.HandleSACN) + sacnReceiver, err := sacn.NewReceiver(*sacnInterface) if err != nil { log.Fatalf("sacn receiver error: %v", err) } + var iface *net.Interface + if *sacnInterface != "" { + iface, _ = net.InterfaceByName(*sacnInterface) + } + for _, u := range sacnUniverses { + if err := sacnReceiver.JoinUniverse(iface, u); err != nil { + log.Printf("[sacn] failed to join universe %d: %v", u, err) + } + } + sacnReceiver.SetHandler(func(src *net.UDPAddr, pkt interface{}) { + if data, ok := pkt.(*sacn.DataPacket); ok { + app.HandleSACN(data.Universe, data.Data) + } + }) app.sacnReceiver = sacnReceiver sacnReceiver.Start() log.Printf("[sacn] listening universes=%v", sacnUniverses) diff --git a/sacn/fuzz_test.go b/sacn/fuzz_test.go deleted file mode 100644 index 7f783ac..0000000 --- a/sacn/fuzz_test.go +++ /dev/null @@ -1,91 +0,0 @@ -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([]byte{}) - f.Add(make([]byte, 125)) - f.Add(make([]byte, 126)) - f.Add(make([]byte, 638)) - - f.Fuzz(func(t *testing.T, data []byte) { - _, dmxData, ok := ParsePacket(data) - if !ok { - return - } - if len(dmxData) != 512 { - t.Fatalf("dmx data should be 512 bytes, got %d", len(dmxData)) - } - }) -} - -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) - parsedUniverse, parsedData, ok := ParsePacket(packet) - if !ok { - t.Fatalf("failed to parse packet we just built") - } - if parsedUniverse != universe { - t.Fatalf("universe mismatch: sent %d, got %d", universe, parsedUniverse) - } - expectedLen := len(dmxInput) - if expectedLen > 512 { - expectedLen = 512 - } - if !bytes.Equal(parsedData[:expectedLen], dmxInput[:expectedLen]) { - t.Fatalf("dmx data mismatch") - } - }) -} - -func ParsePacket(data []byte) (universe uint16, dmxData [512]byte, ok bool) { - if len(data) < 126 { - return 0, dmxData, false - } - if data[4] != 0x41 || data[5] != 0x53 || data[6] != 0x43 { - return 0, dmxData, false - } - rootVector := uint32(data[18])<<24 | uint32(data[19])<<16 | uint32(data[20])<<8 | uint32(data[21]) - if rootVector != VectorRootE131Data { - return 0, dmxData, false - } - framingVector := uint32(data[40])<<24 | uint32(data[41])<<16 | uint32(data[42])<<8 | uint32(data[43]) - if framingVector != VectorE131DataPacket { - return 0, dmxData, false - } - universe = uint16(data[113])<<8 | uint16(data[114]) - if data[117] != VectorDMPSetProperty { - return 0, dmxData, false - } - propCount := uint16(data[123])<<8 | uint16(data[124]) - if propCount < 1 { - return 0, dmxData, false - } - dmxLen := int(propCount) - 1 - if dmxLen > 512 { - dmxLen = 512 - } - if len(data) < 126+dmxLen { - return 0, dmxData, false - } - copy(dmxData[:], data[126:126+dmxLen]) - return universe, dmxData, true -} diff --git a/sacn/protocol.go b/sacn/protocol.go deleted file mode 100644 index cbb65f8..0000000 --- a/sacn/protocol.go +++ /dev/null @@ -1,141 +0,0 @@ -package sacn - -import ( - "encoding/binary" - "net" -) - -const ( - Port = 5568 - - ACNPacketIdentifier = 0x41534300 - - VectorRootE131Data = 0x00000004 - VectorRootE131Extended = 0x00000008 - VectorE131DataPacket = 0x00000002 - VectorE131Discovery = 0x00000002 - VectorDMPSetProperty = 0x02 - VectorUniverseDiscovery = 0x00000001 -) - -var ( - // ACN packet identifier (12 bytes) - packetIdentifier = [12]byte{ - 0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00, - } -) - -// BuildDataPacket creates an E1.31 (sACN) data packet -func BuildDataPacket(universe uint16, sequence uint8, sourceName string, cid [16]byte, data []byte) []byte { - dataLen := len(data) - if dataLen > 512 { - dataLen = 512 - } - - // Total packet size: Root Layer (38) + Framing Layer (77) + DMP Layer (11 + data) - // = 126 + dataLen - pktLen := 126 + dataLen - buf := make([]byte, pktLen) - - // Root Layer (38 bytes) - // Preamble Size (2 bytes) - binary.BigEndian.PutUint16(buf[0:2], 0x0010) - // Post-amble Size (2 bytes) - binary.BigEndian.PutUint16(buf[2:4], 0x0000) - // ACN Packet Identifier (12 bytes) - copy(buf[4:16], packetIdentifier[:]) - // Flags and Length (2 bytes) - high 4 bits are flags (0x7), low 12 bits are length - rootLen := pktLen - 16 // Length from after ACN Packet Identifier - binary.BigEndian.PutUint16(buf[16:18], 0x7000|uint16(rootLen)) - // Vector (4 bytes) - binary.BigEndian.PutUint32(buf[18:22], VectorRootE131Data) - // CID (16 bytes) - copy(buf[22:38], cid[:]) - - // Framing Layer (77 bytes, starting at offset 38) - // Flags and Length (2 bytes) - framingLen := pktLen - 38 - binary.BigEndian.PutUint16(buf[38:40], 0x7000|uint16(framingLen)) - // Vector (4 bytes) - binary.BigEndian.PutUint32(buf[40:44], VectorE131DataPacket) - // Source Name (64 bytes, null-terminated) - copy(buf[44:108], sourceName) - // Priority (1 byte) - buf[108] = 100 - // Synchronization Address (2 bytes) - binary.BigEndian.PutUint16(buf[109:111], 0) - // Sequence Number (1 byte) - buf[111] = sequence - // Options (1 byte) - buf[112] = 0 - // Universe (2 bytes) - binary.BigEndian.PutUint16(buf[113:115], universe) - - // DMP Layer (11 + dataLen bytes, starting at offset 115) - // Flags and Length (2 bytes) - dmpLen := 11 + dataLen - binary.BigEndian.PutUint16(buf[115:117], 0x7000|uint16(dmpLen)) - // Vector (1 byte) - buf[117] = VectorDMPSetProperty - // Address Type & Data Type (1 byte) - buf[118] = 0xa1 - // First Property Address (2 bytes) - binary.BigEndian.PutUint16(buf[119:121], 0) - // Address Increment (2 bytes) - binary.BigEndian.PutUint16(buf[121:123], 1) - // Property Value Count (2 bytes) - includes START code - binary.BigEndian.PutUint16(buf[123:125], uint16(dataLen+1)) - // START Code (1 byte) - buf[125] = 0 - // Property Values (DMX data) - copy(buf[126:], data[:dataLen]) - - return buf -} - -func MulticastAddr(universe uint16) *net.UDPAddr { - return &net.UDPAddr{ - IP: net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff)), - Port: Port, - } -} - -var DiscoveryAddr = &net.UDPAddr{ - IP: net.IPv4(239, 255, 250, 214), - Port: Port, -} - -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 -} diff --git a/sacn/receiver.go b/sacn/receiver.go deleted file mode 100644 index 99c06ac..0000000 --- a/sacn/receiver.go +++ /dev/null @@ -1,143 +0,0 @@ -package sacn - -import ( - "encoding/binary" - "log" - "net" - - "golang.org/x/net/ipv4" -) - -// DMXHandler is called when DMX data is received -type DMXHandler func(universe uint16, data [512]byte) - -// Receiver listens for sACN packets -type Receiver struct { - conn *ipv4.PacketConn - universes []uint16 - handler DMXHandler - done chan struct{} -} - -// NewReceiver creates a new sACN receiver for the given universes -func NewReceiver(universes []uint16, ifaceName string, handler DMXHandler) (*Receiver, error) { - c, err := net.ListenPacket("udp4", ":5568") - if err != nil { - return nil, err - } - - var iface *net.Interface - if ifaceName != "" { - iface, err = net.InterfaceByName(ifaceName) - if err != nil { - c.Close() - return nil, err - } - } - - p := ipv4.NewPacketConn(c) - - for _, u := range universes { - group := net.IPv4(239, 255, byte(u>>8), byte(u&0xff)) - if err := p.JoinGroup(iface, &net.UDPAddr{IP: group}); err != nil { - c.Close() - return nil, err - } - } - - return &Receiver{ - conn: p, - universes: universes, - 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, 638) // Max sACN packet size - - for { - select { - case <-r.done: - return - default: - } - - n, _, _, err := r.conn.ReadFrom(buf) - if err != nil { - select { - case <-r.done: - return - default: - log.Printf("sacn read error: %v", err) - continue - } - } - - r.handlePacket(buf[:n]) - } -} - -func (r *Receiver) handlePacket(data []byte) { - // Minimum packet size check - if len(data) < 126 { - return - } - - // Check ACN packet identifier - if data[4] != 0x41 || data[5] != 0x53 || data[6] != 0x43 { - return - } - - // Check root vector (E1.31 data) - rootVector := binary.BigEndian.Uint32(data[18:22]) - if rootVector != VectorRootE131Data { - return - } - - // Check framing vector (DMP data) - framingVector := binary.BigEndian.Uint32(data[40:44]) - if framingVector != VectorE131DataPacket { - return - } - - // Get universe - universe := binary.BigEndian.Uint16(data[113:115]) - - // Check DMP vector - if data[117] != VectorDMPSetProperty { - return - } - - // Get property count (includes START code) - propCount := binary.BigEndian.Uint16(data[123:125]) - if propCount < 1 { - return - } - - // Skip START code at data[125] - dmxLen := int(propCount) - 1 - if dmxLen > 512 { - dmxLen = 512 - } - - if len(data) < 126+dmxLen { - return - } - - var dmxData [512]byte - copy(dmxData[:], data[126:126+dmxLen]) - - r.handler(universe, dmxData) -} diff --git a/sacn/sender.go b/sacn/sender.go deleted file mode 100644 index 60ee88d..0000000 --- a/sacn/sender.go +++ /dev/null @@ -1,144 +0,0 @@ -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{} -} - -// NewSender creates a new sACN sender -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: make(map[uint16]uint8), - universes: make(map[uint16]bool), - done: make(chan struct{}), - }, nil -} - -// SendDMX sends DMX data to a universe via multicast -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 -} - -// SendDMXUnicast sends DMX data to a specific address -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) Close() error { - select { - case <-s.done: - default: - close(s.done) - } - return s.conn.Close() -} - -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) 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) - } -}