Add pcap-based sACN receiver to avoid port conflicts

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 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2025-12-22 18:27:16 -08:00
parent e823d9838d
commit e289375df8
5 changed files with 234 additions and 16 deletions

View File

@@ -1,5 +1,8 @@
# artmap configuration # 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 <interface> 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 # Target addresses for ArtNet output universes
# Each output universe needs a target IP (broadcast or unicast) # Each output universe needs a target IP (broadcast or unicast)

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.4
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/google/gopacket v1.1.19
golang.org/x/net v0.48.0 golang.org/x/net v0.48.0
) )

14
go.sum
View File

@@ -1,6 +1,20 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 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 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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=

51
main.go
View File

@@ -18,20 +18,22 @@ import (
) )
type App struct { type App struct {
cfg *config.Config cfg *config.Config
artReceiver *artnet.Receiver artReceiver *artnet.Receiver
sacnReceiver *sacn.Receiver sacnReceiver *sacn.Receiver
artSender *artnet.Sender sacnPcapReceiver *sacn.PcapReceiver
sacnSender *sacn.Sender artSender *artnet.Sender
discovery *artnet.Discovery sacnSender *sacn.Sender
engine *remap.Engine discovery *artnet.Discovery
targets map[artnet.Universe]*net.UDPAddr engine *remap.Engine
debug bool targets map[artnet.Universe]*net.UDPAddr
debug bool
} }
func main() { func main() {
configPath := flag.String("config", "config.toml", "path to config file") configPath := flag.String("config", "config.toml", "path to config file")
artnetListen := flag.String("artnet-listen", ":6454", "artnet listen address (empty to disable)") 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") debug := flag.Bool("debug", false, "log incoming/outgoing dmx packets")
flag.Parse() flag.Parse()
@@ -120,13 +122,29 @@ func main() {
// Create sACN receiver if needed // Create sACN receiver if needed
sacnUniverses := cfg.SACNSourceUniverses() sacnUniverses := cfg.SACNSourceUniverses()
if len(sacnUniverses) > 0 { if len(sacnUniverses) > 0 {
sacnReceiver, err := sacn.NewReceiver(sacnUniverses, app.HandleSACN) if *sacnPcap != "" {
if err != nil { // Use pcap-based receiver (requires root, avoids port conflicts)
log.Fatalf("sacn receiver error: %v", err) 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 // Start discovery
@@ -144,6 +162,9 @@ func main() {
if app.sacnReceiver != nil { if app.sacnReceiver != nil {
app.sacnReceiver.Stop() app.sacnReceiver.Stop()
} }
if app.sacnPcapReceiver != nil {
app.sacnPcapReceiver.Stop()
}
discovery.Stop() discovery.Stop()
} }

179
sacn/receiver_pcap.go Normal file
View File

@@ -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"
}