package config import ( "fmt" "strconv" "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 const ( ProtocolArtNet Protocol = "artnet" ProtocolSACN Protocol = "sacn" ) // Mapping represents a single channel mapping rule type Mapping struct { 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 } 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) } } // 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) if err != nil { return err } a.Protocol = proto universeStr, channelSpec := splitAddr(rest) universe, err := parseUniverse(universeStr) if err != nil { return err } a.Universe = universe if channelSpec == "" { a.ChannelStart = 1 a.ChannelEnd = 512 return nil } if idx := strings.Index(channelSpec, "-"); idx != -1 { startStr := channelSpec[:idx] endStr := channelSpec[idx+1:] start, err := strconv.Atoi(startStr) if err != nil { return fmt.Errorf("invalid channel start: %w", err) } a.ChannelStart = start if endStr == "" { a.ChannelEnd = 512 } else { end, err := strconv.Atoi(endStr) if err != nil { return fmt.Errorf("invalid channel end: %w", err) } a.ChannelEnd = end } } else { ch, err := strconv.Atoi(channelSpec) if err != nil { return fmt.Errorf("invalid channel: %w", err) } a.ChannelStart = ch a.ChannelEnd = ch } 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 } 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) } } // 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) if err != nil { return err } a.Protocol = proto universeStr, channelSpec := splitAddr(rest) universe, err := parseUniverse(universeStr) if err != nil { return err } a.Universe = universe if channelSpec == "" { a.ChannelStart = 1 return nil } if strings.Contains(channelSpec, "-") { return fmt.Errorf("to address cannot contain range; use single channel number") } ch, err := strconv.Atoi(channelSpec) if err != nil { 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 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:] } return s, "" } func parseUniverse(s string) (artnet.Universe, error) { if strings.Contains(s, ".") { parts := strings.Split(s, ".") if len(parts) != 3 { return 0, fmt.Errorf("invalid universe format: %s (expected net.subnet.universe)", s) } net, err := strconv.Atoi(parts[0]) if err != nil { return 0, fmt.Errorf("invalid net: %w", err) } subnet, err := strconv.Atoi(parts[1]) if err != nil { return 0, fmt.Errorf("invalid subnet: %w", err) } universe, err := strconv.Atoi(parts[2]) if err != nil { return 0, fmt.Errorf("invalid universe: %w", err) } return artnet.NewUniverse(uint8(net), uint8(subnet), uint8(universe)), nil } u, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("invalid universe: %s", s) } return artnet.Universe(u), nil } // Load loads configuration from a TOML file func Load(path string) (*Config, error) { var cfg Config if _, err := toml.DecodeFile(path, &cfg); err != nil { 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, 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) } if m.From.ChannelEnd < 1 || m.From.ChannelEnd > 512 { return nil, fmt.Errorf("mapping %d: from channel end must be 1-512", i) } if m.From.ChannelStart > m.From.ChannelEnd { return nil, fmt.Errorf("mapping %d: from channel start > end", i) } if m.To.ChannelStart < 1 || m.To.ChannelStart > 512 { return nil, fmt.Errorf("mapping %d: to channel must be 1-512", i) } toEnd := m.To.ChannelStart + m.From.Count() - 1 if toEnd > 512 { return nil, fmt.Errorf("mapping %d: to channels exceed 512", i) } } return &cfg, nil } // 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 } // Normalize converts config mappings to normalized form (0-indexed channels) 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, } } return result } // SACNSourceUniverses returns universes that need sACN 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 } } result := make([]uint16, 0, len(seen)) for u := range seen { result = append(result, u) } return result }