Initial implementation of ArtNet remapping proxy
Channel-level DMX remapping between ArtNet universes with: - TOML configuration with multiple address formats (net.subnet.universe, plain number) - ArtPoll discovery for output nodes - Configurable channel ranges for fixture spillover handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
config.toml
|
||||||
|
artmap
|
||||||
223
artnet/discovery.go
Normal file
223
artnet/discovery.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package artnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node represents a discovered ArtNet node
|
||||||
|
type Node struct {
|
||||||
|
IP net.IP
|
||||||
|
Port uint16
|
||||||
|
ShortName string
|
||||||
|
LongName string
|
||||||
|
Universes []Universe
|
||||||
|
LastSeen time.Time
|
||||||
|
CanTransmit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery handles ArtNet node discovery
|
||||||
|
type Discovery struct {
|
||||||
|
sender *Sender
|
||||||
|
nodes map[string]*Node // keyed by IP string
|
||||||
|
nodesMu sync.RWMutex
|
||||||
|
localIP [4]byte
|
||||||
|
shortName string
|
||||||
|
longName string
|
||||||
|
universes []Universe
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDiscovery creates a new discovery handler
|
||||||
|
func NewDiscovery(sender *Sender, shortName, longName string, universes []Universe) *Discovery {
|
||||||
|
return &Discovery{
|
||||||
|
sender: sender,
|
||||||
|
nodes: make(map[string]*Node),
|
||||||
|
shortName: shortName,
|
||||||
|
longName: longName,
|
||||||
|
universes: universes,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins periodic discovery
|
||||||
|
func (d *Discovery) Start() {
|
||||||
|
// Get local IP
|
||||||
|
d.localIP = d.getLocalIP()
|
||||||
|
|
||||||
|
// Start periodic poll
|
||||||
|
go d.pollLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops discovery
|
||||||
|
func (d *Discovery) Stop() {
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) pollLoop() {
|
||||||
|
// Send initial poll
|
||||||
|
if err := d.sender.SendPoll(); err != nil {
|
||||||
|
log.Printf("failed to send ArtPoll: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
cleanupTicker := time.NewTicker(30 * time.Second)
|
||||||
|
defer cleanupTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := d.sender.SendPoll(); err != nil {
|
||||||
|
log.Printf("failed to send ArtPoll: %v", err)
|
||||||
|
}
|
||||||
|
case <-cleanupTicker.C:
|
||||||
|
d.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) cleanup() {
|
||||||
|
d.nodesMu.Lock()
|
||||||
|
defer d.nodesMu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-60 * time.Second)
|
||||||
|
for ip, node := range d.nodes {
|
||||||
|
if node.LastSeen.Before(cutoff) {
|
||||||
|
log.Printf("node %s (%s) timed out", ip, node.ShortName)
|
||||||
|
delete(d.nodes, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePollReply processes an incoming ArtPollReply
|
||||||
|
func (d *Discovery) HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) {
|
||||||
|
d.nodesMu.Lock()
|
||||||
|
defer d.nodesMu.Unlock()
|
||||||
|
|
||||||
|
ip := src.IP.String()
|
||||||
|
|
||||||
|
// Skip our own replies
|
||||||
|
localIP := net.IP(d.localIP[:])
|
||||||
|
if src.IP.Equal(localIP) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse universes from SwOut
|
||||||
|
var universes []Universe
|
||||||
|
numPorts := int(pkt.NumPortsLo)
|
||||||
|
if numPorts > 4 {
|
||||||
|
numPorts = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < numPorts; i++ {
|
||||||
|
// Check if port can output DMX
|
||||||
|
if pkt.PortTypes[i]&0x80 != 0 {
|
||||||
|
u := NewUniverse(pkt.NetSwitch, pkt.SubSwitch, pkt.SwOut[i])
|
||||||
|
universes = append(universes, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shortName := string(pkt.ShortName[:])
|
||||||
|
// Trim null bytes
|
||||||
|
for i, b := range pkt.ShortName {
|
||||||
|
if b == 0 {
|
||||||
|
shortName = string(pkt.ShortName[:i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
longName := string(pkt.LongName[:])
|
||||||
|
for i, b := range pkt.LongName {
|
||||||
|
if b == 0 {
|
||||||
|
longName = string(pkt.LongName[:i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node, exists := d.nodes[ip]
|
||||||
|
if !exists {
|
||||||
|
node = &Node{
|
||||||
|
IP: src.IP,
|
||||||
|
Port: uint16(src.Port),
|
||||||
|
}
|
||||||
|
d.nodes[ip] = node
|
||||||
|
log.Printf("discovered node: %s (%s) - universes: %v", ip, shortName, universes)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.ShortName = shortName
|
||||||
|
node.LongName = longName
|
||||||
|
node.Universes = universes
|
||||||
|
node.LastSeen = time.Now()
|
||||||
|
node.CanTransmit = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePoll processes an incoming ArtPoll and responds
|
||||||
|
func (d *Discovery) HandlePoll(src *net.UDPAddr) {
|
||||||
|
// Respond with our info
|
||||||
|
err := d.sender.SendPollReply(src, d.localIP, d.shortName, d.longName, d.universes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to send ArtPollReply: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodesForUniverse returns nodes that support a given universe
|
||||||
|
func (d *Discovery) GetNodesForUniverse(universe Universe) []*Node {
|
||||||
|
d.nodesMu.RLock()
|
||||||
|
defer d.nodesMu.RUnlock()
|
||||||
|
|
||||||
|
var result []*Node
|
||||||
|
for _, node := range d.nodes {
|
||||||
|
for _, u := range node.Universes {
|
||||||
|
if u == universe {
|
||||||
|
result = append(result, node)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllNodes returns all discovered nodes
|
||||||
|
func (d *Discovery) GetAllNodes() []*Node {
|
||||||
|
d.nodesMu.RLock()
|
||||||
|
defer d.nodesMu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*Node, 0, len(d.nodes))
|
||||||
|
for _, node := range d.nodes {
|
||||||
|
result = append(result, node)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Discovery) getLocalIP() [4]byte {
|
||||||
|
var result [4]byte
|
||||||
|
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||||
|
if ip4 := ipnet.IP.To4(); ip4 != nil {
|
||||||
|
copy(result[:], ip4)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLocalIP sets the local IP for PollReply responses
|
||||||
|
func (d *Discovery) SetLocalIP(ip net.IP) {
|
||||||
|
if ip4 := ip.To4(); ip4 != nil {
|
||||||
|
copy(d.localIP[:], ip4)
|
||||||
|
}
|
||||||
|
}
|
||||||
292
artnet/protocol.go
Normal file
292
artnet/protocol.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package artnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Port = 6454
|
||||||
|
|
||||||
|
// OpCodes
|
||||||
|
OpPoll = 0x2000
|
||||||
|
OpPollReply = 0x2100
|
||||||
|
OpDmx = 0x5000
|
||||||
|
|
||||||
|
// Protocol
|
||||||
|
ProtocolVersion = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ArtNetID = [8]byte{'A', 'r', 't', '-', 'N', 'e', 't', 0x00}
|
||||||
|
|
||||||
|
ErrInvalidPacket = errors.New("invalid ArtNet packet")
|
||||||
|
ErrInvalidHeader = errors.New("invalid ArtNet header")
|
||||||
|
ErrUnknownOpCode = errors.New("unknown OpCode")
|
||||||
|
ErrPacketTooShort = errors.New("packet too short")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Universe represents an ArtNet universe address (15-bit)
|
||||||
|
// Bits 14-8: Net (0-127)
|
||||||
|
// Bits 7-4: SubNet (0-15)
|
||||||
|
// Bits 3-0: Universe (0-15)
|
||||||
|
type Universe uint16
|
||||||
|
|
||||||
|
func NewUniverse(net, subnet, universe uint8) Universe {
|
||||||
|
return Universe((uint16(net&0x7F) << 8) | (uint16(subnet&0x0F) << 4) | uint16(universe&0x0F))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Universe) Net() uint8 {
|
||||||
|
return uint8((u >> 8) & 0x7F)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Universe) SubNet() uint8 {
|
||||||
|
return uint8((u >> 4) & 0x0F)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Universe) Universe() uint8 {
|
||||||
|
return uint8(u & 0x0F)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u Universe) String() string {
|
||||||
|
return fmt.Sprintf("%d.%d.%d", u.Net(), u.SubNet(), u.Universe())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header is the common ArtNet packet header
|
||||||
|
type Header struct {
|
||||||
|
ID [8]byte
|
||||||
|
OpCode uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// DMXPacket represents an ArtDmx packet (OpCode 0x5000)
|
||||||
|
type DMXPacket struct {
|
||||||
|
ProtocolVersion uint16 // High byte first
|
||||||
|
Sequence uint8 // 0x00 to disable, 0x01-0xFF sequence
|
||||||
|
Physical uint8 // Physical input port
|
||||||
|
Universe Universe // Universe address (low byte first in wire format)
|
||||||
|
Length uint16 // Data length (high byte first), 2-512, even
|
||||||
|
Data [512]byte // DMX data
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollPacket represents an ArtPoll packet (OpCode 0x2000)
|
||||||
|
type PollPacket struct {
|
||||||
|
ProtocolVersion uint16
|
||||||
|
Flags uint8
|
||||||
|
DiagPriority uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollReplyPacket represents an ArtPollReply packet (OpCode 0x2100)
|
||||||
|
type PollReplyPacket struct {
|
||||||
|
IPAddress [4]byte
|
||||||
|
Port uint16
|
||||||
|
VersionInfo uint16
|
||||||
|
NetSwitch uint8
|
||||||
|
SubSwitch uint8
|
||||||
|
OemHi uint8
|
||||||
|
Oem uint8
|
||||||
|
UbeaVersion uint8
|
||||||
|
Status1 uint8
|
||||||
|
EstaMan uint16
|
||||||
|
ShortName [18]byte
|
||||||
|
LongName [64]byte
|
||||||
|
NodeReport [64]byte
|
||||||
|
NumPortsHi uint8
|
||||||
|
NumPortsLo uint8
|
||||||
|
PortTypes [4]byte
|
||||||
|
GoodInput [4]byte
|
||||||
|
GoodOutput [4]byte
|
||||||
|
SwIn [4]byte
|
||||||
|
SwOut [4]byte
|
||||||
|
SwVideo uint8
|
||||||
|
SwMacro uint8
|
||||||
|
SwRemote uint8
|
||||||
|
Spare [3]byte
|
||||||
|
Style uint8
|
||||||
|
MAC [6]byte
|
||||||
|
BindIP [4]byte
|
||||||
|
BindIndex uint8
|
||||||
|
Status2 uint8
|
||||||
|
Filler [26]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePacket parses a raw ArtNet packet and returns the OpCode and parsed data
|
||||||
|
func ParsePacket(data []byte) (uint16, interface{}, error) {
|
||||||
|
if len(data) < 10 {
|
||||||
|
return 0, nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check header
|
||||||
|
if !bytes.Equal(data[:8], ArtNetID[:]) {
|
||||||
|
return 0, nil, ErrInvalidHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
opCode := binary.LittleEndian.Uint16(data[8:10])
|
||||||
|
|
||||||
|
switch opCode {
|
||||||
|
case OpDmx:
|
||||||
|
pkt, err := parseDMXPacket(data)
|
||||||
|
return opCode, pkt, err
|
||||||
|
case OpPoll:
|
||||||
|
pkt, err := parsePollPacket(data)
|
||||||
|
return opCode, pkt, err
|
||||||
|
case OpPollReply:
|
||||||
|
pkt, err := parsePollReplyPacket(data)
|
||||||
|
return opCode, pkt, err
|
||||||
|
default:
|
||||||
|
return opCode, nil, nil // Unknown but valid packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDMXPacket(data []byte) (*DMXPacket, error) {
|
||||||
|
if len(data) < 18 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := &DMXPacket{
|
||||||
|
ProtocolVersion: binary.BigEndian.Uint16(data[10:12]),
|
||||||
|
Sequence: data[12],
|
||||||
|
Physical: data[13],
|
||||||
|
Universe: Universe(binary.LittleEndian.Uint16(data[14:16])),
|
||||||
|
Length: binary.BigEndian.Uint16(data[16:18]),
|
||||||
|
}
|
||||||
|
|
||||||
|
dataLen := int(pkt.Length)
|
||||||
|
if dataLen > 512 {
|
||||||
|
dataLen = 512
|
||||||
|
}
|
||||||
|
if len(data) >= 18+dataLen {
|
||||||
|
copy(pkt.Data[:], data[18:18+dataLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePollPacket(data []byte) (*PollPacket, error) {
|
||||||
|
if len(data) < 14 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PollPacket{
|
||||||
|
ProtocolVersion: binary.BigEndian.Uint16(data[10:12]),
|
||||||
|
Flags: data[12],
|
||||||
|
DiagPriority: data[13],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePollReplyPacket(data []byte) (*PollReplyPacket, error) {
|
||||||
|
if len(data) < 207 {
|
||||||
|
return nil, ErrPacketTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt := &PollReplyPacket{
|
||||||
|
Port: binary.LittleEndian.Uint16(data[14:16]),
|
||||||
|
VersionInfo: binary.BigEndian.Uint16(data[16:18]),
|
||||||
|
NetSwitch: data[18],
|
||||||
|
SubSwitch: data[19],
|
||||||
|
OemHi: data[20],
|
||||||
|
Oem: data[21],
|
||||||
|
UbeaVersion: data[22],
|
||||||
|
Status1: data[23],
|
||||||
|
EstaMan: binary.LittleEndian.Uint16(data[24:26]),
|
||||||
|
NumPortsHi: data[172],
|
||||||
|
NumPortsLo: data[173],
|
||||||
|
Style: data[200],
|
||||||
|
BindIndex: data[212],
|
||||||
|
Status2: data[213],
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(pkt.IPAddress[:], data[10:14])
|
||||||
|
copy(pkt.ShortName[:], data[26:44])
|
||||||
|
copy(pkt.LongName[:], data[44:108])
|
||||||
|
copy(pkt.NodeReport[:], data[108:172])
|
||||||
|
copy(pkt.PortTypes[:], data[174:178])
|
||||||
|
copy(pkt.GoodInput[:], data[178:182])
|
||||||
|
copy(pkt.GoodOutput[:], data[182:186])
|
||||||
|
copy(pkt.SwIn[:], data[186:190])
|
||||||
|
copy(pkt.SwOut[:], data[190:194])
|
||||||
|
copy(pkt.MAC[:], data[201:207])
|
||||||
|
copy(pkt.BindIP[:], data[207:211])
|
||||||
|
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDMXPacket creates a raw ArtDmx packet
|
||||||
|
func BuildDMXPacket(universe Universe, sequence uint8, data []byte) []byte {
|
||||||
|
dataLen := len(data)
|
||||||
|
if dataLen > 512 {
|
||||||
|
dataLen = 512
|
||||||
|
}
|
||||||
|
// Length must be even
|
||||||
|
if dataLen%2 != 0 {
|
||||||
|
dataLen++
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 18+dataLen)
|
||||||
|
|
||||||
|
// Header
|
||||||
|
copy(buf[0:8], ArtNetID[:])
|
||||||
|
binary.LittleEndian.PutUint16(buf[8:10], OpDmx)
|
||||||
|
|
||||||
|
// DMX packet fields
|
||||||
|
binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion)
|
||||||
|
buf[12] = sequence
|
||||||
|
buf[13] = 0 // Physical
|
||||||
|
binary.LittleEndian.PutUint16(buf[14:16], uint16(universe))
|
||||||
|
binary.BigEndian.PutUint16(buf[16:18], uint16(dataLen))
|
||||||
|
copy(buf[18:], data[:dataLen])
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPollPacket creates an ArtPoll packet
|
||||||
|
func BuildPollPacket() []byte {
|
||||||
|
buf := make([]byte, 14)
|
||||||
|
|
||||||
|
copy(buf[0:8], ArtNetID[:])
|
||||||
|
binary.LittleEndian.PutUint16(buf[8:10], OpPoll)
|
||||||
|
binary.BigEndian.PutUint16(buf[10:12], ProtocolVersion)
|
||||||
|
buf[12] = 0x00 // Flags
|
||||||
|
buf[13] = 0x00 // DiagPriority
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildPollReplyPacket creates an ArtPollReply packet
|
||||||
|
func BuildPollReplyPacket(ip [4]byte, shortName, longName string, universes []Universe) []byte {
|
||||||
|
buf := make([]byte, 239)
|
||||||
|
|
||||||
|
copy(buf[0:8], ArtNetID[:])
|
||||||
|
binary.LittleEndian.PutUint16(buf[8:10], OpPollReply)
|
||||||
|
copy(buf[10:14], ip[:])
|
||||||
|
binary.LittleEndian.PutUint16(buf[14:16], Port)
|
||||||
|
binary.BigEndian.PutUint16(buf[16:18], ProtocolVersion)
|
||||||
|
|
||||||
|
// Net/Subnet from first universe if available
|
||||||
|
if len(universes) > 0 {
|
||||||
|
buf[18] = universes[0].Net()
|
||||||
|
buf[19] = universes[0].SubNet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names
|
||||||
|
copy(buf[26:44], shortName)
|
||||||
|
copy(buf[44:108], longName)
|
||||||
|
|
||||||
|
// Ports
|
||||||
|
numPorts := len(universes)
|
||||||
|
if numPorts > 4 {
|
||||||
|
numPorts = 4
|
||||||
|
}
|
||||||
|
buf[173] = byte(numPorts)
|
||||||
|
|
||||||
|
for i := 0; i < numPorts; i++ {
|
||||||
|
buf[174+i] = 0xC0 // Output, can output DMX
|
||||||
|
buf[182+i] = 0x80 // Data transmitted
|
||||||
|
buf[190+i] = universes[i].Universe()
|
||||||
|
}
|
||||||
|
|
||||||
|
buf[200] = 0x00 // StNode
|
||||||
|
|
||||||
|
return buf
|
||||||
|
}
|
||||||
103
artnet/receiver.go
Normal file
103
artnet/receiver.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package artnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PacketHandler is called when a packet is received
|
||||||
|
type PacketHandler interface {
|
||||||
|
HandleDMX(src *net.UDPAddr, pkt *DMXPacket)
|
||||||
|
HandlePoll(src *net.UDPAddr, pkt *PollPacket)
|
||||||
|
HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receiver listens for ArtNet packets
|
||||||
|
type Receiver struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
handler PacketHandler
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReceiver creates a new ArtNet receiver
|
||||||
|
func NewReceiver(port int, handler PacketHandler) (*Receiver, error) {
|
||||||
|
addr := &net.UDPAddr{
|
||||||
|
Port: port,
|
||||||
|
IP: net.IPv4zero,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.ListenUDP("udp4", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Receiver{
|
||||||
|
conn: conn,
|
||||||
|
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, 1024)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
n, src, err := r.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-r.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Printf("read error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.handlePacket(src, buf[:n])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Receiver) handlePacket(src *net.UDPAddr, data []byte) {
|
||||||
|
opCode, pkt, err := ParsePacket(data)
|
||||||
|
if err != nil {
|
||||||
|
// Silently ignore invalid packets
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch opCode {
|
||||||
|
case OpDmx:
|
||||||
|
if dmx, ok := pkt.(*DMXPacket); ok {
|
||||||
|
r.handler.HandleDMX(src, dmx)
|
||||||
|
}
|
||||||
|
case OpPoll:
|
||||||
|
if poll, ok := pkt.(*PollPacket); ok {
|
||||||
|
r.handler.HandlePoll(src, poll)
|
||||||
|
}
|
||||||
|
case OpPollReply:
|
||||||
|
if reply, ok := pkt.(*PollReplyPacket); ok {
|
||||||
|
r.handler.HandlePollReply(src, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalAddr returns the local address the receiver is bound to
|
||||||
|
func (r *Receiver) LocalAddr() net.Addr {
|
||||||
|
return r.conn.LocalAddr()
|
||||||
|
}
|
||||||
92
artnet/sender.go
Normal file
92
artnet/sender.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package artnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sender sends ArtNet packets
|
||||||
|
type Sender struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
broadcastAddr *net.UDPAddr
|
||||||
|
sequences map[Universe]uint8
|
||||||
|
seqMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSender creates a new ArtNet sender
|
||||||
|
func NewSender(broadcastAddr string) (*Sender, error) {
|
||||||
|
// Create a UDP socket for sending
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable broadcast
|
||||||
|
if err := conn.SetWriteBuffer(65536); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast, err := net.ResolveUDPAddr("udp4", broadcastAddr+":"+string(rune(Port)))
|
||||||
|
if err != nil {
|
||||||
|
// Try parsing as IP:Port
|
||||||
|
broadcast, err = net.ResolveUDPAddr("udp4", broadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
broadcast = &net.UDPAddr{
|
||||||
|
IP: net.ParseIP(broadcastAddr),
|
||||||
|
Port: Port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Sender{
|
||||||
|
conn: conn,
|
||||||
|
broadcastAddr: broadcast,
|
||||||
|
sequences: make(map[Universe]uint8),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDMX sends a DMX packet to a specific address
|
||||||
|
func (s *Sender) SendDMX(addr *net.UDPAddr, universe Universe, data []byte) error {
|
||||||
|
s.seqMu.Lock()
|
||||||
|
seq := s.sequences[universe]
|
||||||
|
seq++
|
||||||
|
if seq == 0 {
|
||||||
|
seq = 1 // Skip 0
|
||||||
|
}
|
||||||
|
s.sequences[universe] = seq
|
||||||
|
s.seqMu.Unlock()
|
||||||
|
|
||||||
|
pkt := BuildDMXPacket(universe, seq, data)
|
||||||
|
_, err := s.conn.WriteToUDP(pkt, addr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDMXBroadcast sends a DMX packet to the broadcast address
|
||||||
|
func (s *Sender) SendDMXBroadcast(universe Universe, data []byte) error {
|
||||||
|
return s.SendDMX(s.broadcastAddr, universe, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPoll sends an ArtPoll packet to the broadcast address
|
||||||
|
func (s *Sender) SendPoll() error {
|
||||||
|
pkt := BuildPollPacket()
|
||||||
|
_, err := s.conn.WriteToUDP(pkt, s.broadcastAddr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPollReply sends an ArtPollReply to a specific address
|
||||||
|
func (s *Sender) SendPollReply(addr *net.UDPAddr, localIP [4]byte, shortName, longName string, universes []Universe) error {
|
||||||
|
pkt := BuildPollReplyPacket(localIP, shortName, longName, universes)
|
||||||
|
_, err := s.conn.WriteToUDP(pkt, addr)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the sender
|
||||||
|
func (s *Sender) Close() error {
|
||||||
|
return s.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastAddr returns the configured broadcast address
|
||||||
|
func (s *Sender) BroadcastAddr() *net.UDPAddr {
|
||||||
|
return s.broadcastAddr
|
||||||
|
}
|
||||||
42
config.example.toml
Normal file
42
config.example.toml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# artmap configuration
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
listen_port = 6454 # ArtNet port (default: 6454)
|
||||||
|
broadcast_addr = "2.255.255.255" # ArtNet broadcast address
|
||||||
|
|
||||||
|
# Universe address formats supported:
|
||||||
|
# "0.0.1" - Net.Subnet.Universe
|
||||||
|
# "0:0:1" - Net:Subnet:Universe
|
||||||
|
# 1 - Universe number only (net=0, subnet=0)
|
||||||
|
# "1" - Universe number as string
|
||||||
|
|
||||||
|
# Example: Remap entire universe
|
||||||
|
# Maps all 512 channels from universe 0 to universe 5
|
||||||
|
[[mapping]]
|
||||||
|
from = "0.0.0"
|
||||||
|
to = "0.0.5"
|
||||||
|
|
||||||
|
# Example: Channel-level remap for fixture spillover
|
||||||
|
# Maps channels 450-512 from universe 0 to channels 1-63 of universe 1
|
||||||
|
# Use case: A fixture at the end of universe 0 spills into universe 1
|
||||||
|
[[mapping]]
|
||||||
|
from = "0.0.0"
|
||||||
|
from_channel = 450 # 1-512 (1-indexed, like DMX)
|
||||||
|
to = "0.0.1"
|
||||||
|
to_channel = 1
|
||||||
|
count = 63 # Number of channels to remap
|
||||||
|
|
||||||
|
# Example: Using plain universe numbers
|
||||||
|
[[mapping]]
|
||||||
|
from = 2
|
||||||
|
to = 10
|
||||||
|
|
||||||
|
# Example: Multiple outputs from same source
|
||||||
|
# The same source channels can be mapped to multiple destinations
|
||||||
|
[[mapping]]
|
||||||
|
from = "0.0.3"
|
||||||
|
to = "0.0.7"
|
||||||
|
|
||||||
|
[[mapping]]
|
||||||
|
from = "0.0.3"
|
||||||
|
to = "0.0.8"
|
||||||
201
config/config.go
Normal file
201
config/config.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/gopatchy/artmap/artnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the application configuration
|
||||||
|
type Config struct {
|
||||||
|
Settings Settings `toml:"settings"`
|
||||||
|
Mappings []Mapping `toml:"mapping"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings contains global configuration options
|
||||||
|
type Settings struct {
|
||||||
|
ListenPort int `toml:"listen_port"`
|
||||||
|
BroadcastAddr string `toml:"broadcast_addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping represents a single channel mapping rule
|
||||||
|
type Mapping struct {
|
||||||
|
// Source
|
||||||
|
From UniverseAddr `toml:"from"`
|
||||||
|
FromChannel int `toml:"from_channel"` // 1-512, 0 means all channels
|
||||||
|
|
||||||
|
// Destination
|
||||||
|
To UniverseAddr `toml:"to"`
|
||||||
|
ToChannel int `toml:"to_channel"` // 1-512, 0 means same as from_channel
|
||||||
|
|
||||||
|
// Range
|
||||||
|
Count int `toml:"count"` // Number of channels, 0 means all remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
// UniverseAddr handles multiple universe address formats
|
||||||
|
type UniverseAddr struct {
|
||||||
|
Universe artnet.Universe
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UniverseAddr) UnmarshalText(text []byte) error {
|
||||||
|
s := string(text)
|
||||||
|
universe, err := ParseUniverseAddr(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Universe = universe
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UniverseAddr) UnmarshalTOML(data interface{}) error {
|
||||||
|
switch v := data.(type) {
|
||||||
|
case string:
|
||||||
|
universe, err := ParseUniverseAddr(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.Universe = universe
|
||||||
|
return nil
|
||||||
|
case int64:
|
||||||
|
// Universe number only (0-32767)
|
||||||
|
u.Universe = artnet.Universe(v)
|
||||||
|
return nil
|
||||||
|
case float64:
|
||||||
|
// TOML sometimes parses integers as floats
|
||||||
|
u.Universe = artnet.Universe(int64(v))
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported universe address type: %T", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseUniverseAddr parses various universe address formats:
|
||||||
|
// - "0.0.1" or "0.0.1" - Net.Subnet.Universe
|
||||||
|
// - "0:0:1" - Net:Subnet:Universe
|
||||||
|
// - "1" - Universe number only
|
||||||
|
func ParseUniverseAddr(s string) (artnet.Universe, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
// Try Net.Subnet.Universe format
|
||||||
|
if strings.Contains(s, ".") {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) == 3 {
|
||||||
|
net, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid net: %w", err)
|
||||||
|
}
|
||||||
|
subnet, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid subnet: %w", err)
|
||||||
|
}
|
||||||
|
universe, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid universe: %w", err)
|
||||||
|
}
|
||||||
|
return artnet.NewUniverse(uint8(net), uint8(subnet), uint8(universe)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Net:Subnet:Universe format
|
||||||
|
if strings.Contains(s, ":") {
|
||||||
|
parts := strings.Split(s, ":")
|
||||||
|
if len(parts) == 3 {
|
||||||
|
net, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid net: %w", err)
|
||||||
|
}
|
||||||
|
subnet, err := strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid subnet: %w", err)
|
||||||
|
}
|
||||||
|
universe, err := strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid universe: %w", err)
|
||||||
|
}
|
||||||
|
return artnet.NewUniverse(uint8(net), uint8(subnet), uint8(universe)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try plain universe number
|
||||||
|
u, err := strconv.Atoi(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid universe address format: %s", s)
|
||||||
|
}
|
||||||
|
return artnet.Universe(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load loads configuration from a TOML file
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
cfg.Settings.ListenPort = artnet.Port
|
||||||
|
cfg.Settings.BroadcastAddr = "2.255.255.255"
|
||||||
|
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and normalize mappings
|
||||||
|
for i := range cfg.Mappings {
|
||||||
|
m := &cfg.Mappings[i]
|
||||||
|
|
||||||
|
// Default from_channel to 1 (start of universe)
|
||||||
|
if m.FromChannel == 0 {
|
||||||
|
m.FromChannel = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to_channel to same as from_channel
|
||||||
|
if m.ToChannel == 0 {
|
||||||
|
m.ToChannel = m.FromChannel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default count to all remaining channels
|
||||||
|
if m.Count == 0 {
|
||||||
|
m.Count = 512 - m.FromChannel + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ranges
|
||||||
|
if m.FromChannel < 1 || m.FromChannel > 512 {
|
||||||
|
return nil, fmt.Errorf("mapping %d: from_channel must be 1-512", i)
|
||||||
|
}
|
||||||
|
if m.ToChannel < 1 || m.ToChannel > 512 {
|
||||||
|
return nil, fmt.Errorf("mapping %d: to_channel must be 1-512", i)
|
||||||
|
}
|
||||||
|
if m.FromChannel+m.Count-1 > 512 {
|
||||||
|
return nil, fmt.Errorf("mapping %d: from_channel + count exceeds 512", i)
|
||||||
|
}
|
||||||
|
if m.ToChannel+m.Count-1 > 512 {
|
||||||
|
return nil, fmt.Errorf("mapping %d: to_channel + count exceeds 512", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedMapping is a processed mapping ready for the remapper
|
||||||
|
type NormalizedMapping struct {
|
||||||
|
FromUniverse artnet.Universe
|
||||||
|
FromChannel int // 0-indexed
|
||||||
|
ToUniverse artnet.Universe
|
||||||
|
ToChannel int // 0-indexed
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize converts config mappings to normalized form (0-indexed channels)
|
||||||
|
func (c *Config) Normalize() []NormalizedMapping {
|
||||||
|
result := make([]NormalizedMapping, len(c.Mappings))
|
||||||
|
for i, m := range c.Mappings {
|
||||||
|
result[i] = NormalizedMapping{
|
||||||
|
FromUniverse: m.From.Universe,
|
||||||
|
FromChannel: m.FromChannel - 1, // Convert to 0-indexed
|
||||||
|
ToUniverse: m.To.Universe,
|
||||||
|
ToChannel: m.ToChannel - 1, // Convert to 0-indexed
|
||||||
|
Count: m.Count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/gopatchy/artmap
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require github.com/BurntSushi/toml v1.6.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
138
main.go
Normal file
138
main.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/gopatchy/artmap/artnet"
|
||||||
|
"github.com/gopatchy/artmap/config"
|
||||||
|
"github.com/gopatchy/artmap/remap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
cfg *config.Config
|
||||||
|
receiver *artnet.Receiver
|
||||||
|
sender *artnet.Sender
|
||||||
|
discovery *artnet.Discovery
|
||||||
|
engine *remap.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
configPath := flag.String("config", "config.toml", "path to config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
cfg, err := config.Load(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("loaded %d mappings", len(cfg.Mappings))
|
||||||
|
|
||||||
|
// Create remapping engine
|
||||||
|
engine := remap.NewEngine(cfg.Normalize())
|
||||||
|
|
||||||
|
// Log mappings
|
||||||
|
for _, m := range cfg.Mappings {
|
||||||
|
if m.Count == 512 && m.FromChannel == 1 {
|
||||||
|
log.Printf(" %s -> %s (all channels)", m.From.Universe, m.To.Universe)
|
||||||
|
} else {
|
||||||
|
log.Printf(" %s[%d-%d] -> %s[%d-%d]",
|
||||||
|
m.From.Universe, m.FromChannel, m.FromChannel+m.Count-1,
|
||||||
|
m.To.Universe, m.ToChannel, m.ToChannel+m.Count-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sender
|
||||||
|
sender, err := artnet.NewSender(cfg.Settings.BroadcastAddr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create sender: %v", err)
|
||||||
|
}
|
||||||
|
defer sender.Close()
|
||||||
|
|
||||||
|
// Create discovery
|
||||||
|
destUniverses := engine.DestUniverses()
|
||||||
|
discovery := artnet.NewDiscovery(sender, "artmap", "ArtNet Remapping Proxy", destUniverses)
|
||||||
|
|
||||||
|
// Create app
|
||||||
|
app := &App{
|
||||||
|
cfg: cfg,
|
||||||
|
sender: sender,
|
||||||
|
discovery: discovery,
|
||||||
|
engine: engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create receiver
|
||||||
|
receiver, err := artnet.NewReceiver(cfg.Settings.ListenPort, app)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create receiver: %v", err)
|
||||||
|
}
|
||||||
|
app.receiver = receiver
|
||||||
|
|
||||||
|
// Start everything
|
||||||
|
receiver.Start()
|
||||||
|
discovery.Start()
|
||||||
|
|
||||||
|
log.Printf("listening on port %d", cfg.Settings.ListenPort)
|
||||||
|
log.Printf("broadcasting to %s", cfg.Settings.BroadcastAddr)
|
||||||
|
|
||||||
|
// Wait for interrupt
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigChan
|
||||||
|
|
||||||
|
log.Println("shutting down...")
|
||||||
|
receiver.Stop()
|
||||||
|
discovery.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDMX implements artnet.PacketHandler
|
||||||
|
func (a *App) HandleDMX(src *net.UDPAddr, pkt *artnet.DMXPacket) {
|
||||||
|
// Apply remapping
|
||||||
|
outputs := a.engine.Remap(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 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
|
||||||
|
if err := a.sender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil {
|
||||||
|
log.Printf("failed to broadcast: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePoll implements artnet.PacketHandler
|
||||||
|
func (a *App) HandlePoll(src *net.UDPAddr, pkt *artnet.PollPacket) {
|
||||||
|
a.discovery.HandlePoll(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlePollReply implements artnet.PacketHandler
|
||||||
|
func (a *App) HandlePollReply(src *net.UDPAddr, pkt *artnet.PollReplyPacket) {
|
||||||
|
a.discovery.HandlePollReply(src, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetFlags(log.Ltime | log.Lmicroseconds)
|
||||||
|
fmt.Println("artmap - ArtNet Remapping Proxy")
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
94
remap/engine.go
Normal file
94
remap/engine.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package remap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gopatchy/artmap/artnet"
|
||||||
|
"github.com/gopatchy/artmap/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Output represents a remapped DMX output
|
||||||
|
type Output struct {
|
||||||
|
Universe artnet.Universe
|
||||||
|
Data [512]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngine creates a new remapping engine
|
||||||
|
func NewEngine(mappings []config.NormalizedMapping) *Engine {
|
||||||
|
bySource := make(map[artnet.Universe][]config.NormalizedMapping)
|
||||||
|
for _, m := range mappings {
|
||||||
|
bySource[m.FromUniverse] = append(bySource[m.FromUniverse], m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Engine{
|
||||||
|
mappings: mappings,
|
||||||
|
bySource: bySource,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group outputs by destination universe
|
||||||
|
outputs := make(map[artnet.Universe]*Output)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy channels
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to slice
|
||||||
|
result := make([]Output, 0, len(outputs))
|
||||||
|
for _, out := range outputs {
|
||||||
|
result = append(result, *out)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
result = append(result, u)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestUniverses returns all destination universes
|
||||||
|
func (e *Engine) DestUniverses() []artnet.Universe {
|
||||||
|
seen := make(map[artnet.Universe]bool)
|
||||||
|
for _, m := range e.mappings {
|
||||||
|
seen[m.ToUniverse] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]artnet.Universe, 0, len(seen))
|
||||||
|
for u := range seen {
|
||||||
|
result = append(result, u)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user