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:
@@ -20,25 +20,27 @@ type Node struct {
|
|||||||
|
|
||||||
// Discovery handles ArtNet node discovery
|
// Discovery handles ArtNet node discovery
|
||||||
type Discovery struct {
|
type Discovery struct {
|
||||||
sender *Sender
|
sender *Sender
|
||||||
nodes map[string]*Node // keyed by IP string
|
nodes map[string]*Node // keyed by IP string
|
||||||
nodesMu sync.RWMutex
|
nodesMu sync.RWMutex
|
||||||
localIP [4]byte
|
localIP [4]byte
|
||||||
shortName string
|
shortName string
|
||||||
longName string
|
longName string
|
||||||
universes []Universe
|
universes []Universe
|
||||||
done chan struct{}
|
pollTargets []*net.UDPAddr
|
||||||
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDiscovery creates a new discovery handler
|
// 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{
|
return &Discovery{
|
||||||
sender: sender,
|
sender: sender,
|
||||||
nodes: make(map[string]*Node),
|
nodes: make(map[string]*Node),
|
||||||
shortName: shortName,
|
shortName: shortName,
|
||||||
longName: longName,
|
longName: longName,
|
||||||
universes: universes,
|
universes: universes,
|
||||||
done: make(chan struct{}),
|
pollTargets: pollTargets,
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +59,8 @@ func (d *Discovery) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Discovery) pollLoop() {
|
func (d *Discovery) pollLoop() {
|
||||||
// Send initial poll
|
// Send initial poll to all targets
|
||||||
if err := d.sender.SendPoll(); err != nil {
|
d.sendPolls()
|
||||||
log.Printf("failed to send ArtPoll: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -73,15 +73,21 @@ func (d *Discovery) pollLoop() {
|
|||||||
case <-d.done:
|
case <-d.done:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := d.sender.SendPoll(); err != nil {
|
d.sendPolls()
|
||||||
log.Printf("failed to send ArtPoll: %v", err)
|
|
||||||
}
|
|
||||||
case <-cleanupTicker.C:
|
case <-cleanupTicker.C:
|
||||||
d.cleanup()
|
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() {
|
func (d *Discovery) cleanup() {
|
||||||
d.nodesMu.Lock()
|
d.nodesMu.Lock()
|
||||||
defer d.nodesMu.Unlock()
|
defer d.nodesMu.Unlock()
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import (
|
|||||||
|
|
||||||
// Sender sends ArtNet packets
|
// Sender sends ArtNet packets
|
||||||
type Sender struct {
|
type Sender struct {
|
||||||
conn *net.UDPConn
|
conn *net.UDPConn
|
||||||
broadcastAddr *net.UDPAddr
|
sequences map[Universe]uint8
|
||||||
sequences map[Universe]uint8
|
seqMu sync.Mutex
|
||||||
seqMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSender creates a new ArtNet sender
|
// NewSender creates a new ArtNet sender
|
||||||
func NewSender(broadcastAddr string) (*Sender, error) {
|
func NewSender() (*Sender, error) {
|
||||||
// Create a UDP socket for sending
|
// Create a UDP socket for sending
|
||||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,22 +26,9 @@ func NewSender(broadcastAddr string) (*Sender, error) {
|
|||||||
return nil, err
|
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{
|
return &Sender{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
broadcastAddr: broadcast,
|
sequences: make(map[Universe]uint8),
|
||||||
sequences: make(map[Universe]uint8),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,15 +48,10 @@ func (s *Sender) SendDMX(addr *net.UDPAddr, universe Universe, data []byte) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendDMXBroadcast sends a DMX packet to the broadcast address
|
// SendPoll sends an ArtPoll packet to the specified address
|
||||||
func (s *Sender) SendDMXBroadcast(universe Universe, data []byte) error {
|
func (s *Sender) SendPoll(addr *net.UDPAddr) error {
|
||||||
return s.SendDMX(s.broadcastAddr, universe, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendPoll sends an ArtPoll packet to the broadcast address
|
|
||||||
func (s *Sender) SendPoll() error {
|
|
||||||
pkt := BuildPollPacket()
|
pkt := BuildPollPacket()
|
||||||
_, err := s.conn.WriteToUDP(pkt, s.broadcastAddr)
|
_, err := s.conn.WriteToUDP(pkt, addr)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +66,3 @@ func (s *Sender) SendPollReply(addr *net.UDPAddr, localIP [4]byte, shortName, lo
|
|||||||
func (s *Sender) Close() error {
|
func (s *Sender) Close() error {
|
||||||
return s.conn.Close()
|
return s.conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BroadcastAddr returns the configured broadcast address
|
|
||||||
func (s *Sender) BroadcastAddr() *net.UDPAddr {
|
|
||||||
return s.broadcastAddr
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# artmap configuration
|
# 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:
|
# Address format:
|
||||||
# from: universe[:channels] - range specifies which channels to read
|
# from: universe[:channels] - range specifies which channels to read
|
||||||
|
|||||||
@@ -11,9 +11,41 @@ import (
|
|||||||
|
|
||||||
// Config represents the application configuration
|
// Config represents the application configuration
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
Targets []Target `toml:"target"`
|
||||||
Mappings []Mapping `toml:"mapping"`
|
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
|
// Protocol specifies the output protocol
|
||||||
type Protocol string
|
type Protocol string
|
||||||
|
|
||||||
@@ -211,6 +243,13 @@ func Load(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
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 {
|
for i := range cfg.Mappings {
|
||||||
m := &cfg.Mappings[i]
|
m := &cfg.Mappings[i]
|
||||||
if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 {
|
if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 {
|
||||||
@@ -286,3 +325,12 @@ func (c *Config) SACNSourceUniverses() []uint16 {
|
|||||||
}
|
}
|
||||||
return result
|
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
77
main.go
@@ -25,13 +25,13 @@ type App struct {
|
|||||||
sacnSender *sacn.Sender
|
sacnSender *sacn.Sender
|
||||||
discovery *artnet.Discovery
|
discovery *artnet.Discovery
|
||||||
engine *remap.Engine
|
engine *remap.Engine
|
||||||
|
targets map[artnet.Universe]*net.UDPAddr
|
||||||
debug bool
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "config.toml", "path to config file")
|
configPath := flag.String("config", "config.toml", "path to config file")
|
||||||
listenAddr := flag.String("listen", ":6454", "listen address (host:port, host, or :port)")
|
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")
|
debug := flag.Bool("debug", false, "log ArtNet packets")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -60,8 +60,27 @@ func main() {
|
|||||||
m.Protocol, m.To.Universe, m.To.ChannelStart, toEnd)
|
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
|
// Create ArtNet sender
|
||||||
artSender, err := artnet.NewSender(*broadcastAddr)
|
artSender, err := artnet.NewSender()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create artnet sender: %v", err)
|
log.Fatalf("failed to create artnet sender: %v", err)
|
||||||
}
|
}
|
||||||
@@ -76,7 +95,7 @@ func main() {
|
|||||||
|
|
||||||
// Create discovery
|
// Create discovery
|
||||||
destUniverses := engine.DestUniverses()
|
destUniverses := engine.DestUniverses()
|
||||||
discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses)
|
discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses, pollTargetSlice)
|
||||||
|
|
||||||
// Create app
|
// Create app
|
||||||
app := &App{
|
app := &App{
|
||||||
@@ -85,6 +104,7 @@ func main() {
|
|||||||
sacnSender: sacnSender,
|
sacnSender: sacnSender,
|
||||||
discovery: discovery,
|
discovery: discovery,
|
||||||
engine: engine,
|
engine: engine,
|
||||||
|
targets: targets,
|
||||||
debug: *debug,
|
debug: *debug,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +132,6 @@ func main() {
|
|||||||
discovery.Start()
|
discovery.Start()
|
||||||
|
|
||||||
log.Printf("listening for ArtNet on %s", addr)
|
log.Printf("listening for ArtNet on %s", addr)
|
||||||
log.Printf("broadcasting to %s", *broadcastAddr)
|
|
||||||
|
|
||||||
// Wait for interrupt
|
// Wait for interrupt
|
||||||
sigChan := make(chan os.Signal, 1)
|
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)
|
log.Printf("failed to send to %s: %v", node.IP, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if target, ok := a.targets[out.Universe]; ok {
|
||||||
if a.debug {
|
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 {
|
if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil {
|
||||||
log.Printf("failed to broadcast: %v", err)
|
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)
|
log.Printf("failed to send to %s: %v", node.IP, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if target, ok := a.targets[out.Universe]; ok {
|
||||||
if a.debug {
|
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 {
|
if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil {
|
||||||
log.Printf("failed to broadcast: %v", err)
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user