package main import ( "context" "fmt" "sync" "time" ) // Source is a control input that drives a device's zones. Each source is bound // to one device and arbitrates for control via the device's Apply. type Source interface { // Name returns the source's configured name. Name() string // Start begins receiving and applying control input. Start(ctx context.Context) error // Stop releases the source's resources. Stop() } // NewSource constructs the source implementation for the given configuration, // bound to its target device. func NewSource(cfg *SourceConfig, dev *Device) (Source, error) { if dev == nil { return nil, fmt.Errorf("device %q not found", cfg.Device) } hold := time.Duration(cfg.HoldSec * float64(time.Second)) // DMX is a stream of instantaneous level data — the console owns the crossfade // — so default DMX sources to an instant fade when none is configured; other // sources fall back to the device's fade. fade := cfg.Fade if fade == "" && (cfg.Type == "sacn" || cfg.Type == "artnet") { fade = "00:00" } binding := dev.RegisterSource(cfg.Name, cfg.Priority, hold, fade) switch cfg.Type { case "mqtt": return newMQTTSource(cfg, dev, binding), nil case "osc": return newOSCSource(cfg, dev, binding), nil case "sacn": disp, err := newDMXDispatcher(dev, binding, cfg.SACN.DMXMap) if err != nil { return nil, err } return newSACNSource(cfg, dev, disp), nil case "artnet": disp, err := newDMXDispatcher(dev, binding, cfg.ArtNet.DMXMap) if err != nil { return nil, err } return newArtNetSource(cfg, dev, disp), nil default: return nil, fmt.Errorf("unknown source type %q", cfg.Type) } } // dmxBinding maps a DMX channel (0-indexed offset into the universe) to a target // zone for level control. type dmxBinding struct { channel int target int } // sceneSelectBinding maps a DMX channel to scene activation by value: the channel's // value selects the scene (1..maxScene) to trigger, and 0 means no action. It fires // when the value changes, so it acts as a momentary trigger rather than a level. type sceneSelectBinding struct { channel int maxScene int } // dmxDispatcher applies a DMX universe frame to a device: zone channels set zone // levels, scene-select channels trigger scenes by value. type dmxDispatcher struct { dev *Device binding *sourceBinding zoneBindings []dmxBinding sceneBindings []sceneSelectBinding mu sync.Mutex prev []byte // Previous frame, for scene change detection. } // newDMXDispatcher builds a dispatcher from the DMX channel map. It notes scene // control on the device so scene monitoring is enabled. func newDMXDispatcher(dev *Device, binding *sourceBinding, m DMXMap) (*dmxDispatcher, error) { zones, scenes, err := buildDMXMap(m, dev.Zones()) if err != nil { return nil, err } if len(scenes) > 0 { dev.NoteSceneControl() } return &dmxDispatcher{ dev: dev, binding: binding, zoneBindings: zones, sceneBindings: scenes, }, nil } // dispatch applies a universe frame to the device. func (d *dmxDispatcher) dispatch(data []byte) { // Snapshot the previous frame and store the current one for edge detection. d.mu.Lock() prev := d.prev cur := make([]byte, len(data)) copy(cur, data) d.prev = cur d.mu.Unlock() // Zone channels: apply each mapped zone's level, leaving zones this source // doesn't map untouched rather than forcing them to zero. if len(d.zoneBindings) > 0 { levels := make(map[int]byte, len(d.zoneBindings)) for _, zb := range d.zoneBindings { if zb.channel < len(data) && zb.target >= 1 && zb.target <= d.dev.Zones() { levels[zb.target] = data[zb.channel] } } d.dev.ApplyZoneLevels(d.binding, levels) } // Scene-select channels: trigger a scene when the channel's value changes to a // non-zero scene number. Holding a value doesn't re-fire; sweeping a fader // through the channel fires each scene it passes, so set it deliberately. for _, sb := range d.sceneBindings { if sb.channel >= len(data) { continue } scene := int(data[sb.channel]) prevScene := 0 if sb.channel < len(prev) { prevScene = int(prev[sb.channel]) } if scene != prevScene && scene >= 1 && scene <= sb.maxScene { d.dev.ApplyScene(d.binding, scene) } } } // refreshActivity re-asserts the source's zone activity so a continuously // streaming universe keeps its arbitration lock between value changes. Scene-select // channels trigger on change and carry their own hold, so only zone-driving // dispatchers need this. func (d *dmxDispatcher) refreshActivity() { if len(d.zoneBindings) > 0 { d.dev.RefreshZoneActivity(d.binding) } } // release drives every zone this dispatcher owns to 0, mirroring a DMX source // going away: an unsourced universe collapses to all-zeros, so a console release // blacks the zones out instead of latching the last value. Scene-select channels // are left untouched; they trigger on change. The previous-frame state is cleared // so a returning identical look re-applies. func (d *dmxDispatcher) release() { if len(d.zoneBindings) == 0 { return } levels := make(map[int]byte, len(d.zoneBindings)) for _, zb := range d.zoneBindings { if zb.target >= 1 && zb.target <= d.dev.Zones() { levels[zb.target] = 0 } } d.dev.ApplyZoneLevels(d.binding, levels) d.mu.Lock() d.prev = nil d.mu.Unlock() } // buildDMXMap resolves a DMX channel map into zone bindings and scene-select // bindings. An explicit channels list takes precedence; otherwise the zones are // laid out sequentially from the start address, followed by a single scene-select // channel when scenes are enabled. func buildDMXMap(m DMXMap, deviceZones int) (zones []dmxBinding, scenes []sceneSelectBinding, err error) { // Explicit per-channel map. if len(m.Channels) > 0 { for _, c := range m.Channels { if c.Channel < 1 || c.Channel > 512 { return nil, nil, fmt.Errorf("channel %d: must be 1-512", c.Channel) } idx := c.Channel - 1 // 1-indexed DMX address to 0-indexed offset. switch c.Type { case "zone": if c.Zone < 1 || c.Zone > deviceZones { return nil, nil, fmt.Errorf("channel %d: zone %d out of range 1-%d", c.Channel, c.Zone, deviceZones) } zones = append(zones, dmxBinding{channel: idx, target: c.Zone}) case "scene": scenes = append(scenes, sceneSelectBinding{channel: idx, maxScene: qseMaxScene}) default: return nil, nil, fmt.Errorf("channel %d: unknown type %q", c.Channel, c.Type) } } return zones, scenes, nil } // Sequential layout: zones from the start address, then one scene-select channel. zoneCount := m.Zones if zoneCount == 0 { zoneCount = deviceZones } if m.StartAddress < 0 { return nil, nil, fmt.Errorf("start_address %d: must be >= 0", m.StartAddress) } if m.Scenes < 0 || m.Scenes > qseMaxScene { return nil, nil, fmt.Errorf("scenes %d: must be 0-%d", m.Scenes, qseMaxScene) } // One channel carries the scene selector when scenes are enabled. sceneChannels := 0 if m.Scenes > 0 { sceneChannels = 1 } // Reject a layout that runs past the 512-channel universe, which would // otherwise silently drop the out-of-range channels at dispatch. if last := m.StartAddress + zoneCount + sceneChannels; last > 512 { return nil, nil, fmt.Errorf("sequential layout runs to channel %d, past the 512-channel universe", last) } addr := m.StartAddress for z := 1; z <= zoneCount; z++ { zones = append(zones, dmxBinding{channel: addr, target: z}) addr++ } if m.Scenes > 0 { scenes = append(scenes, sceneSelectBinding{channel: addr, maxScene: m.Scenes}) } return zones, scenes, nil }