Replace --broadcast flag with per-universe target config

- 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 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2025-12-22 18:06:33 -08:00
parent b0e9ecdee7
commit 86403f1ff8
5 changed files with 162 additions and 68 deletions

View File

@@ -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()

View File

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

View File

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

View File

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

77
main.go
View File

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