Add sACN input/output support and fix multi-source merging

- Add sACN/E1.31 protocol support for both input and output
- from_proto = "sacn" to receive from sACN multicast
- proto = "sacn" to output via sACN multicast
- Fix remap engine to maintain persistent state per output universe
- Multiple inputs targeting same output now merge correctly
- Prevents flickering when multiple universes feed same output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2025-12-22 12:32:41 -08:00
parent cdb769d059
commit b0e9ecdee7
9 changed files with 580 additions and 80 deletions

102
sacn/protocol.go Normal file
View File

@@ -0,0 +1,102 @@
package sacn
import (
"encoding/binary"
"net"
)
const (
Port = 5568
// ACN packet identifiers
ACNPacketIdentifier = 0x41534300 // "ASC\0" + more bytes
// Vector values
VectorRootE131Data = 0x00000004
VectorE131DataPacket = 0x00000002
VectorDMPSetProperty = 0x02
)
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
}
// MulticastAddr returns the multicast address for a given universe
func MulticastAddr(universe uint16) *net.UDPAddr {
// 239.255.{universe_high}.{universe_low}
return &net.UDPAddr{
IP: net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff)),
Port: Port,
}
}

140
sacn/receiver.go Normal file
View File

@@ -0,0 +1,140 @@
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, handler DMXHandler) (*Receiver, error) {
// Listen on sACN port
c, err := net.ListenPacket("udp4", ":5568")
if err != nil {
return nil, err
}
p := ipv4.NewPacketConn(c)
// Join multicast groups for each universe
for _, u := range universes {
group := net.IPv4(239, 255, byte(u>>8), byte(u&0xff))
iface, _ := net.InterfaceByIndex(0) // Use default interface
if err := p.JoinGroup(iface, &net.UDPAddr{IP: group}); err != nil {
// Try joining on all interfaces
ifaces, _ := net.Interfaces()
for _, iface := range ifaces {
p.JoinGroup(&iface, &net.UDPAddr{IP: group})
}
}
}
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)
}

67
sacn/sender.go Normal file
View File

@@ -0,0 +1,67 @@
package sacn
import (
"crypto/rand"
"net"
"sync"
)
// Sender sends sACN (E1.31) packets
type Sender struct {
conn *net.UDPConn
sourceName string
cid [16]byte
sequences map[uint16]uint8
seqMu sync.Mutex
}
// NewSender creates a new sACN sender
func NewSender(sourceName string) (*Sender, error) {
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
return nil, err
}
// Generate random CID
var cid [16]byte
rand.Read(cid[:])
return &Sender{
conn: conn,
sourceName: sourceName,
cid: cid,
sequences: make(map[uint16]uint8),
}, 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
}
// Close closes the sender
func (s *Sender) Close() error {
return s.conn.Close()
}