From a709e5498b0f6cb891ce07d0afc3ae22a2d2757e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 22 Dec 2025 09:27:20 -0800 Subject: [PATCH] Initial implementation of ArtNet remapping proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 2 + artnet/discovery.go | 223 +++++++++++++++++++++++++++++++++ artnet/protocol.go | 292 ++++++++++++++++++++++++++++++++++++++++++++ artnet/receiver.go | 103 ++++++++++++++++ artnet/sender.go | 92 ++++++++++++++ config.example.toml | 42 +++++++ config/config.go | 201 ++++++++++++++++++++++++++++++ go.mod | 5 + go.sum | 2 + main.go | 138 +++++++++++++++++++++ remap/engine.go | 94 ++++++++++++++ 11 files changed, 1194 insertions(+) create mode 100644 .gitignore create mode 100644 artnet/discovery.go create mode 100644 artnet/protocol.go create mode 100644 artnet/receiver.go create mode 100644 artnet/sender.go create mode 100644 config.example.toml create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 remap/engine.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe9af52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.toml +artmap diff --git a/artnet/discovery.go b/artnet/discovery.go new file mode 100644 index 0000000..8d24ba7 --- /dev/null +++ b/artnet/discovery.go @@ -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) + } +} diff --git a/artnet/protocol.go b/artnet/protocol.go new file mode 100644 index 0000000..27bdc3b --- /dev/null +++ b/artnet/protocol.go @@ -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 +} diff --git a/artnet/receiver.go b/artnet/receiver.go new file mode 100644 index 0000000..1d31c1b --- /dev/null +++ b/artnet/receiver.go @@ -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() +} diff --git a/artnet/sender.go b/artnet/sender.go new file mode 100644 index 0000000..9becce1 --- /dev/null +++ b/artnet/sender.go @@ -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 +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..6f22966 --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..17c4411 --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c57dc99 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/gopatchy/artmap + +go 1.25.4 + +require github.com/BurntSushi/toml v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f74b269 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..66779e4 --- /dev/null +++ b/main.go @@ -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() +} diff --git a/remap/engine.go b/remap/engine.go new file mode 100644 index 0000000..b87ce4b --- /dev/null +++ b/remap/engine.go @@ -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 +}