From e289375df84b85d7baba732c99fe5e42863c4017 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 22 Dec 2025 18:27:16 -0800 Subject: [PATCH] Add pcap-based sACN receiver to avoid port conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New -sacn-pcap flag uses packet capture instead of binding port 5568. This allows running alongside other software using the sACN port. - Captures both incoming and outgoing packets (same-host sender support) - Requires root/sudo for packet capture privileges - Use "auto" for automatic interface detection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- config.example.toml | 5 +- go.mod | 1 + go.sum | 14 ++++ main.go | 51 ++++++++---- sacn/receiver_pcap.go | 179 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 sacn/receiver_pcap.go diff --git a/config.example.toml b/config.example.toml index 05a1460..aa80052 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,8 @@ # artmap configuration -# Run with: go run . -config config.toml [-artnet-listen :6454] +# Run with: go run . -config config.toml [-artnet-listen :6454] [-sacn-pcap en0] +# +# Use -sacn-pcap to capture sACN via pcap instead of binding port 5568. +# This avoids port conflicts but requires root. Use "auto" for auto-detect. # Target addresses for ArtNet output universes # Each output universe needs a target IP (broadcast or unicast) diff --git a/go.mod b/go.mod index 6e548ce..21dfa3c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.4 require ( github.com/BurntSushi/toml v1.6.0 + github.com/google/gopacket v1.1.19 golang.org/x/net v0.48.0 ) diff --git a/go.sum b/go.sum index 2b7c131..2ed0923 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,20 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 8981ae9..12d01d5 100644 --- a/main.go +++ b/main.go @@ -18,20 +18,22 @@ import ( ) type App struct { - cfg *config.Config - artReceiver *artnet.Receiver - sacnReceiver *sacn.Receiver - artSender *artnet.Sender - sacnSender *sacn.Sender - discovery *artnet.Discovery - engine *remap.Engine - targets map[artnet.Universe]*net.UDPAddr - debug bool + cfg *config.Config + artReceiver *artnet.Receiver + sacnReceiver *sacn.Receiver + sacnPcapReceiver *sacn.PcapReceiver + artSender *artnet.Sender + sacnSender *sacn.Sender + discovery *artnet.Discovery + engine *remap.Engine + targets map[artnet.Universe]*net.UDPAddr + debug bool } func main() { configPath := flag.String("config", "config.toml", "path to config file") artnetListen := flag.String("artnet-listen", ":6454", "artnet listen address (empty to disable)") + sacnPcap := flag.String("sacn-pcap", "", "use pcap for sacn on interface (e.g. en0, eth0)") debug := flag.Bool("debug", false, "log incoming/outgoing dmx packets") flag.Parse() @@ -120,13 +122,29 @@ func main() { // Create sACN receiver if needed sacnUniverses := cfg.SACNSourceUniverses() if len(sacnUniverses) > 0 { - sacnReceiver, err := sacn.NewReceiver(sacnUniverses, app.HandleSACN) - if err != nil { - log.Fatalf("sacn receiver error: %v", err) + if *sacnPcap != "" { + // Use pcap-based receiver (requires root, avoids port conflicts) + iface := *sacnPcap + if iface == "auto" { + iface = sacn.DefaultInterface() + } + pcapReceiver, err := sacn.NewPcapReceiver(iface, sacnUniverses, app.HandleSACN) + if err != nil { + log.Fatalf("sacn pcap error: %v", err) + } + app.sacnPcapReceiver = pcapReceiver + pcapReceiver.Start() + log.Printf("sacn pcap listening iface=%s universes=%v", iface, sacnUniverses) + } else { + // Use standard UDP receiver + sacnReceiver, err := sacn.NewReceiver(sacnUniverses, app.HandleSACN) + if err != nil { + log.Fatalf("sacn receiver error: %v", err) + } + app.sacnReceiver = sacnReceiver + sacnReceiver.Start() + log.Printf("sacn listening universes=%v", sacnUniverses) } - app.sacnReceiver = sacnReceiver - sacnReceiver.Start() - log.Printf("sacn listening universes=%v", sacnUniverses) } // Start discovery @@ -144,6 +162,9 @@ func main() { if app.sacnReceiver != nil { app.sacnReceiver.Stop() } + if app.sacnPcapReceiver != nil { + app.sacnPcapReceiver.Stop() + } discovery.Stop() } diff --git a/sacn/receiver_pcap.go b/sacn/receiver_pcap.go new file mode 100644 index 0000000..39684a7 --- /dev/null +++ b/sacn/receiver_pcap.go @@ -0,0 +1,179 @@ +package sacn + +import ( + "encoding/binary" + "fmt" + "log" + + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/google/gopacket/pcap" +) + +// PcapReceiver listens for sACN packets using packet capture +type PcapReceiver struct { + handle *pcap.Handle + universes map[uint16]bool + handler DMXHandler + done chan struct{} +} + +// NewPcapReceiver creates a new sACN receiver using packet capture +// This requires root/admin privileges but avoids port conflicts +func NewPcapReceiver(iface string, universes []uint16, handler DMXHandler) (*PcapReceiver, error) { + // Open device for capturing + handle, err := pcap.OpenLive(iface, 1600, true, pcap.BlockForever) + if err != nil { + return nil, fmt.Errorf("pcap open: %w", err) + } + + // Filter for UDP port 5568 (sACN) - captures both directions + if err := handle.SetBPFFilter("udp port 5568"); err != nil { + handle.Close() + return nil, fmt.Errorf("pcap filter: %w", err) + } + + universeMap := make(map[uint16]bool) + for _, u := range universes { + universeMap[u] = true + } + + return &PcapReceiver{ + handle: handle, + universes: universeMap, + handler: handler, + done: make(chan struct{}), + }, nil +} + +// Start begins receiving packets +func (r *PcapReceiver) Start() { + go r.receiveLoop() +} + +// Stop stops the receiver +func (r *PcapReceiver) Stop() { + close(r.done) + r.handle.Close() +} + +func (r *PcapReceiver) receiveLoop() { + packetSource := gopacket.NewPacketSource(r.handle, r.handle.LinkType()) + + for { + select { + case <-r.done: + return + case packet, ok := <-packetSource.Packets(): + if !ok { + return + } + r.handlePacket(packet) + } + } +} + +func (r *PcapReceiver) handlePacket(packet gopacket.Packet) { + // Extract UDP layer + udpLayer := packet.Layer(layers.LayerTypeUDP) + if udpLayer == nil { + return + } + + udp, _ := udpLayer.(*layers.UDP) + if udp == nil { + return + } + + // Get payload + data := udp.Payload + 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 if we care about this universe + if !r.universes[universe] { + return + } + + // 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) +} + +// ListInterfaces returns available network interfaces for packet capture +func ListInterfaces() ([]string, error) { + devices, err := pcap.FindAllDevs() + if err != nil { + return nil, err + } + + var names []string + for _, dev := range devices { + names = append(names, dev.Name) + } + return names, nil +} + +// DefaultInterface returns a reasonable default interface for capture +func DefaultInterface() string { + devices, err := pcap.FindAllDevs() + if err != nil { + return "en0" + } + + // Prefer interfaces with addresses + for _, dev := range devices { + if len(dev.Addresses) > 0 && dev.Name != "lo0" && dev.Name != "lo" { + log.Printf("sacn pcap using interface: %s", dev.Name) + return dev.Name + } + } + + if len(devices) > 0 { + return devices[0].Name + } + return "en0" +}