diff --git a/config.example.toml b/config.example.toml index 877eee5..c30932b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,6 +19,14 @@ # To examples: # "0.0.1" - universe 1, starting at channel 1 # "0.0.1:50" - universe 1, starting at channel 50 +# +# Input protocol (optional, default "artnet"): +# from_proto = "artnet" - receive via ArtNet +# from_proto = "sacn" - receive via sACN/E1.31 (multicast) +# +# Output protocol (optional, default "artnet"): +# proto = "artnet" - output via ArtNet (broadcast or discovered nodes) +# proto = "sacn" - output via sACN/E1.31 (multicast) # Remap entire universe [[mapping]] @@ -44,3 +52,15 @@ to = "0.0.7" [[mapping]] from = "0.0.3" to = "0.0.8" + +# Output to sACN instead of ArtNet +[[mapping]] +from = "0.0.4" +to = 1 +proto = "sacn" + +# Convert sACN input to ArtNet output +[[mapping]] +from = 5 +from_proto = "sacn" +to = "0.0.5" diff --git a/config/config.go b/config/config.go index a9a7f7c..fd495a5 100644 --- a/config/config.go +++ b/config/config.go @@ -14,10 +14,20 @@ type Config struct { Mappings []Mapping `toml:"mapping"` } +// Protocol specifies the output protocol +type Protocol string + +const ( + ProtocolArtNet Protocol = "artnet" + ProtocolSACN Protocol = "sacn" +) + // Mapping represents a single channel mapping rule type Mapping struct { - From FromAddr `toml:"from"` - To ToAddr `toml:"to"` + From FromAddr `toml:"from"` + FromProto Protocol `toml:"from_proto"` + To ToAddr `toml:"to"` + Protocol Protocol `toml:"proto"` } // FromAddr represents a source universe address with channel range @@ -201,7 +211,8 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("failed to load config: %w", err) } - for i, m := range cfg.Mappings { + for i := range cfg.Mappings { + m := &cfg.Mappings[i] if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 { return nil, fmt.Errorf("mapping %d: from channel start must be 1-512", i) } @@ -218,6 +229,16 @@ func Load(path string) (*Config, error) { if toEnd > 512 { return nil, fmt.Errorf("mapping %d: to channels exceed 512", i) } + if m.FromProto == "" { + m.FromProto = ProtocolArtNet + } else if m.FromProto != ProtocolArtNet && m.FromProto != ProtocolSACN { + return nil, fmt.Errorf("mapping %d: from_proto must be 'artnet' or 'sacn'", i) + } + if m.Protocol == "" { + m.Protocol = ProtocolArtNet + } else if m.Protocol != ProtocolArtNet && m.Protocol != ProtocolSACN { + return nil, fmt.Errorf("mapping %d: proto must be 'artnet' or 'sacn'", i) + } } return &cfg, nil @@ -227,9 +248,11 @@ func Load(path string) (*Config, error) { type NormalizedMapping struct { FromUniverse artnet.Universe FromChannel int // 0-indexed + FromProto Protocol ToUniverse artnet.Universe ToChannel int // 0-indexed Count int + Protocol Protocol } // Normalize converts config mappings to normalized form (0-indexed channels) @@ -239,10 +262,27 @@ func (c *Config) Normalize() []NormalizedMapping { result[i] = NormalizedMapping{ FromUniverse: m.From.Universe, FromChannel: m.From.ChannelStart - 1, + FromProto: m.FromProto, ToUniverse: m.To.Universe, ToChannel: m.To.ChannelStart - 1, Count: m.From.Count(), + Protocol: m.Protocol, } } return result } + +// SACNSourceUniverses returns universes that need sACN input +func (c *Config) SACNSourceUniverses() []uint16 { + seen := make(map[uint16]bool) + for _, m := range c.Mappings { + if m.FromProto == ProtocolSACN { + seen[uint16(m.From.Universe)] = true + } + } + result := make([]uint16, 0, len(seen)) + for u := range seen { + result = append(result, u) + } + return result +} diff --git a/go.mod b/go.mod index c57dc99..6e548ce 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module github.com/gopatchy/artmap go 1.25.4 -require github.com/BurntSushi/toml v1.6.0 +require ( + github.com/BurntSushi/toml v1.6.0 + golang.org/x/net v0.48.0 +) + +require golang.org/x/sys v0.39.0 // indirect diff --git a/go.sum b/go.sum index f74b269..2b7c131 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go index 4f24f41..0ae8172 100644 --- a/main.go +++ b/main.go @@ -14,15 +14,18 @@ import ( "github.com/gopatchy/artmap/artnet" "github.com/gopatchy/artmap/config" "github.com/gopatchy/artmap/remap" + "github.com/gopatchy/artmap/sacn" ) type App struct { - cfg *config.Config - receiver *artnet.Receiver - sender *artnet.Sender - discovery *artnet.Discovery - engine *remap.Engine - debug bool + cfg *config.Config + artReceiver *artnet.Receiver + sacnReceiver *sacn.Receiver + artSender *artnet.Sender + sacnSender *sacn.Sender + discovery *artnet.Discovery + engine *remap.Engine + debug bool } func main() { @@ -52,43 +55,63 @@ func main() { // Log mappings for _, m := range cfg.Mappings { toEnd := m.To.ChannelStart + m.From.Count() - 1 - log.Printf(" %s:%d-%d -> %s:%d-%d", - m.From.Universe, m.From.ChannelStart, m.From.ChannelEnd, - m.To.Universe, m.To.ChannelStart, toEnd) + log.Printf(" [%s] %s:%d-%d -> [%s] %s:%d-%d", + m.FromProto, m.From.Universe, m.From.ChannelStart, m.From.ChannelEnd, + m.Protocol, m.To.Universe, m.To.ChannelStart, toEnd) } - // Create sender - sender, err := artnet.NewSender(*broadcastAddr) + // Create ArtNet sender + artSender, err := artnet.NewSender(*broadcastAddr) if err != nil { - log.Fatalf("failed to create sender: %v", err) + log.Fatalf("failed to create artnet sender: %v", err) } - defer sender.Close() + defer artSender.Close() + + // Create sACN sender + sacnSender, err := sacn.NewSender("artmap") + if err != nil { + log.Fatalf("failed to create sacn sender: %v", err) + } + defer sacnSender.Close() // Create discovery destUniverses := engine.DestUniverses() - discovery := artnet.NewDiscovery(sender, "artmap", "ArtNet Remapping Proxy", destUniverses) + discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses) // Create app app := &App{ - cfg: cfg, - sender: sender, - discovery: discovery, - engine: engine, - debug: *debug, + cfg: cfg, + artSender: artSender, + sacnSender: sacnSender, + discovery: discovery, + engine: engine, + debug: *debug, } - // Create receiver - receiver, err := artnet.NewReceiver(addr, app) + // Create ArtNet receiver + artReceiver, err := artnet.NewReceiver(addr, app) if err != nil { - log.Fatalf("failed to create receiver: %v", err) + log.Fatalf("failed to create artnet receiver: %v", err) + } + app.artReceiver = artReceiver + + // Create sACN receiver if needed + sacnUniverses := cfg.SACNSourceUniverses() + if len(sacnUniverses) > 0 { + sacnReceiver, err := sacn.NewReceiver(sacnUniverses, app.HandleSACN) + if err != nil { + log.Fatalf("failed to create sacn receiver: %v", err) + } + app.sacnReceiver = sacnReceiver + sacnReceiver.Start() + log.Printf("listening for sACN on universes %v", sacnUniverses) } - app.receiver = receiver // Start everything - receiver.Start() + artReceiver.Start() discovery.Start() - log.Printf("listening on %s", addr) + log.Printf("listening for ArtNet on %s", addr) log.Printf("broadcasting to %s", *broadcastAddr) // Wait for interrupt @@ -97,46 +120,57 @@ func main() { <-sigChan log.Println("shutting down...") - receiver.Stop() + artReceiver.Stop() + if app.sacnReceiver != nil { + app.sacnReceiver.Stop() + } discovery.Stop() } // HandleDMX implements artnet.PacketHandler func (a *App) HandleDMX(src *net.UDPAddr, pkt *artnet.DMXPacket) { if a.debug { - log.Printf("recv DMX from %s: universe=%s seq=%d len=%d", + log.Printf("recv ArtNet from %s: universe=%s seq=%d len=%d", src.IP, pkt.Universe, pkt.Sequence, pkt.Length) } // Apply remapping - outputs := a.engine.Remap(pkt.Universe, pkt.Data) + outputs := a.engine.Remap(config.ProtocolArtNet, pkt.Universe, pkt.Data) // Send remapped outputs for _, out := range outputs { - // Find nodes for this universe - nodes := a.discovery.GetNodesForUniverse(out.Universe) - - if len(nodes) > 0 { - // Send to discovered nodes - for _, node := range nodes { - addr := &net.UDPAddr{ - IP: node.IP, - Port: int(node.Port), - } - if a.debug { - log.Printf("send DMX to %s: universe=%s", node.IP, out.Universe) - } - if err := a.sender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { - log.Printf("failed to send to %s: %v", node.IP, err) - } - } - } else { - // Broadcast if no nodes discovered + switch out.Protocol { + case config.ProtocolSACN: if a.debug { - log.Printf("send DMX broadcast: universe=%s", out.Universe) + log.Printf("send sACN multicast: universe=%d", uint16(out.Universe)) } - if err := a.sender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil { - log.Printf("failed to broadcast: %v", err) + if err := a.sacnSender.SendDMX(uint16(out.Universe), out.Data[:]); err != nil { + log.Printf("failed to send sACN: %v", err) + } + + default: // ArtNet + nodes := a.discovery.GetNodesForUniverse(out.Universe) + + if len(nodes) > 0 { + for _, node := range nodes { + addr := &net.UDPAddr{ + IP: node.IP, + Port: int(node.Port), + } + if a.debug { + log.Printf("send ArtNet to %s: universe=%s", node.IP, out.Universe) + } + if err := a.artSender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { + log.Printf("failed to send to %s: %v", node.IP, err) + } + } + } else { + if a.debug { + log.Printf("send ArtNet broadcast: universe=%s", out.Universe) + } + if err := a.artSender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil { + log.Printf("failed to broadcast: %v", err) + } } } } @@ -158,6 +192,54 @@ func (a *App) HandlePollReply(src *net.UDPAddr, pkt *artnet.PollReplyPacket) { a.discovery.HandlePollReply(src, pkt) } +// HandleSACN handles incoming sACN DMX data +func (a *App) HandleSACN(universe uint16, data [512]byte) { + if a.debug { + log.Printf("recv sACN: universe=%d", universe) + } + + // Apply remapping + outputs := a.engine.Remap(config.ProtocolSACN, artnet.Universe(universe), data) + + // Send remapped outputs + for _, out := range outputs { + switch out.Protocol { + case config.ProtocolSACN: + if a.debug { + log.Printf("send sACN multicast: universe=%d", uint16(out.Universe)) + } + if err := a.sacnSender.SendDMX(uint16(out.Universe), out.Data[:]); err != nil { + log.Printf("failed to send sACN: %v", err) + } + + default: // ArtNet + nodes := a.discovery.GetNodesForUniverse(out.Universe) + + if len(nodes) > 0 { + for _, node := range nodes { + addr := &net.UDPAddr{ + IP: node.IP, + Port: int(node.Port), + } + if a.debug { + log.Printf("send ArtNet to %s: universe=%s", node.IP, out.Universe) + } + if err := a.artSender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { + log.Printf("failed to send to %s: %v", node.IP, err) + } + } + } else { + if a.debug { + log.Printf("send ArtNet broadcast: universe=%s", out.Universe) + } + if err := a.artSender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil { + log.Printf("failed to broadcast: %v", err) + } + } + } + } +} + func init() { log.SetFlags(log.Ltime | log.Lmicroseconds) fmt.Println("artmap - ArtNet Remapping Proxy") diff --git a/remap/engine.go b/remap/engine.go index b87ce4b..e964992 100644 --- a/remap/engine.go +++ b/remap/engine.go @@ -1,6 +1,8 @@ package remap import ( + "sync" + "github.com/gopatchy/artmap/artnet" "github.com/gopatchy/artmap/config" ) @@ -8,63 +10,95 @@ import ( // Output represents a remapped DMX output type Output struct { Universe artnet.Universe + Protocol config.Protocol Data [512]byte } +// outputKey uniquely identifies an output destination +type outputKey struct { + Universe artnet.Universe + Protocol config.Protocol +} + +// sourceKey uniquely identifies an input source +type sourceKey struct { + Universe artnet.Universe + Protocol config.Protocol +} + // Engine handles DMX channel remapping type Engine struct { mappings []config.NormalizedMapping - // Index mappings by source universe for faster lookup - bySource map[artnet.Universe][]config.NormalizedMapping + // Index mappings by source universe and protocol for faster lookup + bySource map[sourceKey][]config.NormalizedMapping + // Persistent state for each output universe (merged from all sources) + state map[outputKey]*[512]byte + stateMu sync.Mutex } // NewEngine creates a new remapping engine func NewEngine(mappings []config.NormalizedMapping) *Engine { - bySource := make(map[artnet.Universe][]config.NormalizedMapping) + bySource := make(map[sourceKey][]config.NormalizedMapping) for _, m := range mappings { - bySource[m.FromUniverse] = append(bySource[m.FromUniverse], m) + key := sourceKey{Universe: m.FromUniverse, Protocol: m.FromProto} + bySource[key] = append(bySource[key], m) + } + + // Initialize state for all output universes + state := make(map[outputKey]*[512]byte) + for _, m := range mappings { + key := outputKey{Universe: m.ToUniverse, Protocol: m.Protocol} + if _, ok := state[key]; !ok { + state[key] = &[512]byte{} + } } return &Engine{ mappings: mappings, bySource: bySource, + state: state, } } // Remap applies mappings to incoming DMX data and returns outputs -func (e *Engine) Remap(srcUniverse artnet.Universe, srcData [512]byte) []Output { - mappings, ok := e.bySource[srcUniverse] +func (e *Engine) Remap(srcProto config.Protocol, srcUniverse artnet.Universe, srcData [512]byte) []Output { + key := sourceKey{Universe: srcUniverse, Protocol: srcProto} + mappings, ok := e.bySource[key] if !ok { return nil } - // Group outputs by destination universe - outputs := make(map[artnet.Universe]*Output) + e.stateMu.Lock() + defer e.stateMu.Unlock() + + // Track which outputs are affected by this input + affected := make(map[outputKey]bool) for _, m := range mappings { - // Get or create output for this destination universe - out, ok := outputs[m.ToUniverse] - if !ok { - out = &Output{ - Universe: m.ToUniverse, - } - outputs[m.ToUniverse] = out - } + outKey := outputKey{Universe: m.ToUniverse, Protocol: m.Protocol} + affected[outKey] = true - // Copy channels + // Update state for this output + outState := e.state[outKey] + + // Copy channels into persistent state for i := 0; i < m.Count; i++ { srcChan := m.FromChannel + i dstChan := m.ToChannel + i if srcChan < 512 && dstChan < 512 { - out.Data[dstChan] = srcData[srcChan] + outState[dstChan] = srcData[srcChan] } } } - // Convert map to slice - result := make([]Output, 0, len(outputs)) - for _, out := range outputs { - result = append(result, *out) + // Return outputs for all affected universes + result := make([]Output, 0, len(affected)) + for outKey := range affected { + result = append(result, Output{ + Universe: outKey.Universe, + Protocol: outKey.Protocol, + Data: *e.state[outKey], + }) } return result @@ -72,18 +106,24 @@ func (e *Engine) Remap(srcUniverse artnet.Universe, srcData [512]byte) []Output // SourceUniverses returns all universes that have mappings func (e *Engine) SourceUniverses() []artnet.Universe { - result := make([]artnet.Universe, 0, len(e.bySource)) - for u := range e.bySource { + seen := make(map[artnet.Universe]bool) + for key := range e.bySource { + seen[key.Universe] = true + } + result := make([]artnet.Universe, 0, len(seen)) + for u := range seen { result = append(result, u) } return result } -// DestUniverses returns all destination universes +// DestUniverses returns all destination universes (for ArtNet discovery) func (e *Engine) DestUniverses() []artnet.Universe { seen := make(map[artnet.Universe]bool) for _, m := range e.mappings { - seen[m.ToUniverse] = true + if m.Protocol == config.ProtocolArtNet { + seen[m.ToUniverse] = true + } } result := make([]artnet.Universe, 0, len(seen)) diff --git a/sacn/protocol.go b/sacn/protocol.go new file mode 100644 index 0000000..71d12a1 --- /dev/null +++ b/sacn/protocol.go @@ -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, + } +} diff --git a/sacn/receiver.go b/sacn/receiver.go new file mode 100644 index 0000000..bdbed09 --- /dev/null +++ b/sacn/receiver.go @@ -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) +} diff --git a/sacn/sender.go b/sacn/sender.go new file mode 100644 index 0000000..9cc8ff9 --- /dev/null +++ b/sacn/sender.go @@ -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() +}