Initial sACN library with protocol, sender, receiver, and discovery
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
124
discovery.go
Normal file
124
discovery.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package sacn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
CID [16]byte
|
||||||
|
SourceName string
|
||||||
|
IP net.IP
|
||||||
|
Universes []uint16
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Discovery struct {
|
||||||
|
sources map[string]*Source
|
||||||
|
mu sync.RWMutex
|
||||||
|
onChange func(*Source)
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDiscovery() *Discovery {
|
||||||
|
return &Discovery{
|
||||||
|
sources: map[string]*Source{},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) SetOnChange(fn func(*Source)) {
|
||||||
|
d.onChange = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) HandleDiscoveryPacket(src *net.UDPAddr, pkt *DiscoveryPacket) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
cidStr := FormatCID(pkt.CID)
|
||||||
|
|
||||||
|
source, exists := d.sources[cidStr]
|
||||||
|
if !exists {
|
||||||
|
source = &Source{
|
||||||
|
CID: pkt.CID,
|
||||||
|
}
|
||||||
|
d.sources[cidStr] = source
|
||||||
|
}
|
||||||
|
|
||||||
|
source.SourceName = pkt.SourceName
|
||||||
|
source.IP = src.IP
|
||||||
|
source.Universes = pkt.Universes
|
||||||
|
source.LastSeen = time.Now()
|
||||||
|
|
||||||
|
if d.onChange != nil {
|
||||||
|
d.onChange(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) GetSource(cid string) *Source {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return d.sources[cid]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) GetSourceByIP(ip net.IP) *Source {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, source := range d.sources {
|
||||||
|
if source.IP != nil && source.IP.Equal(ip) {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) GetAllSources() []*Source {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*Source, 0, len(d.sources))
|
||||||
|
for _, source := range d.sources {
|
||||||
|
result = append(result, source)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) Expire() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-60 * time.Second)
|
||||||
|
for cid, source := range d.sources {
|
||||||
|
if source.LastSeen.Before(cutoff) {
|
||||||
|
delete(d.sources, cid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) StartCleanup() {
|
||||||
|
go d.cleanupLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) Stop() {
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
default:
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) cleanupLoop() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
d.Expire()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
fuzz_test.go
Normal file
107
fuzz_test.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package sacn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FuzzParsePacket(f *testing.F) {
|
||||||
|
cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
|
validPacket := BuildDataPacket(1, 0, "test", cid, make([]byte, 512))
|
||||||
|
f.Add(validPacket)
|
||||||
|
f.Add(BuildDataPacket(1, 0, "test", cid, make([]byte, 100)))
|
||||||
|
f.Add(BuildDataPacket(63999, 255, "long source name here", cid, make([]byte, 512)))
|
||||||
|
f.Add(BuildDiscoveryPacket("test", cid, 0, 0, []uint16{1, 2, 3}))
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add(make([]byte, 125))
|
||||||
|
f.Add(make([]byte, 126))
|
||||||
|
f.Add(make([]byte, 638))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
pkt, err := ParsePacket(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch p := pkt.(type) {
|
||||||
|
case *DataPacket:
|
||||||
|
if p.DataLen > 512 {
|
||||||
|
t.Fatalf("data length should be <= 512, got %d", p.DataLen)
|
||||||
|
}
|
||||||
|
case *DiscoveryPacket:
|
||||||
|
for _, u := range p.Universes {
|
||||||
|
if u < 1 || u > 63999 {
|
||||||
|
t.Fatalf("universe out of range: %d", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzBuildParseRoundtrip(f *testing.F) {
|
||||||
|
f.Add(uint16(1), uint8(0), "test", make([]byte, 512))
|
||||||
|
f.Add(uint16(63999), uint8(255), "source", make([]byte, 100))
|
||||||
|
f.Add(uint16(100), uint8(128), "", make([]byte, 0))
|
||||||
|
f.Add(uint16(1), uint8(0), "a very long source name that exceeds normal limits", make([]byte, 512))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, universe uint16, seq uint8, sourceName string, dmxInput []byte) {
|
||||||
|
if universe < 1 || universe > 63999 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
|
packet := BuildDataPacket(universe, seq, sourceName, cid, dmxInput)
|
||||||
|
pkt, err := ParsePacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse packet we just built: %v", err)
|
||||||
|
}
|
||||||
|
dataPkt, ok := pkt.(*DataPacket)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected DataPacket, got %T", pkt)
|
||||||
|
}
|
||||||
|
if dataPkt.Universe != universe {
|
||||||
|
t.Fatalf("universe mismatch: sent %d, got %d", universe, dataPkt.Universe)
|
||||||
|
}
|
||||||
|
expectedLen := len(dmxInput)
|
||||||
|
if expectedLen > 512 {
|
||||||
|
expectedLen = 512
|
||||||
|
}
|
||||||
|
if !bytes.Equal(dataPkt.Data[:expectedLen], dmxInput[:expectedLen]) {
|
||||||
|
t.Fatalf("dmx data mismatch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzDiscoveryRoundtrip(f *testing.F) {
|
||||||
|
f.Add("test", uint8(0), uint8(0), []byte{0, 1, 0, 2, 0, 3})
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, sourceName string, page, lastPage uint8, universeBytes []byte) {
|
||||||
|
universes := make([]uint16, 0, len(universeBytes)/2)
|
||||||
|
for i := 0; i+1 < len(universeBytes); i += 2 {
|
||||||
|
u := uint16(universeBytes[i])<<8 | uint16(universeBytes[i+1])
|
||||||
|
if u >= 1 && u <= 63999 {
|
||||||
|
universes = append(universes, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(universes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
|
||||||
|
packet := BuildDiscoveryPacket(sourceName, cid, page, lastPage, universes)
|
||||||
|
pkt, err := ParsePacket(packet)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse discovery packet: %v", err)
|
||||||
|
}
|
||||||
|
discPkt, ok := pkt.(*DiscoveryPacket)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected DiscoveryPacket, got %T", pkt)
|
||||||
|
}
|
||||||
|
if discPkt.Page != page {
|
||||||
|
t.Fatalf("page mismatch: sent %d, got %d", page, discPkt.Page)
|
||||||
|
}
|
||||||
|
if discPkt.LastPage != lastPage {
|
||||||
|
t.Fatalf("lastPage mismatch: sent %d, got %d", lastPage, discPkt.LastPage)
|
||||||
|
}
|
||||||
|
if len(discPkt.Universes) != len(universes) {
|
||||||
|
t.Fatalf("universe count mismatch: sent %d, got %d", len(universes), len(discPkt.Universes))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module github.com/gopatchy/sacn
|
||||||
|
|
||||||
|
go 1.25.6
|
||||||
|
|
||||||
|
require golang.org/x/net v0.49.0
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.40.0 // indirect
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
255
protocol.go
Normal file
255
protocol.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package sacn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Port = 5568
|
||||||
|
|
||||||
|
VectorRootE131Data = 0x00000004
|
||||||
|
VectorRootE131Extended = 0x00000008
|
||||||
|
VectorE131DataPacket = 0x00000002
|
||||||
|
VectorE131Discovery = 0x00000002
|
||||||
|
VectorDMPSetProperty = 0x02
|
||||||
|
VectorUniverseDiscovery = 0x00000001
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
PacketIdentifier = [12]byte{
|
||||||
|
0x41, 0x53, 0x43, 0x2d, 0x45, 0x31, 0x2e, 0x31, 0x37, 0x00, 0x00, 0x00,
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoveryAddr = &net.UDPAddr{
|
||||||
|
IP: net.IPv4(239, 255, 250, 214),
|
||||||
|
Port: Port,
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrInvalidHeader = errors.New("invalid sACN header")
|
||||||
|
ErrPacketTooShort = errors.New("packet too short")
|
||||||
|
ErrInvalidVector = errors.New("invalid vector")
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataPacket struct {
|
||||||
|
CID [16]byte
|
||||||
|
SourceName string
|
||||||
|
Priority uint8
|
||||||
|
Sequence uint8
|
||||||
|
Universe uint16
|
||||||
|
Data [512]byte
|
||||||
|
DataLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DiscoveryPacket struct {
|
||||||
|
CID [16]byte
|
||||||
|
SourceName string
|
||||||
|
Page uint8
|
||||||
|
LastPage uint8
|
||||||
|
Universes []uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
func MulticastAddr(universe uint16) *net.UDPAddr {
|
||||||
|
return &net.UDPAddr{
|
||||||
|
IP: net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff)),
|
||||||
|
Port: Port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePacket(data []byte) (interface{}, error) {
|
||||||
|
if len(data) < 22 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[4] != PacketIdentifier[0] || data[5] != PacketIdentifier[1] ||
|
||||||
|
data[6] != PacketIdentifier[2] || data[7] != PacketIdentifier[3] {
|
||||||
|
return nil, ErrInvalidHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
rootVector := binary.BigEndian.Uint32(data[18:22])
|
||||||
|
|
||||||
|
switch rootVector {
|
||||||
|
case VectorRootE131Data:
|
||||||
|
return parseDataPacket(data)
|
||||||
|
case VectorRootE131Extended:
|
||||||
|
return parseExtendedPacket(data)
|
||||||
|
default:
|
||||||
|
return nil, ErrInvalidVector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDataPacket(data []byte) (*DataPacket, error) {
|
||||||
|
if len(data) < 126 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
framingVector := binary.BigEndian.Uint32(data[40:44])
|
||||||
|
if framingVector != VectorE131DataPacket {
|
||||||
|
return nil, ErrInvalidVector
|
||||||
|
}
|
||||||
|
|
||||||
|
if data[117] != VectorDMPSetProperty {
|
||||||
|
return nil, ErrInvalidVector
|
||||||
|
}
|
||||||
|
|
||||||
|
propCount := binary.BigEndian.Uint16(data[123:125])
|
||||||
|
if propCount < 1 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
dmxLen := int(propCount) - 1
|
||||||
|
if dmxLen > 512 {
|
||||||
|
dmxLen = 512
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 126+dmxLen {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := &DataPacket{
|
||||||
|
SourceName: strings.TrimRight(string(data[44:108]), "\x00"),
|
||||||
|
Priority: data[108],
|
||||||
|
Sequence: data[111],
|
||||||
|
Universe: binary.BigEndian.Uint16(data[113:115]),
|
||||||
|
DataLen: dmxLen,
|
||||||
|
}
|
||||||
|
copy(pkt.CID[:], data[22:38])
|
||||||
|
copy(pkt.Data[:], data[126:126+dmxLen])
|
||||||
|
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExtendedPacket(data []byte) (interface{}, error) {
|
||||||
|
if len(data) < 118 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
framingVector := binary.BigEndian.Uint32(data[40:44])
|
||||||
|
if framingVector != VectorE131Discovery {
|
||||||
|
return nil, ErrInvalidVector
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 120 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryVector := binary.BigEndian.Uint32(data[114:118])
|
||||||
|
if discoveryVector != VectorUniverseDiscovery {
|
||||||
|
return nil, ErrInvalidVector
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := &DiscoveryPacket{
|
||||||
|
SourceName: strings.TrimRight(string(data[44:108]), "\x00"),
|
||||||
|
Page: data[118],
|
||||||
|
LastPage: data[119],
|
||||||
|
}
|
||||||
|
copy(pkt.CID[:], data[22:38])
|
||||||
|
|
||||||
|
universeCount := (len(data) - 120) / 2
|
||||||
|
pkt.Universes = make([]uint16, 0, universeCount)
|
||||||
|
for i := 0; i < universeCount; i++ {
|
||||||
|
u := binary.BigEndian.Uint16(data[120+i*2 : 122+i*2])
|
||||||
|
if u >= 1 && u <= 63999 {
|
||||||
|
pkt.Universes = append(pkt.Universes, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildDataPacket(universe uint16, sequence uint8, sourceName string, cid [16]byte, data []byte) []byte {
|
||||||
|
dataLen := len(data)
|
||||||
|
if dataLen > 512 {
|
||||||
|
dataLen = 512
|
||||||
|
}
|
||||||
|
|
||||||
|
pktLen := 126 + dataLen
|
||||||
|
buf := make([]byte, pktLen)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(buf[0:2], 0x0010)
|
||||||
|
binary.BigEndian.PutUint16(buf[2:4], 0x0000)
|
||||||
|
copy(buf[4:16], PacketIdentifier[:])
|
||||||
|
rootLen := pktLen - 16
|
||||||
|
binary.BigEndian.PutUint16(buf[16:18], 0x7000|uint16(rootLen))
|
||||||
|
binary.BigEndian.PutUint32(buf[18:22], VectorRootE131Data)
|
||||||
|
copy(buf[22:38], cid[:])
|
||||||
|
|
||||||
|
framingLen := pktLen - 38
|
||||||
|
binary.BigEndian.PutUint16(buf[38:40], 0x7000|uint16(framingLen))
|
||||||
|
binary.BigEndian.PutUint32(buf[40:44], VectorE131DataPacket)
|
||||||
|
copy(buf[44:108], sourceName)
|
||||||
|
buf[108] = 100
|
||||||
|
binary.BigEndian.PutUint16(buf[109:111], 0)
|
||||||
|
buf[111] = sequence
|
||||||
|
buf[112] = 0
|
||||||
|
binary.BigEndian.PutUint16(buf[113:115], universe)
|
||||||
|
|
||||||
|
dmpLen := 11 + dataLen
|
||||||
|
binary.BigEndian.PutUint16(buf[115:117], 0x7000|uint16(dmpLen))
|
||||||
|
buf[117] = VectorDMPSetProperty
|
||||||
|
buf[118] = 0xa1
|
||||||
|
binary.BigEndian.PutUint16(buf[119:121], 0)
|
||||||
|
binary.BigEndian.PutUint16(buf[121:123], 1)
|
||||||
|
binary.BigEndian.PutUint16(buf[123:125], uint16(dataLen+1))
|
||||||
|
buf[125] = 0
|
||||||
|
copy(buf[126:], data[:dataLen])
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildDiscoveryPacket(sourceName string, cid [16]byte, page, lastPage uint8, universes []uint16) []byte {
|
||||||
|
universeCount := len(universes)
|
||||||
|
if universeCount > 512 {
|
||||||
|
universeCount = 512
|
||||||
|
}
|
||||||
|
|
||||||
|
pktLen := 120 + universeCount*2
|
||||||
|
buf := make([]byte, pktLen)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(buf[0:2], 0x0010)
|
||||||
|
binary.BigEndian.PutUint16(buf[2:4], 0x0000)
|
||||||
|
copy(buf[4:16], PacketIdentifier[:])
|
||||||
|
rootLen := pktLen - 16
|
||||||
|
binary.BigEndian.PutUint16(buf[16:18], 0x7000|uint16(rootLen))
|
||||||
|
binary.BigEndian.PutUint32(buf[18:22], VectorRootE131Extended)
|
||||||
|
copy(buf[22:38], cid[:])
|
||||||
|
|
||||||
|
framingLen := pktLen - 38
|
||||||
|
binary.BigEndian.PutUint16(buf[38:40], 0x7000|uint16(framingLen))
|
||||||
|
binary.BigEndian.PutUint32(buf[40:44], VectorE131Discovery)
|
||||||
|
copy(buf[44:108], sourceName)
|
||||||
|
binary.BigEndian.PutUint32(buf[108:112], 0)
|
||||||
|
|
||||||
|
discoveryLen := pktLen - 112
|
||||||
|
binary.BigEndian.PutUint16(buf[112:114], 0x7000|uint16(discoveryLen))
|
||||||
|
binary.BigEndian.PutUint32(buf[114:118], VectorUniverseDiscovery)
|
||||||
|
buf[118] = page
|
||||||
|
buf[119] = lastPage
|
||||||
|
for i := 0; i < universeCount; i++ {
|
||||||
|
binary.BigEndian.PutUint16(buf[120+i*2:122+i*2], universes[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatCID(cid [16]byte) string {
|
||||||
|
return strings.ToLower(formatUUID(cid))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUUID(b [16]byte) string {
|
||||||
|
const hexChars = "0123456789ABCDEF"
|
||||||
|
result := make([]byte, 36)
|
||||||
|
idx := 0
|
||||||
|
for i, v := range b {
|
||||||
|
if i == 4 || i == 6 || i == 8 || i == 10 {
|
||||||
|
result[idx] = '-'
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
result[idx] = hexChars[v>>4]
|
||||||
|
result[idx+1] = hexChars[v&0x0f]
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
return string(result)
|
||||||
|
}
|
||||||
100
receiver.go
Normal file
100
receiver.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package sacn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Receiver struct {
|
||||||
|
conn *ipv4.PacketConn
|
||||||
|
rawConn net.PacketConn
|
||||||
|
handler func(src *net.UDPAddr, pkt interface{})
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReceiver(ifaceName string) (*Receiver, error) {
|
||||||
|
c, err := net.ListenPacket("udp4", ":5568")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p := ipv4.NewPacketConn(c)
|
||||||
|
|
||||||
|
if ifaceName != "" {
|
||||||
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
c.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.SetMulticastInterface(iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Receiver{
|
||||||
|
conn: p,
|
||||||
|
rawConn: c,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) JoinUniverse(iface *net.Interface, universe uint16) error {
|
||||||
|
group := net.IPv4(239, 255, byte(universe>>8), byte(universe&0xff))
|
||||||
|
return r.conn.JoinGroup(iface, &net.UDPAddr{IP: group})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) JoinDiscovery(iface *net.Interface) error {
|
||||||
|
return r.conn.JoinGroup(iface, DiscoveryAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) SetHandler(fn func(src *net.UDPAddr, pkt interface{})) {
|
||||||
|
r.handler = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) Start() {
|
||||||
|
go r.receiveLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) Stop() {
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
default:
|
||||||
|
close(r.done)
|
||||||
|
}
|
||||||
|
r.rawConn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) receiveLoop() {
|
||||||
|
buf := make([]byte, 638)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
r.rawConn.SetReadDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
n, _, src, err := r.conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, err := ParsePacket(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.handler != nil {
|
||||||
|
r.handler(src.(*net.UDPAddr), pkt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
sender.go
Normal file
145
sender.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package sacn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sender struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
sourceName string
|
||||||
|
cid [16]byte
|
||||||
|
sequences map[uint16]uint8
|
||||||
|
seqMu sync.Mutex
|
||||||
|
universes map[uint16]bool
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSender(sourceName string, ifaceName string) (*Sender, error) {
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ifaceName != "" {
|
||||||
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := ipv4.NewPacketConn(conn)
|
||||||
|
if err := p.SetMulticastInterface(iface); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cid [16]byte
|
||||||
|
rand.Read(cid[:])
|
||||||
|
|
||||||
|
return &Sender{
|
||||||
|
conn: conn,
|
||||||
|
sourceName: sourceName,
|
||||||
|
cid: cid,
|
||||||
|
sequences: map[uint16]uint8{},
|
||||||
|
universes: map[uint16]bool{},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) CID() [16]byte {
|
||||||
|
return s.cid
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) RegisterUniverse(universe uint16) {
|
||||||
|
s.seqMu.Lock()
|
||||||
|
s.universes[universe] = true
|
||||||
|
s.seqMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) StartDiscovery() {
|
||||||
|
go s.discoveryLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) Close() error {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
default:
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
return s.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) discoveryLoop() {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.sendDiscovery()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
s.sendDiscovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) sendDiscovery() {
|
||||||
|
s.seqMu.Lock()
|
||||||
|
universes := make([]uint16, 0, len(s.universes))
|
||||||
|
for u := range s.universes {
|
||||||
|
universes = append(universes, u)
|
||||||
|
}
|
||||||
|
s.seqMu.Unlock()
|
||||||
|
|
||||||
|
if len(universes) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(universes, func(i, j int) bool { return universes[i] < universes[j] })
|
||||||
|
|
||||||
|
const maxPerPage = 512
|
||||||
|
totalPages := (len(universes) + maxPerPage - 1) / maxPerPage
|
||||||
|
|
||||||
|
for page := 0; page < totalPages; page++ {
|
||||||
|
start := page * maxPerPage
|
||||||
|
end := start + maxPerPage
|
||||||
|
if end > len(universes) {
|
||||||
|
end = len(universes)
|
||||||
|
}
|
||||||
|
pkt := BuildDiscoveryPacket(s.sourceName, s.cid, uint8(page), uint8(totalPages-1), universes[start:end])
|
||||||
|
s.conn.WriteToUDP(pkt, DiscoveryAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user