lutron-control/source.go
2026-06-28 08:32:22 -05:00

233 lines
7.5 KiB
Go

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
}