233 lines
7.5 KiB
Go
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
|
|
}
|