diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..486466b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +* Don't commit unless told to +* Single-line commit messages, no mention of claude +* Use go vet or go run instead of go build +* No code comments diff --git a/config.toml b/config.toml index 80451e4..7687cc6 100644 --- a/config.toml +++ b/config.toml @@ -1,3 +1,7 @@ +[[mapping]] +from = "artnet:32.0.0" +to = "sacn:32" + # lighting-1 port 1 [[target]] universe = "artnet:0.0.0" diff --git a/config/config.go b/config/config.go index 60d3eca..3965f7e 100644 --- a/config/config.go +++ b/config/config.go @@ -6,58 +6,8 @@ import ( "strings" "github.com/BurntSushi/toml" - "github.com/gopatchy/artmap/artnet" ) -// 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 TargetAddr `toml:"universe"` - Address string `toml:"address"` -} - -// TargetAddr is a protocol-prefixed universe address -type TargetAddr struct { - Protocol Protocol - Universe artnet.Universe -} - -func (t *TargetAddr) UnmarshalTOML(data interface{}) error { - switch v := data.(type) { - case string: - 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: - return fmt.Errorf("unsupported universe type: %T", data) - } -} - -func (t TargetAddr) String() string { - return fmt.Sprintf("%s:%s", t.Protocol, t.Universe) -} - // Protocol specifies the output protocol type Protocol string @@ -66,6 +16,104 @@ const ( ProtocolSACN Protocol = "sacn" ) +// Universe represents a DMX universe with its protocol +type Universe struct { + Protocol Protocol + Number uint16 +} + +func NewUniverse(proto Protocol, num any) (Universe, error) { + n, err := toUint16(num, proto) + if err != nil { + return Universe{}, err + } + return makeUniverse(proto, n) +} + +func ParseUniverse(s string) (Universe, error) { + proto, rest, err := splitProtoPrefix(s) + if err != nil { + return Universe{}, err + } + return NewUniverse(proto, rest) +} + +func (u Universe) String() string { + if u.Protocol == ProtocolSACN { + return fmt.Sprintf("sacn:%d", u.Number) + } + net := (u.Number >> 8) & 0x7F + subnet := (u.Number >> 4) & 0x0F + universe := u.Number & 0x0F + return fmt.Sprintf("artnet:%d.%d.%d", net, subnet, universe) +} + +func (u *Universe) UnmarshalTOML(data any) error { + switch v := data.(type) { + case string: + parsed, err := ParseUniverse(v) + if err != nil { + return err + } + *u = parsed + return nil + default: + parsed, err := NewUniverse(ProtocolArtNet, data) + if err != nil { + return err + } + *u = parsed + return nil + } +} + +func toUint16(v any, proto Protocol) (uint16, error) { + switch n := v.(type) { + case int: + return uint16(n), nil + case int64: + return uint16(n), nil + case uint16: + return n, nil + case uint: + return uint16(n), nil + case float64: + return uint16(n), nil + case string: + return parseUniverseNumber(n, proto) + default: + return 0, fmt.Errorf("unsupported universe type: %T", v) + } +} + +func makeUniverse(proto Protocol, n uint16) (Universe, error) { + switch proto { + case ProtocolArtNet: + if n > 0x7FFF { + return Universe{}, fmt.Errorf("artnet universe %d out of range (max 32767)", n) + } + case ProtocolSACN: + if n < 1 || n > 63999 { + return Universe{}, fmt.Errorf("sacn universe %d out of range (1-63999)", n) + } + default: + return Universe{}, fmt.Errorf("unknown protocol: %s", proto) + } + return Universe{Protocol: proto, Number: n}, nil +} + +// 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 Universe `toml:"universe"` + Address string `toml:"address"` +} + // Mapping represents a single channel mapping rule type Mapping struct { From FromAddr `toml:"from"` @@ -74,54 +122,37 @@ type Mapping struct { // FromAddr represents a source universe address with channel range type FromAddr struct { - Protocol Protocol - Universe artnet.Universe + Universe Universe ChannelStart int // 1-indexed ChannelEnd int // 1-indexed } -func (a *FromAddr) UnmarshalTOML(data interface{}) error { - switch v := data.(type) { - 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 - return nil - default: - return fmt.Errorf("unsupported address type: %T", data) +func (a *FromAddr) UnmarshalTOML(data any) error { + if s, ok := data.(string); ok { + return a.parse(s) } + u, err := NewUniverse(ProtocolArtNet, data) + if err != nil { + return err + } + a.Universe = u + a.ChannelStart = 1 + a.ChannelEnd = 512 + return nil } -// 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) - - proto, rest, err := splitProtoPrefix(s) + proto, rest, err := splitProtoPrefix(strings.TrimSpace(s)) if err != nil { return err } - a.Protocol = proto universeStr, channelSpec := splitAddr(rest) - - universe, err := parseUniverse(universeStr) + u, err := NewUniverse(proto, universeStr) if err != nil { return err } - a.Universe = universe + a.Universe = u if channelSpec == "" { a.ChannelStart = 1 @@ -129,45 +160,45 @@ func (a *FromAddr) parse(s string) error { return nil } - if idx := strings.Index(channelSpec, "-"); idx != -1 { - startStr := channelSpec[:idx] - endStr := channelSpec[idx+1:] + return parseChannelRange(channelSpec, &a.ChannelStart, &a.ChannelEnd) +} - start, err := strconv.Atoi(startStr) +func parseChannelRange(spec string, start, end *int) error { + if idx := strings.Index(spec, "-"); idx != -1 { + s, err := strconv.Atoi(spec[:idx]) if err != nil { return fmt.Errorf("invalid channel start: %w", err) } - a.ChannelStart = start + *start = s - if endStr == "" { - a.ChannelEnd = 512 + if spec[idx+1:] == "" { + *end = 512 } else { - end, err := strconv.Atoi(endStr) + e, err := strconv.Atoi(spec[idx+1:]) if err != nil { return fmt.Errorf("invalid channel end: %w", err) } - a.ChannelEnd = end + *end = e } } else { - ch, err := strconv.Atoi(channelSpec) + ch, err := strconv.Atoi(spec) if err != nil { return fmt.Errorf("invalid channel: %w", err) } - a.ChannelStart = ch - a.ChannelEnd = ch + *start = ch + *end = ch } - return nil } func (a FromAddr) String() string { if a.ChannelStart == 1 && a.ChannelEnd == 512 { - return fmt.Sprintf("%s:%s", a.Protocol, a.Universe) + return a.Universe.String() } if a.ChannelStart == a.ChannelEnd { - return fmt.Sprintf("%s:%s:%d", a.Protocol, a.Universe, a.ChannelStart) + return fmt.Sprintf("%s:%d", a.Universe, a.ChannelStart) } - return fmt.Sprintf("%s:%s:%d-%d", a.Protocol, a.Universe, a.ChannelStart, a.ChannelEnd) + return fmt.Sprintf("%s:%d-%d", a.Universe, a.ChannelStart, a.ChannelEnd) } func (a *FromAddr) Count() int { @@ -176,49 +207,35 @@ func (a *FromAddr) Count() int { // ToAddr represents a destination universe address with starting channel type ToAddr struct { - Protocol Protocol - Universe artnet.Universe + Universe Universe ChannelStart int // 1-indexed } -func (a *ToAddr) UnmarshalTOML(data interface{}) error { - switch v := data.(type) { - 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 - default: - return fmt.Errorf("unsupported address type: %T", data) +func (a *ToAddr) UnmarshalTOML(data any) error { + if s, ok := data.(string); ok { + return a.parse(s) } + u, err := NewUniverse(ProtocolArtNet, data) + if err != nil { + return err + } + a.Universe = u + a.ChannelStart = 1 + return nil } -// 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) - - proto, rest, err := splitProtoPrefix(s) + proto, rest, err := splitProtoPrefix(strings.TrimSpace(s)) if err != nil { return err } - a.Protocol = proto universeStr, channelSpec := splitAddr(rest) - - universe, err := parseUniverse(universeStr) + u, err := NewUniverse(proto, universeStr) if err != nil { return err } - a.Universe = universe + a.Universe = u if channelSpec == "" { a.ChannelStart = 1 @@ -234,15 +251,14 @@ func (a *ToAddr) parse(s string) error { return fmt.Errorf("invalid channel: %w", err) } a.ChannelStart = ch - return nil } func (a ToAddr) String() string { if a.ChannelStart == 1 { - return fmt.Sprintf("%s:%s", a.Protocol, a.Universe) + return a.Universe.String() } - return fmt.Sprintf("%s:%s:%d", a.Protocol, a.Universe, a.ChannelStart) + return fmt.Sprintf("%s:%d", a.Universe, a.ChannelStart) } func splitProtoPrefix(s string) (Protocol, string, error) { @@ -262,8 +278,11 @@ func splitAddr(s string) (universe, channel string) { return s, "" } -func parseUniverse(s string) (artnet.Universe, error) { +func parseUniverseNumber(s string, proto Protocol) (uint16, error) { if strings.Contains(s, ".") { + if proto == ProtocolSACN { + return 0, fmt.Errorf("sACN universes cannot use net.subnet.universe format") + } parts := strings.Split(s, ".") if len(parts) != 3 { return 0, fmt.Errorf("invalid universe format: %s (expected net.subnet.universe)", s) @@ -280,14 +299,14 @@ func parseUniverse(s string) (artnet.Universe, error) { if err != nil { return 0, fmt.Errorf("invalid universe: %w", err) } - return artnet.NewUniverse(uint8(net), uint8(subnet), uint8(universe)), nil + return uint16(net&0x7F)<<8 | uint16(subnet&0x0F)<<4 | uint16(universe&0x0F), nil } u, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid universe: %s", s) } - return artnet.Universe(u), nil + return uint16(u), nil } // Load loads configuration from a TOML file @@ -329,13 +348,11 @@ func Load(path string) (*Config, error) { // NormalizedMapping is a processed mapping ready for the remapper type NormalizedMapping struct { - FromUniverse artnet.Universe - FromChannel int // 0-indexed - FromProto Protocol - ToUniverse artnet.Universe - ToChannel int // 0-indexed - Count int - Protocol Protocol + From Universe + FromChan int // 0-indexed + To Universe + ToChan int // 0-indexed + Count int } // Normalize converts config mappings to normalized form (0-indexed channels) @@ -343,24 +360,22 @@ 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.From.ChannelStart - 1, - FromProto: m.From.Protocol, - ToUniverse: m.To.Universe, - ToChannel: m.To.ChannelStart - 1, - Count: m.From.Count(), - Protocol: m.To.Protocol, + From: m.From.Universe, + FromChan: m.From.ChannelStart - 1, + To: m.To.Universe, + ToChan: m.To.ChannelStart - 1, + Count: m.From.Count(), } } return result } -// SACNSourceUniverses returns universes that need sACN input +// SACNSourceUniverses returns sACN universe numbers that need input func (c *Config) SACNSourceUniverses() []uint16 { seen := make(map[uint16]bool) for _, m := range c.Mappings { - if m.From.Protocol == ProtocolSACN { - seen[uint16(m.From.Universe)] = true + if m.From.Universe.Protocol == ProtocolSACN { + seen[m.From.Universe.Number] = true } } result := make([]uint16, 0, len(seen)) diff --git a/main.go b/main.go index e0e3c09..a842481 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ type App struct { sacnSender *sacn.Sender discovery *artnet.Discovery engine *remap.Engine - artTargets map[artnet.Universe]*net.UDPAddr + artTargets map[uint16]*net.UDPAddr sacnTargets map[uint16][]*net.UDPAddr debug bool } @@ -54,28 +54,22 @@ func main() { } // Parse targets - artTargets := make(map[artnet.Universe]*net.UDPAddr) + artTargets := make(map[uint16]*net.UDPAddr) sacnTargets := make(map[uint16][]*net.UDPAddr) - pollTargets := make(map[string]*net.UDPAddr) // dedupe by address string + pollTargets := make(map[string]*net.UDPAddr) for _, t := range cfg.Targets { + addr, err := parseTargetAddr(t.Address, protocolPort(t.Universe.Protocol)) + if err != nil { + log.Fatalf("target error: address=%q err=%v", t.Address, err) + } switch t.Universe.Protocol { case config.ProtocolArtNet: - addr, err := parseTargetAddr(t.Address, artnet.Port) - if err != nil { - log.Fatalf("target error: address=%q err=%v", t.Address, err) - } - artTargets[t.Universe.Universe] = addr + artTargets[t.Universe.Number] = addr pollTargets[addr.String()] = addr - log.Printf("[config] target %s -> %s", t.Universe, addr) case config.ProtocolSACN: - addr, err := parseTargetAddr(t.Address, sacn.Port) - if err != nil { - log.Fatalf("target error: address=%q err=%v", t.Address, err) - } - u := uint16(t.Universe.Universe) - sacnTargets[u] = append(sacnTargets[u], addr) - log.Printf("[config] target %s -> %s", t.Universe, addr) + sacnTargets[t.Universe.Number] = append(sacnTargets[t.Universe.Number], addr) } + log.Printf("[config] target %s -> %s", t.Universe, addr) } // Parse broadcast addresses @@ -120,7 +114,11 @@ func main() { defer sacnSender.Close() // Create discovery - destUniverses := engine.DestUniverses() + destNums := engine.DestArtNetUniverses() + destUniverses := make([]artnet.Universe, len(destNums)) + for i, n := range destNums { + destUniverses[i] = artnet.Universe(n) + } discovery := artnet.NewDiscovery(artSender, "artmap", "ArtNet Remapping Proxy", destUniverses, pollTargetSlice) // Create app @@ -186,8 +184,8 @@ func (a *App) HandleDMX(src *net.UDPAddr, pkt *artnet.DMXPacket) { log.Printf("[<-artnet] src=%s universe=%s seq=%d len=%d", src.IP, pkt.Universe, pkt.Sequence, pkt.Length) } - - a.sendOutputs(a.engine.Remap(config.ProtocolArtNet, pkt.Universe, pkt.Data)) + u := config.Universe{Protocol: config.ProtocolArtNet, Number: uint16(pkt.Universe)} + a.sendOutputs(a.engine.Remap(u, pkt.Data)) } // HandlePoll implements artnet.PacketHandler @@ -211,15 +209,15 @@ func (a *App) HandleSACN(universe uint16, data [512]byte) { if a.debug { log.Printf("[<-sacn] universe=%d", universe) } - - a.sendOutputs(a.engine.Remap(config.ProtocolSACN, artnet.Universe(universe), data)) + u := config.Universe{Protocol: config.ProtocolSACN, Number: universe} + a.sendOutputs(a.engine.Remap(u, data)) } func (a *App) sendOutputs(outputs []remap.Output) { for _, out := range outputs { - switch out.Protocol { + switch out.Universe.Protocol { case config.ProtocolSACN: - u := uint16(out.Universe) + u := out.Universe.Number if a.debug { log.Printf("[->sacn] universe=%d", u) } @@ -235,15 +233,17 @@ func (a *App) sendOutputs(outputs []remap.Output) { } } - default: // ArtNet - if target, ok := a.artTargets[out.Universe]; ok { + case config.ProtocolArtNet: + u := out.Universe.Number + artU := artnet.Universe(u) + if target, ok := a.artTargets[u]; ok { if a.debug { log.Printf("[->artnet] dst=%s universe=%s", target.IP, out.Universe) } - if err := a.artSender.SendDMX(target, out.Universe, out.Data[:]); err != nil { + if err := a.artSender.SendDMX(target, artU, out.Data[:]); err != nil { log.Printf("[->artnet] error: dst=%s err=%v", target.IP, err) } - } else if nodes := a.discovery.GetNodesForUniverse(out.Universe); len(nodes) > 0 { + } else if nodes := a.discovery.GetNodesForUniverse(artU); len(nodes) > 0 { for _, node := range nodes { addr := &net.UDPAddr{ IP: node.IP, @@ -252,7 +252,7 @@ func (a *App) sendOutputs(outputs []remap.Output) { if a.debug { log.Printf("[->artnet] dst=%s universe=%s", node.IP, out.Universe) } - if err := a.artSender.SendDMX(addr, out.Universe, out.Data[:]); err != nil { + if err := a.artSender.SendDMX(addr, artU, out.Data[:]); err != nil { log.Printf("[->artnet] error: dst=%s err=%v", node.IP, err) } } @@ -305,9 +305,13 @@ 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 port +func protocolPort(p config.Protocol) int { + if p == config.ProtocolSACN { + return sacn.Port + } + return artnet.Port +} + func parseTargetAddr(s string, defaultPort int) (*net.UDPAddr, error) { var host string var port int diff --git a/remap/engine.go b/remap/engine.go index e964992..2361552 100644 --- a/remap/engine.go +++ b/remap/engine.go @@ -3,53 +3,34 @@ package remap import ( "sync" - "github.com/gopatchy/artmap/artnet" "github.com/gopatchy/artmap/config" ) // Output represents a remapped DMX output type Output struct { - Universe artnet.Universe - Protocol config.Protocol + Universe config.Universe Data [512]byte } -// outputKey uniquely identifies an output destination -type outputKey struct { - Universe artnet.Universe - Protocol config.Protocol -} - -// sourceKey uniquely identifies an input source -type sourceKey struct { - Universe artnet.Universe - Protocol config.Protocol -} - // Engine handles DMX channel remapping type Engine struct { mappings []config.NormalizedMapping - // Index mappings by source universe and protocol for faster lookup - bySource map[sourceKey][]config.NormalizedMapping - // Persistent state for each output universe (merged from all sources) - state map[outputKey]*[512]byte - stateMu sync.Mutex + bySource map[config.Universe][]config.NormalizedMapping + state map[config.Universe]*[512]byte + stateMu sync.Mutex } // NewEngine creates a new remapping engine func NewEngine(mappings []config.NormalizedMapping) *Engine { - bySource := make(map[sourceKey][]config.NormalizedMapping) + bySource := make(map[config.Universe][]config.NormalizedMapping) for _, m := range mappings { - key := sourceKey{Universe: m.FromUniverse, Protocol: m.FromProto} - bySource[key] = append(bySource[key], m) + bySource[m.From] = append(bySource[m.From], m) } - // Initialize state for all output universes - state := make(map[outputKey]*[512]byte) + state := make(map[config.Universe]*[512]byte) for _, m := range mappings { - key := outputKey{Universe: m.ToUniverse, Protocol: m.Protocol} - if _, ok := state[key]; !ok { - state[key] = &[512]byte{} + if _, ok := state[m.To]; !ok { + state[m.To] = &[512]byte{} } } @@ -61,9 +42,8 @@ func NewEngine(mappings []config.NormalizedMapping) *Engine { } // Remap applies mappings to incoming DMX data and returns outputs -func (e *Engine) Remap(srcProto config.Protocol, srcUniverse artnet.Universe, srcData [512]byte) []Output { - key := sourceKey{Universe: srcUniverse, Protocol: srcProto} - mappings, ok := e.bySource[key] +func (e *Engine) Remap(src config.Universe, srcData [512]byte) []Output { + mappings, ok := e.bySource[src] if !ok { return nil } @@ -71,62 +51,41 @@ func (e *Engine) Remap(srcProto config.Protocol, srcUniverse artnet.Universe, sr e.stateMu.Lock() defer e.stateMu.Unlock() - // Track which outputs are affected by this input - affected := make(map[outputKey]bool) + affected := make(map[config.Universe]bool) for _, m := range mappings { - outKey := outputKey{Universe: m.ToUniverse, Protocol: m.Protocol} - affected[outKey] = true + affected[m.To] = true + outState := e.state[m.To] - // Update state for this output - outState := e.state[outKey] - - // Copy channels into persistent state for i := 0; i < m.Count; i++ { - srcChan := m.FromChannel + i - dstChan := m.ToChannel + i + srcChan := m.FromChan + i + dstChan := m.ToChan + i if srcChan < 512 && dstChan < 512 { outState[dstChan] = srcData[srcChan] } } } - // Return outputs for all affected universes result := make([]Output, 0, len(affected)) - for outKey := range affected { + for u := range affected { result = append(result, Output{ - Universe: outKey.Universe, - Protocol: outKey.Protocol, - Data: *e.state[outKey], + Universe: u, + Data: *e.state[u], }) } return result } -// SourceUniverses returns all universes that have mappings -func (e *Engine) SourceUniverses() []artnet.Universe { - seen := make(map[artnet.Universe]bool) - for key := range e.bySource { - seen[key.Universe] = true - } - result := make([]artnet.Universe, 0, len(seen)) - for u := range seen { - result = append(result, u) - } - return result -} - -// DestUniverses returns all destination universes (for ArtNet discovery) -func (e *Engine) DestUniverses() []artnet.Universe { - seen := make(map[artnet.Universe]bool) +// DestArtNetUniverses returns destination ArtNet universe numbers (for discovery) +func (e *Engine) DestArtNetUniverses() []uint16 { + seen := make(map[uint16]bool) for _, m := range e.mappings { - if m.Protocol == config.ProtocolArtNet { - seen[m.ToUniverse] = true + if m.To.Protocol == config.ProtocolArtNet { + seen[m.To.Number] = true } } - - result := make([]artnet.Universe, 0, len(seen)) + result := make([]uint16, 0, len(seen)) for u := range seen { result = append(result, u) }