From 86403f1ff8339f7051e3171827be518884c506ba Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 22 Dec 2025 18:06:33 -0800 Subject: [PATCH] Replace --broadcast flag with per-universe target config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove --broadcast CLI flag - Add [[target]] config section for per-universe destination addresses - Each ArtNet output universe can have its own target IP (broadcast or unicast) - ArtPoll discovery broadcasts to all unique target addresses - Falls back to configured target when no nodes discovered for universe 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- artnet/discovery.go | 50 ++++++++++++++++------------- artnet/sender.go | 42 ++++++------------------- config.example.toml | 13 +++++++- config/config.go | 48 ++++++++++++++++++++++++++++ main.go | 77 ++++++++++++++++++++++++++++++++++++++------- 5 files changed, 162 insertions(+), 68 deletions(-) diff --git a/artnet/discovery.go b/artnet/discovery.go index 8d24ba7..fed43ec 100644 --- a/artnet/discovery.go +++ b/artnet/discovery.go @@ -20,25 +20,27 @@ type Node struct { // 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{} + sender *Sender + nodes map[string]*Node // keyed by IP string + nodesMu sync.RWMutex + localIP [4]byte + shortName string + longName string + universes []Universe + pollTargets []*net.UDPAddr + done chan struct{} } // NewDiscovery creates a new discovery handler -func NewDiscovery(sender *Sender, shortName, longName string, universes []Universe) *Discovery { +func NewDiscovery(sender *Sender, shortName, longName string, universes []Universe, pollTargets []*net.UDPAddr) *Discovery { return &Discovery{ - sender: sender, - nodes: make(map[string]*Node), - shortName: shortName, - longName: longName, - universes: universes, - done: make(chan struct{}), + sender: sender, + nodes: make(map[string]*Node), + shortName: shortName, + longName: longName, + universes: universes, + pollTargets: pollTargets, + done: make(chan struct{}), } } @@ -57,10 +59,8 @@ func (d *Discovery) Stop() { } func (d *Discovery) pollLoop() { - // Send initial poll - if err := d.sender.SendPoll(); err != nil { - log.Printf("failed to send ArtPoll: %v", err) - } + // Send initial poll to all targets + d.sendPolls() ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() @@ -73,15 +73,21 @@ func (d *Discovery) pollLoop() { case <-d.done: return case <-ticker.C: - if err := d.sender.SendPoll(); err != nil { - log.Printf("failed to send ArtPoll: %v", err) - } + d.sendPolls() case <-cleanupTicker.C: d.cleanup() } } } +func (d *Discovery) sendPolls() { + for _, target := range d.pollTargets { + if err := d.sender.SendPoll(target); err != nil { + log.Printf("failed to send ArtPoll to %s: %v", target.IP, err) + } + } +} + func (d *Discovery) cleanup() { d.nodesMu.Lock() defer d.nodesMu.Unlock() diff --git a/artnet/sender.go b/artnet/sender.go index 9becce1..e058408 100644 --- a/artnet/sender.go +++ b/artnet/sender.go @@ -7,14 +7,13 @@ import ( // Sender sends ArtNet packets type Sender struct { - conn *net.UDPConn - broadcastAddr *net.UDPAddr - sequences map[Universe]uint8 - seqMu sync.Mutex + conn *net.UDPConn + sequences map[Universe]uint8 + seqMu sync.Mutex } // NewSender creates a new ArtNet sender -func NewSender(broadcastAddr string) (*Sender, error) { +func NewSender() (*Sender, error) { // Create a UDP socket for sending conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { @@ -27,22 +26,9 @@ func NewSender(broadcastAddr string) (*Sender, error) { 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), + conn: conn, + sequences: make(map[Universe]uint8), }, nil } @@ -62,15 +48,10 @@ func (s *Sender) SendDMX(addr *net.UDPAddr, universe Universe, data []byte) erro 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 { +// SendPoll sends an ArtPoll packet to the specified address +func (s *Sender) SendPoll(addr *net.UDPAddr) error { pkt := BuildPollPacket() - _, err := s.conn.WriteToUDP(pkt, s.broadcastAddr) + _, err := s.conn.WriteToUDP(pkt, addr) return err } @@ -85,8 +66,3 @@ func (s *Sender) SendPollReply(addr *net.UDPAddr, localIP [4]byte, shortName, lo 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 index c30932b..1ba14fe 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,5 +1,16 @@ # artmap configuration -# Run with: go run . -config config.toml [-listen :6454] [-broadcast 2.255.255.255] +# Run with: go run . -config config.toml [-listen :6454] + +# Target addresses for ArtNet output universes +# Configure the destination IP (broadcast or unicast) for each output universe +# ArtPoll discovery will be sent to all unique target addresses +[[target]] +universe = "0.0.0" +address = "2.255.255.255" + +[[target]] +universe = "0.0.5" +address = "10.50.255.255" # Address format: # from: universe[:channels] - range specifies which channels to read diff --git a/config/config.go b/config/config.go index fd495a5..e2fbe86 100644 --- a/config/config.go +++ b/config/config.go @@ -11,9 +11,41 @@ import ( // Config represents the application configuration type Config struct { + Targets []Target `toml:"target"` Mappings []Mapping `toml:"mapping"` } +// Target represents a target address for an output universe +type Target struct { + Universe TargetUniverse `toml:"universe"` + Address string `toml:"address"` +} + +// TargetUniverse is a universe that can be parsed from string or int +type TargetUniverse struct { + artnet.Universe +} + +func (t *TargetUniverse) UnmarshalTOML(data interface{}) error { + switch v := data.(type) { + case string: + u, err := parseUniverse(v) + if err != nil { + return err + } + t.Universe = u + return nil + case int64: + t.Universe = artnet.Universe(v) + return nil + case float64: + t.Universe = artnet.Universe(int64(v)) + return nil + default: + return fmt.Errorf("unsupported universe type: %T", data) + } +} + // Protocol specifies the output protocol type Protocol string @@ -211,6 +243,13 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("failed to load config: %w", err) } + // Validate targets + for i, t := range cfg.Targets { + if t.Address == "" { + return nil, fmt.Errorf("target %d: address is required", i) + } + } + for i := range cfg.Mappings { m := &cfg.Mappings[i] if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 { @@ -286,3 +325,12 @@ func (c *Config) SACNSourceUniverses() []uint16 { } return result } + +// TargetMap returns a map of universe to target address +func (c *Config) TargetMap() map[artnet.Universe]string { + result := make(map[artnet.Universe]string) + for _, t := range c.Targets { + result[t.Universe.Universe] = t.Address + } + return result +} diff --git a/main.go b/main.go index 0ae8172..c3f85fe 100644 --- a/main.go +++ b/main.go @@ -25,13 +25,13 @@ type App struct { sacnSender *sacn.Sender discovery *artnet.Discovery engine *remap.Engine + targets map[artnet.Universe]*net.UDPAddr debug bool } func main() { configPath := flag.String("config", "config.toml", "path to config file") listenAddr := flag.String("listen", ":6454", "listen address (host:port, host, or :port)") - broadcastAddr := flag.String("broadcast", "2.255.255.255", "ArtNet broadcast address") debug := flag.Bool("debug", false, "log ArtNet packets") flag.Parse() @@ -60,8 +60,27 @@ func main() { m.Protocol, m.To.Universe, m.To.ChannelStart, toEnd) } + // Parse targets + targets := make(map[artnet.Universe]*net.UDPAddr) + pollTargets := make(map[string]*net.UDPAddr) // dedupe by address string + for _, t := range cfg.Targets { + addr, err := parseTargetAddr(t.Address) + if err != nil { + log.Fatalf("invalid target address %q: %v", t.Address, err) + } + targets[t.Universe.Universe] = addr + pollTargets[addr.String()] = addr + log.Printf(" target %s -> %s", t.Universe.Universe, addr) + } + + // Convert poll targets to slice + pollTargetSlice := make([]*net.UDPAddr, 0, len(pollTargets)) + for _, addr := range pollTargets { + pollTargetSlice = append(pollTargetSlice, addr) + } + // Create ArtNet sender - artSender, err := artnet.NewSender(*broadcastAddr) + artSender, err := artnet.NewSender() if err != nil { log.Fatalf("failed to create artnet sender: %v", err) } @@ -76,7 +95,7 @@ func main() { // Create discovery destUniverses := engine.DestUniverses() - discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses) + discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses, pollTargetSlice) // Create app app := &App{ @@ -85,6 +104,7 @@ func main() { sacnSender: sacnSender, discovery: discovery, engine: engine, + targets: targets, debug: *debug, } @@ -112,7 +132,6 @@ func main() { discovery.Start() log.Printf("listening for ArtNet on %s", addr) - log.Printf("broadcasting to %s", *broadcastAddr) // Wait for interrupt sigChan := make(chan os.Signal, 1) @@ -164,13 +183,15 @@ func (a *App) HandleDMX(src *net.UDPAddr, pkt *artnet.DMXPacket) { log.Printf("failed to send to %s: %v", node.IP, err) } } - } else { + } else if target, ok := a.targets[out.Universe]; ok { if a.debug { - log.Printf("send ArtNet broadcast: universe=%s", out.Universe) + log.Printf("send ArtNet to %s: universe=%s", target.IP, out.Universe) } - if err := a.artSender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil { - log.Printf("failed to broadcast: %v", err) + if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil { + log.Printf("failed to send to %s: %v", target.IP, err) } + } else { + log.Printf("no target configured for universe %s", out.Universe) } } } @@ -228,13 +249,15 @@ func (a *App) HandleSACN(universe uint16, data [512]byte) { log.Printf("failed to send to %s: %v", node.IP, err) } } - } else { + } else if target, ok := a.targets[out.Universe]; ok { if a.debug { - log.Printf("send ArtNet broadcast: universe=%s", out.Universe) + log.Printf("send ArtNet to %s: universe=%s", target.IP, out.Universe) } - if err := a.artSender.SendDMXBroadcast(out.Universe, out.Data[:]); err != nil { - log.Printf("failed to broadcast: %v", err) + if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil { + log.Printf("failed to send to %s: %v", target.IP, err) } + } else { + log.Printf("no target configured for universe %s", out.Universe) } } } @@ -285,3 +308,33 @@ func parseListenAddr(s string) (*net.UDPAddr, error) { return &net.UDPAddr{IP: ip, Port: port}, nil } + +// parseTargetAddr parses target address formats: +// - "host:port" -> specific host and port +// - "host" -> specific host, default ArtNet port +func parseTargetAddr(s string) (*net.UDPAddr, error) { + var host string + var port int + + if strings.Contains(s, ":") { + h, p, err := net.SplitHostPort(s) + if err != nil { + return nil, err + } + host = h + port, err = strconv.Atoi(p) + if err != nil { + return nil, err + } + } else { + host = s + port = artnet.Port + } + + ip := net.ParseIP(host) + if ip == nil { + return nil, fmt.Errorf("invalid IP address: %s", host) + } + + return &net.UDPAddr{IP: ip, Port: port}, nil +}