Initial implementation of ArtNet remapping proxy
Channel-level DMX remapping between ArtNet universes with: - TOML configuration with multiple address formats (net.subnet.universe, plain number) - ArtPoll discovery for output nodes - Configurable channel ranges for fixture spillover handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
201
config/config.go
Normal file
201
config/config.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/gopatchy/artmap/artnet"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Settings Settings `toml:"settings"`
|
||||
Mappings []Mapping `toml:"mapping"`
|
||||
}
|
||||
|
||||
// Settings contains global configuration options
|
||||
type Settings struct {
|
||||
ListenPort int `toml:"listen_port"`
|
||||
BroadcastAddr string `toml:"broadcast_addr"`
|
||||
}
|
||||
|
||||
// Mapping represents a single channel mapping rule
|
||||
type Mapping struct {
|
||||
// Source
|
||||
From UniverseAddr `toml:"from"`
|
||||
FromChannel int `toml:"from_channel"` // 1-512, 0 means all channels
|
||||
|
||||
// Destination
|
||||
To UniverseAddr `toml:"to"`
|
||||
ToChannel int `toml:"to_channel"` // 1-512, 0 means same as from_channel
|
||||
|
||||
// Range
|
||||
Count int `toml:"count"` // Number of channels, 0 means all remaining
|
||||
}
|
||||
|
||||
// UniverseAddr handles multiple universe address formats
|
||||
type UniverseAddr struct {
|
||||
Universe artnet.Universe
|
||||
}
|
||||
|
||||
func (u *UniverseAddr) UnmarshalText(text []byte) error {
|
||||
s := string(text)
|
||||
universe, err := ParseUniverseAddr(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Universe = universe
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UniverseAddr) UnmarshalTOML(data interface{}) error {
|
||||
switch v := data.(type) {
|
||||
case string:
|
||||
universe, err := ParseUniverseAddr(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Universe = universe
|
||||
return nil
|
||||
case int64:
|
||||
// Universe number only (0-32767)
|
||||
u.Universe = artnet.Universe(v)
|
||||
return nil
|
||||
case float64:
|
||||
// TOML sometimes parses integers as floats
|
||||
u.Universe = artnet.Universe(int64(v))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unsupported universe address type: %T", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseUniverseAddr parses various universe address formats:
|
||||
// - "0.0.1" or "0.0.1" - Net.Subnet.Universe
|
||||
// - "0:0:1" - Net:Subnet:Universe
|
||||
// - "1" - Universe number only
|
||||
func ParseUniverseAddr(s string) (artnet.Universe, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
// Try Net.Subnet.Universe format
|
||||
if strings.Contains(s, ".") {
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) == 3 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Try Net:Subnet:Universe format
|
||||
if strings.Contains(s, ":") {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) == 3 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Try plain universe number
|
||||
u, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid universe address format: %s", s)
|
||||
}
|
||||
return artnet.Universe(u), nil
|
||||
}
|
||||
|
||||
// Load loads configuration from a TOML file
|
||||
func Load(path string) (*Config, error) {
|
||||
var cfg Config
|
||||
|
||||
// Set defaults
|
||||
cfg.Settings.ListenPort = artnet.Port
|
||||
cfg.Settings.BroadcastAddr = "2.255.255.255"
|
||||
|
||||
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Validate and normalize mappings
|
||||
for i := range cfg.Mappings {
|
||||
m := &cfg.Mappings[i]
|
||||
|
||||
// Default from_channel to 1 (start of universe)
|
||||
if m.FromChannel == 0 {
|
||||
m.FromChannel = 1
|
||||
}
|
||||
|
||||
// Default to_channel to same as from_channel
|
||||
if m.ToChannel == 0 {
|
||||
m.ToChannel = m.FromChannel
|
||||
}
|
||||
|
||||
// Default count to all remaining channels
|
||||
if m.Count == 0 {
|
||||
m.Count = 512 - m.FromChannel + 1
|
||||
}
|
||||
|
||||
// Validate ranges
|
||||
if m.FromChannel < 1 || m.FromChannel > 512 {
|
||||
return nil, fmt.Errorf("mapping %d: from_channel must be 1-512", i)
|
||||
}
|
||||
if m.ToChannel < 1 || m.ToChannel > 512 {
|
||||
return nil, fmt.Errorf("mapping %d: to_channel must be 1-512", i)
|
||||
}
|
||||
if m.FromChannel+m.Count-1 > 512 {
|
||||
return nil, fmt.Errorf("mapping %d: from_channel + count exceeds 512", i)
|
||||
}
|
||||
if m.ToChannel+m.Count-1 > 512 {
|
||||
return nil, fmt.Errorf("mapping %d: to_channel + count exceeds 512", i)
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// NormalizedMapping is a processed mapping ready for the remapper
|
||||
type NormalizedMapping struct {
|
||||
FromUniverse artnet.Universe
|
||||
FromChannel int // 0-indexed
|
||||
ToUniverse artnet.Universe
|
||||
ToChannel int // 0-indexed
|
||||
Count int
|
||||
}
|
||||
|
||||
// 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.FromChannel - 1, // Convert to 0-indexed
|
||||
ToUniverse: m.To.Universe,
|
||||
ToChannel: m.ToChannel - 1, // Convert to 0-indexed
|
||||
Count: m.Count,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user