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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
go.mod
7
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
182
main.go
182
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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
102
sacn/protocol.go
Normal file
102
sacn/protocol.go
Normal 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
140
sacn/receiver.go
Normal 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
67
sacn/sender.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user