Add sACN input/output support and fix multi-source merging

- Add sACN/E1.31 protocol support for both input and output
- from_proto = "sacn" to receive from sACN multicast
- proto = "sacn" to output via sACN multicast
- Fix remap engine to maintain persistent state per output universe
- Multiple inputs targeting same output now merge correctly
- Prevents flickering when multiple universes feed same output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2025-12-22 12:32:41 -08:00
parent cdb769d059
commit b0e9ecdee7
9 changed files with 580 additions and 80 deletions

View File

@@ -14,10 +14,20 @@ type Config struct {
Mappings []Mapping `toml:"mapping"`
}
// 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"`
From FromAddr `toml:"from"`
FromProto Protocol `toml:"from_proto"`
To ToAddr `toml:"to"`
Protocol Protocol `toml:"proto"`
}
// FromAddr represents a source universe address with channel range
@@ -201,7 +211,8 @@ func Load(path string) (*Config, error) {
return nil, fmt.Errorf("failed to load config: %w", err)
}
for i, m := range cfg.Mappings {
for i := range cfg.Mappings {
m := &cfg.Mappings[i]
if m.From.ChannelStart < 1 || m.From.ChannelStart > 512 {
return nil, fmt.Errorf("mapping %d: from channel start must be 1-512", i)
}
@@ -218,6 +229,16 @@ 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
@@ -227,9 +248,11 @@ func Load(path string) (*Config, error) {
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)
@@ -239,10 +262,27 @@ func (c *Config) Normalize() []NormalizedMapping {
result[i] = NormalizedMapping{
FromUniverse: m.From.Universe,
FromChannel: m.From.ChannelStart - 1,
FromProto: m.FromProto,
ToUniverse: m.To.Universe,
ToChannel: m.To.ChannelStart - 1,
Count: m.From.Count(),
Protocol: m.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.FromProto == ProtocolSACN {
seen[uint16(m.From.Universe)] = true
}
}
result := make([]uint16, 0, len(seen))
for u := range seen {
result = append(result, u)
}
return result
}