diff --git a/artnet/discovery.go b/artnet/discovery.go index fed43ec..2c64edf 100644 --- a/artnet/discovery.go +++ b/artnet/discovery.go @@ -83,7 +83,7 @@ func (d *Discovery) pollLoop() { 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) + log.Printf("[->artnet] poll error: dst=%s err=%v", target.IP, err) } } } @@ -95,7 +95,7 @@ func (d *Discovery) cleanup() { 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) + log.Printf("discovery timeout: ip=%s name=%s", ip, node.ShortName) delete(d.nodes, ip) } } @@ -153,7 +153,7 @@ func (d *Discovery) HandlePollReply(src *net.UDPAddr, pkt *PollReplyPacket) { Port: uint16(src.Port), } d.nodes[ip] = node - log.Printf("discovered node: %s (%s) - universes: %v", ip, shortName, universes) + log.Printf("discovery found: ip=%s name=%s universes=%v", ip, shortName, universes) } node.ShortName = shortName @@ -168,7 +168,7 @@ 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) + log.Printf("[->artnet] pollreply error: dst=%s err=%v", src.IP, err) } } diff --git a/config.example.toml b/config.example.toml index 1ba14fe..05a1460 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,77 +1,68 @@ # artmap configuration -# Run with: go run . -config config.toml [-listen :6454] +# Run with: go run . -config config.toml [-artnet-listen :6454] # Target addresses for ArtNet output universes -# Configure the destination IP (broadcast or unicast) for each output universe +# Each output universe needs a target IP (broadcast or unicast) # ArtPoll discovery will be sent to all unique target addresses [[target]] -universe = "0.0.0" +universe = "artnet:0.0.0" address = "2.255.255.255" [[target]] -universe = "0.0.5" +universe = "artnet:0.0.5" address = "10.50.255.255" # Address format: -# from: universe[:channels] - range specifies which channels to read -# to: universe[:channel] - single channel specifies where to start writing +# proto:universe[:channels] +# +# Protocol prefix (required): +# artnet: - ArtNet protocol +# sacn: - sACN/E1.31 protocol # # Universe: "net.subnet.universe" or plain number (all 0-indexed, 0-127.0-15.0-15) # Channels: 1-indexed (1-512), matching DMX convention # # From examples: -# "0.0.1" - universe 1, all channels (1-512) -# "0.0.1:50" - universe 1, channel 50 only -# "0.0.1:50-" - universe 1, channels 50-512 -# "0.0.1:50-100" - universe 1, channels 50-100 -# 1 - universe 1, all channels -# "1:50-100" - universe 1, channels 50-100 +# "artnet:0.0.1" - universe 1, all channels (1-512) +# "sacn:64:50" - universe 64, channel 50 only +# "artnet:0.0.1:50-" - universe 1, channels 50-512 +# "sacn:1:50-100" - universe 1, channels 50-100 # # To examples: -# "0.0.1" - universe 1, starting at channel 1 -# "0.0.1:50" - universe 1, starting at channel 50 -# -# Input protocol (optional, default "artnet"): -# from_proto = "artnet" - receive via ArtNet -# from_proto = "sacn" - receive via sACN/E1.31 (multicast) -# -# Output protocol (optional, default "artnet"): -# proto = "artnet" - output via ArtNet (broadcast or discovered nodes) -# proto = "sacn" - output via sACN/E1.31 (multicast) +# "artnet:0.0.1" - universe 1, starting at channel 1 +# "sacn:1:50" - universe 1, starting at channel 50 # Remap entire universe [[mapping]] -from = "0.0.0" -to = "0.0.5" +from = "artnet:0.0.0" +to = "artnet:0.0.5" # Channel-level remap for fixture spillover # Channels 450-512 from universe 0 -> channels 1-63 of universe 1 [[mapping]] -from = "0.0.0:450-512" -to = "0.0.1:1" +from = "artnet:0.0.0:450-512" +to = "artnet:0.0.1:1" # Using plain universe numbers [[mapping]] -from = 2 -to = 10 +from = "artnet:2" +to = "artnet:10" # Multiple outputs from same source [[mapping]] -from = "0.0.3" -to = "0.0.7" +from = "artnet:0.0.3" +to = "artnet:0.0.7" [[mapping]] -from = "0.0.3" -to = "0.0.8" +from = "artnet:0.0.3" +to = "artnet:0.0.8" # Output to sACN instead of ArtNet [[mapping]] -from = "0.0.4" -to = 1 -proto = "sacn" +from = "artnet:0.0.4" +to = "sacn:1" # Convert sACN input to ArtNet output [[mapping]] -from = 5 -from_proto = "sacn" -to = "0.0.5" +from = "sacn:5" +to = "artnet:0.0.5" diff --git a/config/config.go b/config/config.go index e2fbe86..60d3eca 100644 --- a/config/config.go +++ b/config/config.go @@ -17,28 +17,36 @@ type Config struct { // Target represents a target address for an output universe type Target struct { - Universe TargetUniverse `toml:"universe"` - Address string `toml:"address"` + Universe TargetAddr `toml:"universe"` + Address string `toml:"address"` } -// TargetUniverse is a universe that can be parsed from string or int -type TargetUniverse struct { - artnet.Universe +// TargetAddr is a protocol-prefixed universe address +type TargetAddr struct { + Protocol Protocol + Universe artnet.Universe } -func (t *TargetUniverse) UnmarshalTOML(data interface{}) error { +func (t *TargetAddr) UnmarshalTOML(data interface{}) error { switch v := data.(type) { case string: - u, err := parseUniverse(v) + proto, rest, err := splitProtoPrefix(v) + if err != nil { + return err + } + t.Protocol = proto + u, err := parseUniverse(rest) if err != nil { return err } t.Universe = u return nil case int64: + t.Protocol = ProtocolArtNet t.Universe = artnet.Universe(v) return nil case float64: + t.Protocol = ProtocolArtNet t.Universe = artnet.Universe(int64(v)) return nil default: @@ -46,6 +54,10 @@ func (t *TargetUniverse) UnmarshalTOML(data interface{}) error { } } +func (t TargetAddr) String() string { + return fmt.Sprintf("%s:%s", t.Protocol, t.Universe) +} + // Protocol specifies the output protocol type Protocol string @@ -56,14 +68,13 @@ const ( // Mapping represents a single channel mapping rule type Mapping struct { - From FromAddr `toml:"from"` - FromProto Protocol `toml:"from_proto"` - To ToAddr `toml:"to"` - Protocol Protocol `toml:"proto"` + From FromAddr `toml:"from"` + To ToAddr `toml:"to"` } // FromAddr represents a source universe address with channel range type FromAddr struct { + Protocol Protocol Universe artnet.Universe ChannelStart int // 1-indexed ChannelEnd int // 1-indexed @@ -74,11 +85,13 @@ func (a *FromAddr) UnmarshalTOML(data interface{}) error { case string: return a.parse(v) case int64: + a.Protocol = ProtocolArtNet a.Universe = artnet.Universe(v) a.ChannelStart = 1 a.ChannelEnd = 512 return nil case float64: + a.Protocol = ProtocolArtNet a.Universe = artnet.Universe(int64(v)) a.ChannelStart = 1 a.ChannelEnd = 512 @@ -88,15 +101,21 @@ func (a *FromAddr) UnmarshalTOML(data interface{}) error { } } -// parse parses address formats: -// - "0.0.1" - all channels -// - "0.0.1:50" - single channel -// - "0.0.1:50-" - channel 50 through end -// - "0.0.1:50-100" - channel range +// parse parses address formats with protocol prefix: +// - "artnet:0.0.1" - all channels +// - "sacn:64:50" - single channel +// - "artnet:0.0.1:50-" - channel 50 through end +// - "sacn:1:50-100" - channel range func (a *FromAddr) parse(s string) error { s = strings.TrimSpace(s) - universeStr, channelSpec := splitAddr(s) + proto, rest, err := splitProtoPrefix(s) + if err != nil { + return err + } + a.Protocol = proto + + universeStr, channelSpec := splitAddr(rest) universe, err := parseUniverse(universeStr) if err != nil { @@ -141,12 +160,23 @@ func (a *FromAddr) parse(s string) error { return nil } +func (a FromAddr) String() string { + if a.ChannelStart == 1 && a.ChannelEnd == 512 { + return fmt.Sprintf("%s:%s", a.Protocol, a.Universe) + } + if a.ChannelStart == a.ChannelEnd { + return fmt.Sprintf("%s:%s:%d", a.Protocol, a.Universe, a.ChannelStart) + } + return fmt.Sprintf("%s:%s:%d-%d", a.Protocol, a.Universe, a.ChannelStart, a.ChannelEnd) +} + func (a *FromAddr) Count() int { return a.ChannelEnd - a.ChannelStart + 1 } // ToAddr represents a destination universe address with starting channel type ToAddr struct { + Protocol Protocol Universe artnet.Universe ChannelStart int // 1-indexed } @@ -156,10 +186,12 @@ func (a *ToAddr) UnmarshalTOML(data interface{}) error { case string: return a.parse(v) case int64: + a.Protocol = ProtocolArtNet a.Universe = artnet.Universe(v) a.ChannelStart = 1 return nil case float64: + a.Protocol = ProtocolArtNet a.Universe = artnet.Universe(int64(v)) a.ChannelStart = 1 return nil @@ -168,13 +200,19 @@ func (a *ToAddr) UnmarshalTOML(data interface{}) error { } } -// parse parses address formats: -// - "0.0.1" - starting at channel 1 -// - "0.0.1:50" - starting at channel 50 +// parse parses address formats with protocol prefix: +// - "artnet:0.0.1" - starting at channel 1 +// - "sacn:1:50" - starting at channel 50 func (a *ToAddr) parse(s string) error { s = strings.TrimSpace(s) - universeStr, channelSpec := splitAddr(s) + proto, rest, err := splitProtoPrefix(s) + if err != nil { + return err + } + a.Protocol = proto + + universeStr, channelSpec := splitAddr(rest) universe, err := parseUniverse(universeStr) if err != nil { @@ -200,6 +238,23 @@ func (a *ToAddr) parse(s string) error { return nil } +func (a ToAddr) String() string { + if a.ChannelStart == 1 { + return fmt.Sprintf("%s:%s", a.Protocol, a.Universe) + } + return fmt.Sprintf("%s:%s:%d", a.Protocol, a.Universe, a.ChannelStart) +} + +func splitProtoPrefix(s string) (Protocol, string, error) { + if strings.HasPrefix(s, "artnet:") { + return ProtocolArtNet, s[7:], nil + } + if strings.HasPrefix(s, "sacn:") { + return ProtocolSACN, s[5:], nil + } + return "", "", fmt.Errorf("address must start with 'artnet:' or 'sacn:' prefix") +} + func splitAddr(s string) (universe, channel string) { if idx := strings.LastIndex(s, ":"); idx != -1 { return s[:idx], s[idx+1:] @@ -250,8 +305,7 @@ func Load(path string) (*Config, error) { } } - for i := range cfg.Mappings { - m := &cfg.Mappings[i] + for i, m := range cfg.Mappings { if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 { return nil, fmt.Errorf("mapping %d: from channel start must be 1-512", i) } @@ -268,16 +322,6 @@ func Load(path string) (*Config, error) { if toEnd > 512 { return nil, fmt.Errorf("mapping %d: to channels exceed 512", i) } - if m.FromProto == "" { - m.FromProto = ProtocolArtNet - } else if m.FromProto != ProtocolArtNet && m.FromProto != ProtocolSACN { - return nil, fmt.Errorf("mapping %d: from_proto must be 'artnet' or 'sacn'", i) - } - if m.Protocol == "" { - m.Protocol = ProtocolArtNet - } else if m.Protocol != ProtocolArtNet && m.Protocol != ProtocolSACN { - return nil, fmt.Errorf("mapping %d: proto must be 'artnet' or 'sacn'", i) - } } return &cfg, nil @@ -301,11 +345,11 @@ func (c *Config) Normalize() []NormalizedMapping { result[i] = NormalizedMapping{ FromUniverse: m.From.Universe, FromChannel: m.From.ChannelStart - 1, - FromProto: m.FromProto, + FromProto: m.From.Protocol, ToUniverse: m.To.Universe, ToChannel: m.To.ChannelStart - 1, Count: m.From.Count(), - Protocol: m.Protocol, + Protocol: m.To.Protocol, } } return result @@ -315,7 +359,7 @@ func (c *Config) Normalize() []NormalizedMapping { func (c *Config) SACNSourceUniverses() []uint16 { seen := make(map[uint16]bool) for _, m := range c.Mappings { - if m.FromProto == ProtocolSACN { + if m.From.Protocol == ProtocolSACN { seen[uint16(m.From.Universe)] = true } } @@ -325,12 +369,3 @@ 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 c3f85fe..8981ae9 100644 --- a/main.go +++ b/main.go @@ -31,46 +31,40 @@ type App struct { func main() { configPath := flag.String("config", "config.toml", "path to config file") - listenAddr := flag.String("listen", ":6454", "listen address (host:port, host, or :port)") - debug := flag.Bool("debug", false, "log ArtNet packets") + artnetListen := flag.String("artnet-listen", ":6454", "artnet listen address (empty to disable)") + debug := flag.Bool("debug", false, "log incoming/outgoing dmx packets") flag.Parse() - // Parse listen address - addr, err := parseListenAddr(*listenAddr) - if err != nil { - log.Fatalf("invalid listen address: %v", err) - } - // Load config cfg, err := config.Load(*configPath) if err != nil { - log.Fatalf("failed to load config: %v", err) + log.Fatalf("config error: %v", err) } - log.Printf("loaded %d mappings", len(cfg.Mappings)) + log.Printf("loaded mappings=%d", len(cfg.Mappings)) // Create remapping engine engine := remap.NewEngine(cfg.Normalize()) // Log mappings for _, m := range cfg.Mappings { - toEnd := m.To.ChannelStart + m.From.Count() - 1 - log.Printf(" [%s] %s:%d-%d -> [%s] %s:%d-%d", - m.FromProto, m.From.Universe, m.From.ChannelStart, m.From.ChannelEnd, - m.Protocol, m.To.Universe, m.To.ChannelStart, toEnd) + log.Printf(" %s -> %s", m.From, m.To) } // Parse targets targets := make(map[artnet.Universe]*net.UDPAddr) pollTargets := make(map[string]*net.UDPAddr) // dedupe by address string for _, t := range cfg.Targets { + if t.Universe.Protocol != config.ProtocolArtNet { + continue // only artnet targets need addresses + } addr, err := parseTargetAddr(t.Address) if err != nil { - log.Fatalf("invalid target address %q: %v", t.Address, err) + log.Fatalf("target error: address=%q err=%v", t.Address, err) } targets[t.Universe.Universe] = addr pollTargets[addr.String()] = addr - log.Printf(" target %s -> %s", t.Universe.Universe, addr) + log.Printf(" target %s -> %s", t.Universe, addr) } // Convert poll targets to slice @@ -82,14 +76,14 @@ func main() { // Create ArtNet sender artSender, err := artnet.NewSender() if err != nil { - log.Fatalf("failed to create artnet sender: %v", err) + log.Fatalf("artnet sender error: %v", err) } defer artSender.Close() // Create sACN sender sacnSender, err := sacn.NewSender("artmap") if err != nil { - log.Fatalf("failed to create sacn sender: %v", err) + log.Fatalf("sacn sender error: %v", err) } defer sacnSender.Close() @@ -108,38 +102,45 @@ func main() { debug: *debug, } - // Create ArtNet receiver - artReceiver, err := artnet.NewReceiver(addr, app) - if err != nil { - log.Fatalf("failed to create artnet receiver: %v", err) + // Create ArtNet receiver if enabled + if *artnetListen != "" { + addr, err := parseListenAddr(*artnetListen) + if err != nil { + log.Fatalf("artnet listen error: %v", err) + } + artReceiver, err := artnet.NewReceiver(addr, app) + if err != nil { + log.Fatalf("artnet receiver error: %v", err) + } + app.artReceiver = artReceiver + artReceiver.Start() + log.Printf("artnet listening addr=%s", addr) } - app.artReceiver = artReceiver // Create sACN receiver if needed sacnUniverses := cfg.SACNSourceUniverses() if len(sacnUniverses) > 0 { sacnReceiver, err := sacn.NewReceiver(sacnUniverses, app.HandleSACN) if err != nil { - log.Fatalf("failed to create sacn receiver: %v", err) + log.Fatalf("sacn receiver error: %v", err) } app.sacnReceiver = sacnReceiver sacnReceiver.Start() - log.Printf("listening for sACN on universes %v", sacnUniverses) + log.Printf("sacn listening universes=%v", sacnUniverses) } - // Start everything - artReceiver.Start() + // Start discovery discovery.Start() - log.Printf("listening for ArtNet on %s", addr) - // Wait for interrupt sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan - log.Println("shutting down...") - artReceiver.Stop() + log.Println("shutting down") + if app.artReceiver != nil { + app.artReceiver.Stop() + } if app.sacnReceiver != nil { app.sacnReceiver.Stop() } @@ -149,58 +150,17 @@ func main() { // HandleDMX implements artnet.PacketHandler func (a *App) HandleDMX(src *net.UDPAddr, pkt *artnet.DMXPacket) { if a.debug { - log.Printf("recv ArtNet from %s: universe=%s seq=%d len=%d", + log.Printf("[<-artnet] src=%s universe=%s seq=%d len=%d", src.IP, pkt.Universe, pkt.Sequence, pkt.Length) } - // Apply remapping - outputs := a.engine.Remap(config.ProtocolArtNet, pkt.Universe, pkt.Data) - - // Send remapped outputs - for _, out := range outputs { - switch out.Protocol { - case config.ProtocolSACN: - if a.debug { - log.Printf("send sACN multicast: universe=%d", uint16(out.Universe)) - } - if err := a.sacnSender.SendDMX(uint16(out.Universe), out.Data[:]); err != nil { - log.Printf("failed to send sACN: %v", err) - } - - default: // ArtNet - nodes := a.discovery.GetNodesForUniverse(out.Universe) - - if len(nodes) > 0 { - for _, node := range nodes { - addr := &net.UDPAddr{ - IP: node.IP, - Port: int(node.Port), - } - if a.debug { - log.Printf("send ArtNet to %s: universe=%s", node.IP, out.Universe) - } - if err := a.artSender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { - log.Printf("failed to send to %s: %v", node.IP, err) - } - } - } else if target, ok := a.targets[out.Universe]; ok { - if a.debug { - log.Printf("send ArtNet to %s: universe=%s", target.IP, out.Universe) - } - 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) - } - } - } + a.sendOutputs(a.engine.Remap(config.ProtocolArtNet, pkt.Universe, pkt.Data)) } // HandlePoll implements artnet.PacketHandler func (a *App) HandlePoll(src *net.UDPAddr, pkt *artnet.PollPacket) { if a.debug { - log.Printf("recv Poll from %s", src.IP) + log.Printf("[<-artnet] poll src=%s", src.IP) } a.discovery.HandlePoll(src) } @@ -208,7 +168,7 @@ func (a *App) HandlePoll(src *net.UDPAddr, pkt *artnet.PollPacket) { // HandlePollReply implements artnet.PacketHandler func (a *App) HandlePollReply(src *net.UDPAddr, pkt *artnet.PollReplyPacket) { if a.debug { - log.Printf("recv PollReply from %s", src.IP) + log.Printf("[<-artnet] pollreply src=%s", src.IP) } a.discovery.HandlePollReply(src, pkt) } @@ -216,21 +176,21 @@ func (a *App) HandlePollReply(src *net.UDPAddr, pkt *artnet.PollReplyPacket) { // HandleSACN handles incoming sACN DMX data func (a *App) HandleSACN(universe uint16, data [512]byte) { if a.debug { - log.Printf("recv sACN: universe=%d", universe) + log.Printf("[<-sacn] universe=%d", universe) } - // Apply remapping - outputs := a.engine.Remap(config.ProtocolSACN, artnet.Universe(universe), data) + a.sendOutputs(a.engine.Remap(config.ProtocolSACN, artnet.Universe(universe), data)) +} - // Send remapped outputs +func (a *App) sendOutputs(outputs []remap.Output) { for _, out := range outputs { switch out.Protocol { case config.ProtocolSACN: if a.debug { - log.Printf("send sACN multicast: universe=%d", uint16(out.Universe)) + log.Printf("[->sacn] universe=%d", uint16(out.Universe)) } if err := a.sacnSender.SendDMX(uint16(out.Universe), out.Data[:]); err != nil { - log.Printf("failed to send sACN: %v", err) + log.Printf("[->sacn] error: universe=%d err=%v", uint16(out.Universe), err) } default: // ArtNet @@ -243,21 +203,21 @@ func (a *App) HandleSACN(universe uint16, data [512]byte) { Port: int(node.Port), } if a.debug { - log.Printf("send ArtNet to %s: universe=%s", node.IP, out.Universe) + log.Printf("[->artnet] dst=%s universe=%s", node.IP, out.Universe) } if err := a.artSender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { - log.Printf("failed to send to %s: %v", node.IP, err) + log.Printf("[->artnet] error: dst=%s err=%v", node.IP, err) } } } else if target, ok := a.targets[out.Universe]; ok { if a.debug { - log.Printf("send ArtNet to %s: universe=%s", target.IP, out.Universe) + log.Printf("[->artnet] dst=%s universe=%s", target.IP, out.Universe) } if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil { - log.Printf("failed to send to %s: %v", target.IP, err) + log.Printf("[->artnet] error: dst=%s err=%v", target.IP, err) } } else { - log.Printf("no target configured for universe %s", out.Universe) + log.Printf("[->artnet] no target: universe=%s", out.Universe) } } }