Add scene-select channels, MQTT override, and release tooling; bump to 0.1.1
Some checks failed
Go package / build (push) Has been cancelled

Restore uncommitted work from earlier sessions: DMX scene-select channels,
MQTT override handling, configurable zone refresh interval, OSC additions,
and matching tests and docs.

Add versioning following nginx-cache-purge: VERSION file, ldflags-stamped
build identifiers in info.go, Makefile, GoReleaser config, GitHub workflows,
and a -version flag using the standard flag package.

Claude-Session: https://claude.ai/code/session_01C5ZxhELATettecwYcNiXVN
This commit is contained in:
James Coleman 2026-08-25 21:11:06 -05:00
parent a61031f5ef
commit b39600f64a
19 changed files with 909 additions and 39 deletions

31
.github/workflows/release.yaml vendored Normal file
View file

@ -0,0 +1,31 @@
on:
release:
types: [created]
permissions:
contents: write
packages: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
-
name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

24
.github/workflows/test_golang.yaml vendored Normal file
View file

@ -0,0 +1,24 @@
name: Go package
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
- name: Build
run: go build -v ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -v ./...

3
.gitignore vendored
View file

@ -7,3 +7,6 @@ config.yaml
# Logs.
*.log
*.log.gz
# Build artifacts.
dist/

74
.goreleaser.yaml Normal file
View file

@ -0,0 +1,74 @@
# GoReleaser config for lutron-control.
# https://goreleaser.com
#
# CGO is disabled so the binary is fully static (no glibc dependency) and runs
# unmodified across modern Linux distributions.
version: 2
project_name: lutron-control
before:
hooks:
- go mod tidy
- go test ./...
builds:
- id: lutron-control
main: .
binary: lutron-control
env:
- CGO_ENABLED=0
flags:
- -trimpath
# The build identifiers live in package main, so they are stamped there
# rather than in an imported configuration package.
ldflags:
- -s -w
- -X main.Version={{ .Version }}
- -X main.Commit={{ .ShortCommit }}
- -X main.Date={{ .Date }}
- -X main.Mode=release
goos:
- linux
- darwin
goarch:
- "386"
- amd64
- arm
- arm64
- ppc64le
goarm:
- "6"
ignore:
- goos: darwin
goarch: "386"
- goos: darwin
goarch: arm
- goos: darwin
goarch: ppc64le
archives:
- id: default
formats: [tar.gz]
# Kept compatible with the results of uname, matching the naming earlier
# releases were published under.
name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
files:
- README.md
- LICENSE
checksum:
name_template: "checksums.txt"
snapshot:
version_template: "{{ incpatch .Version }}-snapshot"
changelog:
use: git
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"

39
Makefile Normal file
View file

@ -0,0 +1,39 @@
BINARY := lutron-control
# The build identifiers live in package main, so ldflags target it directly
# rather than an imported configuration package.
PACKAGE := main
# VERSION is the single source of truth for the version string. COMMIT and DATE
# are derived from git and the build clock.
VERSION ?= $(shell cat VERSION 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
DATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -s -w -X $(PACKAGE).Version=$(VERSION) -X $(PACKAGE).Commit=$(COMMIT) -X $(PACKAGE).Date=$(DATE)
.PHONY: all build test vet fmt snapshot release clean
all: build
## build: native static binary into dist/
build:
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) .
## test: run the unit tests
test:
go test ./...
vet:
go vet ./...
fmt:
gofmt -w .
## snapshot: local GoReleaser build without publishing (artifacts in dist/)
snapshot:
goreleaser release --snapshot --clean
## release: full GoReleaser release (CI runs this on a tag)
release:
goreleaser release --clean
clean:
rm -rf dist

View file

@ -121,6 +121,60 @@ for `hold_sec` seconds — so giving DMX sources a higher priority than MQTT let
live show keep Home Assistant from changing the lights mid-cue. When an MQTT command
is locked out, the current zone state is mirrored back to MQTT instead.
### Taking control back from the lighting board
Handing the lights over when the show ends needs a moment where neither side is in
charge: a DMX console that stops streaming releases its zones to 0, so simply
switching the board off drops the lights. The MQTT and OSC sources can flip a
**temporary priority override** instead — a switch in Home Assistant
(`mqtt.priority_override`) or the `<prefix>/override` OSC address:
```yaml
- name: home-assistant
type: mqtt
device: grafik-eye
priority: 1
override_sec: 60 # How long the override lasts; 0 disables it.
override_priority: 1000 # Priority held while it runs.
mqtt:
priority_override: true # Expose the switch (and its Home Assistant entity).
```
While the override runs, the source arbitrates at `override_priority` *and* counts
as active for the whole window even if it sends nothing — so it takes the zones
from the console and holds them, including against the console's release to 0. The
lights stay exactly where they are; power the board off, and by the time the window
closes there is nothing left streaming, so control has passed over without the
lights ever dropping. The switch flips itself back off when the window closes (and
can be toggled off early to hand control straight back).
### Parking the bridge
Integration control is the master switch above all of this: while it is off the
bridge drives nothing at all — zone levels, scenes, and movement are refused for
every source — leaving the panel entirely to its keypads. It's how you hand the
room back for a rehearsal or maintenance without stopping the service.
The GRAFIK Eye can signal it from a phantom button (`enable_component` /
`disable_component` on the device), and MQTT and OSC can drive the same state:
```yaml
mqtt:
integration_control: true # Switch on <topic>/control/set, state on <topic>/control.
```
| | |
| --- | --- |
| MQTT | switch on `<topic>/control/set`, state published to `<topic>/control` (a Home Assistant switch with discovery) |
| OSC | `<prefix>/control` with `0`/`1`, state streamed back to the `stream_to` destinations |
The state is shared, not per-source, so all three views stay in step: pressing the
panel's disable button flips the Home Assistant switch and the OSC toggle, and
turning it back on from either re-asserts every zone so the panel returns to the
levels the bridge maintains. While control is parked the bridge still *follows* the
panel's reported levels, so it resumes from where the room actually is rather than
snapping back to a stale look.
## OSC control
An OSC source exposes the full control surface over UDP. Set `osc.listen` to the
@ -156,6 +210,8 @@ The control addresses, all under `prefix`:
| `<prefix>/lock/zone` | `i` | zone lock (0 off, 1 on) |
| `<prefix>/lock/scene` | `i` | scene lock (0 off, 1 on) |
| `<prefix>/sequence` | `i` | sequence (0 off, 1 scenes 14, 2 scenes 516) |
| `<prefix>/override` | `i` | temporary priority override (0 off, 1 on) — see [Arbitration](#arbitration) |
| `<prefix>/control` | `i` | integration control (0 off, 1 on) — see [Arbitration](#arbitration) |
To stream the panel's feedback back to a controller, set `osc.stream_to` to one or
more `host:port` destinations — see [Monitoring feedback](#monitoring-feedback)
@ -176,7 +232,11 @@ which `~` message families are forwarded.
reports get symmetric addresses — `<prefix>/zone/<n>/level`, `<prefix>/scene`,
`<prefix>/group/<id>/occupancy` — and anything else falls back to
`<prefix>/monitor/<family>/<fields...>`. `osc.level_as_float` (default true)
picks the zone-level encoding (01 float or 0255 int).
picks the zone-level encoding (01 float or 0255 int). The priority override
and integration control are bridge state rather than panel reports, so they are
echoed to the same destinations under `<prefix>/override` and `<prefix>/control`
— including when the override window closes or the panel's own enable/disable
buttons move it — letting a controller's toggles follow along.
- **MQTT** publishes to `<topic>/<monitor_prefix>/<family>/<fields...>` (retained,
`monitor_prefix` defaults to `monitor`). These are raw state topics for
automations; no Home Assistant discovery is published for them.

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.1

View file

@ -55,7 +55,7 @@ func (s *ArtNetSource) Start(ctx context.Context) error {
// Drive the node's logging through our configured logger rather than its
// default debug-to-stdout logger.
nodeLog := artnet.NewLogger(log.NewEntry(log.StandardLogger()))
node := artnet.NewNode(serviceName, code.StNode, ip, nodeLog)
node := artnet.NewNode(Name, code.StNode, ip, nodeLog)
s.node = node
node.RegisterCallback(code.OpDMX, func(p packet.ArtNetPacket) {

View file

@ -13,8 +13,11 @@
# sources target the same device, `priority` decides who wins: a higher-priority
# source that is actively sending locks out lower-priority ones for `hold_sec`
# seconds (so a live DMX show keeps Home Assistant from changing the lights
# mid-cue). DMX carries only zone levels; the richer controls (raise/lower/stop,
# shades, locks, sequence) live on the MQTT and OSC sources.
# mid-cue). The MQTT and OSC sources can also flip a temporary priority override
# (`override_sec`, 1 minute by default) to take the lights back from a DMX stream
# long enough for the lighting board to be switched off without them dropping.
# DMX carries only zone levels; the richer controls (raise/lower/stop, shades,
# locks, sequence) live on the MQTT and OSC sources.
log:
level: info # debug, info, warn, or error.
@ -50,6 +53,8 @@ devices:
# (advanced; application-specific programming on the unit). Both default to 0
# (off): the signal is only acted on when set to a non-zero component, so an
# unrelated button can't silently disable the bridge. Set to match your unit.
# The same state can be driven from MQTT (mqtt.integration_control) or OSC
# (<prefix>/control), and those follow these buttons when they are pressed.
# disable_component: 74
# enable_component: 75
@ -124,6 +129,10 @@ sources:
# fade: "00:01" # Optional per-source zone fade override (SS, MM:SS,
# or HH:MM:SS). Empty uses the device fade; DMX
# sources instead default to instant.
override_sec: 60 # Temporary priority override window (see
# priority_override below); 0 disables it.
override_priority: 1000 # Priority held while the override runs; must be
# above every source it needs to take over from.
mqtt:
broker: 127.0.0.1
port: 1883
@ -152,6 +161,18 @@ sources:
scene_lock: false # Scene-lock switch on <topic>/scene_lock/set (QS Standalone).
sequence: false # Sequence select (Off / Scenes 1-4 / Scenes 5-16)
# on <topic>/sequence/set (QS Standalone).
integration_control: false # Switch on <topic>/control/set for integration
# control itself: off parks the bridge (it stops
# driving the panel, leaving it to its keypads), on
# resumes and re-asserts every zone. State (ON/OFF)
# is published to <topic>/control and follows the
# panel's own enable/disable buttons.
priority_override: false # Switch on <topic>/override/set that takes control
# from a live DMX stream for override_sec seconds,
# so the lighting board can be powered off without
# the lights dropping. State (ON/OFF) is published
# to <topic>/override and flips back off by itself
# when the window closes.
# Monitoring relay. Publishes the panel's "~" reports to
# <topic>/<monitor_prefix>/<family>/<fields...> for use in automations
@ -196,6 +217,12 @@ sources:
# <prefix>/lock/zone i zone lock (0 off, 1 on)
# <prefix>/lock/scene i scene lock (0 off, 1 on)
# <prefix>/sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16)
# <prefix>/override i temporary priority override (0 off, 1 on):
# takes control from a live DMX stream for
# override_sec seconds so the lighting board can
# be powered off without the lights dropping
# <prefix>/control i integration control (0 off, 1 on): off parks
# the bridge, on resumes and re-asserts the zones
#
# Monitoring feedback is streamed (when stream_to is set) back out under the
# same prefix, symmetric with the input addresses:
@ -203,12 +230,17 @@ sources:
# <prefix>/zone/<n>/level f|i reported zone level
# <prefix>/scene i reported active scene
# <prefix>/group/<id>/occupancy i occupancy state (3 occupied, 4 unoccupied)
# <prefix>/override i override state, resent when the window closes
# <prefix>/control i integration control state, resent when the
# panel's own enable/disable buttons move it
# <prefix>/monitor/<family>/<fields...> generic fallback for any other report
- name: show-osc
type: osc
device: grafik-eye
priority: 5 # Above MQTT, below a live DMX stream.
hold_sec: 5
override_sec: 60 # Temporary priority override window; 0 disables it.
override_priority: 1000 # Priority held while the override runs.
osc:
listen: 0.0.0.0:9000 # host:port the OSC server binds.
prefix: /lutron # Address namespace this source responds under.

View file

@ -157,6 +157,17 @@ type SourceConfig struct {
// HoldSec is how long after its last update a source stays "active" for the
// purpose of locking out lower-priority sources.
HoldSec float64 `fig:"hold_sec" yaml:"hold_sec" default:"5"`
// OverrideSec is how long a temporary priority override lasts once the source
// toggles it on (MQTT switch or OSC address). While it runs the source
// arbitrates at OverridePriority and counts as active, so it takes control
// from a streaming DMX source and holds it — including against that source's
// release to zero — letting a lighting board be powered off without the lights
// dropping. 0 disables the override.
OverrideSec float64 `fig:"override_sec" yaml:"override_sec" default:"60"`
// OverridePriority is the priority the source arbitrates at while its temporary
// override is active; it must be above the priority of the source being taken
// over from.
OverridePriority int `fig:"override_priority" yaml:"override_priority" default:"1000"`
// Fade overrides the zone fade time this source applies when setting levels
// (format "SS", "MM:SS", or "HH:MM:SS"). When empty, DMX sources (sacn/artnet)
// default to instant ("00:00") so the console owns the crossfade, and other
@ -224,6 +235,17 @@ type MQTTConfig struct {
SceneLock bool `fig:"scene_lock" yaml:"scene_lock"`
// Sequence exposes a scene-sequence select (Off / Scenes 1-4 / Scenes 5-16).
Sequence bool `fig:"sequence" yaml:"sequence"`
// IntegrationControl exposes a switch for integration control itself: off
// stops the bridge driving the panel (the state the disable phantom button
// signals), on resumes and re-asserts every zone. Commands arrive on
// <topic>/control/set and the state is published to <topic>/control, so the
// switch also follows the panel's own enable/disable signals.
IntegrationControl bool `fig:"integration_control" yaml:"integration_control"`
// PriorityOverride exposes a switch that temporarily raises this source's
// arbitration priority (see the source's override_sec and override_priority),
// so MQTT can take control from a live DMX stream. Commands arrive on
// <topic>/override/set and the state is published to <topic>/override.
PriorityOverride bool `fig:"priority_override" yaml:"priority_override"`
// Lights defines the individual lights exposed by this source, each driving
// its own set of zones. When omitted, a single light is synthesized from the
// base topic and device_name driving every zone.
@ -368,7 +390,7 @@ func (l *LogConfig) Apply() {
// defaultLogPath returns a writable path for the `default-file` output, trying
// /var/log first and falling back to beside the executable.
func defaultLogPath() (string, bool) {
logName := fmt.Sprintf("%s.log", serviceName)
logName := fmt.Sprintf("%s.log", Name)
// On *nix, prefer /var/log when writable.
if runtime.GOOS != "windows" {
@ -401,8 +423,8 @@ func (a *App) ReadConfig() {
// Configuration search paths.
localConfig, _ := filepath.Abs("./config.yaml")
homeDirConfig := filepath.Join(usr.HomeDir, ".config", serviceName, "config.yaml")
etcConfig := filepath.Join("/etc", serviceName, "config.yaml")
homeDirConfig := filepath.Join(usr.HomeDir, ".config", Name, "config.yaml")
etcConfig := filepath.Join("/etc", Name, "config.yaml")
// Determine which configuration file to use.
var configFile string
@ -496,6 +518,18 @@ func (c *Config) Validate() error {
if dev == nil {
return fmt.Errorf("source %q: unknown device %q", s.Name, s.Device)
}
// Validate the temporary priority override. Only MQTT and OSC expose the
// toggle, and an override that doesn't raise the source above its normal
// priority would take control from nobody.
if s.OverrideSec < 0 {
return fmt.Errorf("source %q: override_sec must be >= 0", s.Name)
}
if (s.Type == "mqtt" || s.Type == "osc") && s.OverrideSec > 0 && s.OverridePriority <= s.Priority {
return fmt.Errorf("source %q: override_priority %d must be greater than priority %d",
s.Name, s.OverridePriority, s.Priority)
}
if s.Type == "mqtt" {
if s.MQTT.Broker == "" {
return fmt.Errorf("source %q: mqtt.broker is required", s.Name)
@ -511,9 +545,11 @@ func (c *Config) Validate() error {
if s.MQTT.Shades < 0 || s.MQTT.Shades > 3 {
return fmt.Errorf("source %q: mqtt.shades must be 0-3", s.Name)
}
// The shade/lock/sequence controls are rooted on the base topic.
if (s.MQTT.Shades > 0 || s.MQTT.ZoneLock || s.MQTT.SceneLock || s.MQTT.Sequence) && s.MQTT.Topic == "" {
return fmt.Errorf("source %q: mqtt.topic is required for shade/lock/sequence controls", s.Name)
// The shade/lock/sequence/override/control switches are rooted on the
// base topic.
if (s.MQTT.Shades > 0 || s.MQTT.ZoneLock || s.MQTT.SceneLock || s.MQTT.Sequence ||
s.MQTT.PriorityOverride || s.MQTT.IntegrationControl) && s.MQTT.Topic == "" {
return fmt.Errorf("source %q: mqtt.topic is required for shade/lock/sequence/override/control switches", s.Name)
}
// Validate each configured light's topic and zone set.
seen := make(map[string]bool, len(s.MQTT.Lights))

View file

@ -84,6 +84,31 @@ func TestValidateMQTTLightRequirements(t *testing.T) {
}
}
// TestValidatePriorityOverride verifies the temporary override must raise the
// source above its normal priority and that a negative window is rejected.
func TestValidatePriorityOverride(t *testing.T) {
c := baseConfig()
c.Sources[0].Priority = 1
c.Sources[0].OverrideSec = 60
c.Sources[0].OverridePriority = 1000
if err := c.Validate(); err != nil {
t.Fatalf("valid override config rejected: %s", err)
}
// An override that doesn't outrank the source's own priority takes control
// from nobody.
c.Sources[0].OverridePriority = 1
if err := c.Validate(); err == nil {
t.Error("expected error for override_priority not above priority")
}
c = baseConfig()
c.Sources[0].OverrideSec = -1
if err := c.Validate(); err == nil {
t.Error("expected error for negative override_sec")
}
}
// TestNewMQTTSourceSynthesizesLight verifies that, with no lights configured, a
// single light over every zone is synthesized from the base topic.
func TestNewMQTTSourceSynthesizesLight(t *testing.T) {

162
device.go
View file

@ -98,7 +98,9 @@ var qseShadeColumns = map[int]shadeColumn{
// sourceBinding tracks a control source's arbitration state on a device. Zone
// and scene activity are tracked separately so the device knows whether zones
// are being actively driven.
// are being actively driven. A source may also hold a temporary priority
// override, which both raises its priority and counts as activity until the
// window closes.
type sourceBinding struct {
name string
priority int
@ -106,12 +108,39 @@ type sourceBinding struct {
fade string // Zone fade time this source applies; empty uses the device default.
lastZoneActive time.Time
lastSceneActive time.Time
// Temporary priority override state. overrideHold is how long a window lasts
// once toggled on (0 disables the feature) and overrideUntil is when the
// current window closes; it is the zero time when no override is running.
overridePriority int
overrideHold time.Duration
overrideUntil time.Time
}
// overriding reports whether the source's temporary priority override is running
// as of now.
func (b *sourceBinding) overriding(now time.Time) bool {
return now.Before(b.overrideUntil)
}
// effectivePriority returns the priority the source arbitrates with as of now,
// which is its override priority while a temporary override is running.
func (b *sourceBinding) effectivePriority(now time.Time) int {
if b.overriding(now) {
return b.overridePriority
}
return b.priority
}
// activeAt reports whether the source has applied zone or scene control within
// its hold window as of now.
// its hold window as of now. A running priority override counts as activity for
// its whole window, so the source keeps the lock even while it sends nothing —
// that is what stops a departing DMX source from blacking out the zones it just
// handed over.
func (b *sourceBinding) activeAt(now time.Time) bool {
return now.Sub(b.lastZoneActive) < b.hold || now.Sub(b.lastSceneActive) < b.hold
return b.overriding(now) ||
now.Sub(b.lastZoneActive) < b.hold ||
now.Sub(b.lastSceneActive) < b.hold
}
// zoneFeedbackFn is called when the device reports a zone's current level.
@ -120,6 +149,9 @@ type zoneFeedbackFn func(zone int, level byte)
// sceneFeedbackFn is called when the device reports the active scene.
type sceneFeedbackFn func(scene int)
// controlFeedbackFn is called when integration control is enabled or disabled.
type controlFeedbackFn func(enabled bool)
// MonitorEvent is a parsed monitoring message reported by the panel. It carries
// every "~" feedback line generically so a source can relay arbitrary monitoring
// (zone, scene, button, occupancy, etc.) without the device modelling each type.
@ -168,6 +200,7 @@ type Device struct {
pendingResync int // Zone reports still awaited before the resync is complete.
bindings []*sourceBinding
feedbackFns []zoneFeedbackFn
controlFns []controlFeedbackFn
// Generic monitoring fan-out. monitorFns receive every "~" feedback line;
// requestedMonitoring is the union of #MONITORING types sources have asked the
@ -245,6 +278,60 @@ func (d *Device) OnSceneFeedback(fn sceneFeedbackFn) {
d.dataMu.Unlock()
}
// OnControlChange registers a callback invoked when integration control is
// enabled or disabled, whether by the panel's signal or by a source. It must be
// called before Start.
func (d *Device) OnControlChange(fn controlFeedbackFn) {
d.dataMu.Lock()
d.controlFns = append(d.controlFns, fn)
d.dataMu.Unlock()
}
// SetControlEnabled enables or disables integration control on behalf of a
// source. While disabled the bridge drives nothing — zone writes, scenes, and
// movement are all refused — leaving the panel to its keypads; enabling again
// re-asserts every zone. This is the same state the panel's enable/disable
// phantom buttons signal, exposed so a source can drive it too.
func (d *Device) SetControlEnabled(enabled bool) {
d.setControlDisabled(!enabled, "source")
}
// ControlEnabled reports whether integration control is currently enabled.
func (d *Device) ControlEnabled() bool {
d.dataMu.Lock()
defer d.dataMu.Unlock()
return !d.controlDisabled
}
// setControlDisabled records a new control state and notifies subscribers when it
// actually changes. Re-enabling forces a full resend so the panel is brought back
// to the levels we maintain. origin names what drove the change, for the log.
func (d *Device) setControlDisabled(disabled bool, origin string) {
d.dataMu.Lock()
if d.controlDisabled == disabled {
d.dataMu.Unlock()
return
}
d.controlDisabled = disabled
// Re-assert every zone on the way back so the panel matches our targets, which
// followed its reported levels while we were held off.
if !disabled {
d.sendAll = true
}
fns := d.controlFns
d.dataMu.Unlock()
state := "enabled"
if disabled {
state = "disabled"
}
log.Infof("[%s] Integration control %s (%s)", d.cfg.Name, state, origin)
for _, fn := range fns {
fn(!disabled)
}
}
// NoteSceneControl marks that a scene source is attached so scene monitoring is
// enabled on connect. It must be called before Start.
func (d *Device) NoteSceneControl() {
@ -275,19 +362,73 @@ func (d *Device) NoteMonitoring(types ...int) {
}
// lockedOut reports whether a higher-priority source is currently active,
// blocking this binding from taking control. Caller must hold dataMu.
// blocking this binding from taking control. Priorities are compared as they
// stand now, so a source running a temporary override is measured at its
// override priority. Caller must hold dataMu.
func (d *Device) lockedOut(b *sourceBinding, now time.Time) bool {
mine := b.effectivePriority(now)
for _, other := range d.bindings {
if other == b {
continue
}
if other.priority > b.priority && other.activeAt(now) {
if other.effectivePriority(now) > mine && other.activeAt(now) {
return true
}
}
return false
}
// ConfigurePriorityOverride records the priority a source assumes while its
// temporary override runs and how long each window lasts. A non-positive hold
// leaves the override unavailable. It must be called before Start.
func (d *Device) ConfigurePriorityOverride(b *sourceBinding, priority int, hold time.Duration) {
d.dataMu.Lock()
b.overridePriority = priority
b.overrideHold = hold
d.dataMu.Unlock()
}
// SetPriorityOverride starts or clears a source's temporary priority override.
// While it runs the source arbitrates at its override priority and counts as
// active, so it takes control from a higher-priority source (a console streaming
// DMX) and holds it — including against that source's release to zero — which is
// what lets the console be powered off without the lights dropping. Toggling it
// on again restarts the window. It returns the time the window closes, or the
// zero time when the override is off or unavailable.
func (d *Device) SetPriorityOverride(b *sourceBinding, on bool) time.Time {
d.dataMu.Lock()
defer d.dataMu.Unlock()
if !on {
if !b.overrideUntil.IsZero() {
log.Infof("[%s] Priority override cleared for source %q", d.cfg.Name, b.name)
}
b.overrideUntil = time.Time{}
return time.Time{}
}
if b.overrideHold <= 0 {
log.Warnf("[%s] Priority override requested by source %q but override_sec is not set",
d.cfg.Name, b.name)
return time.Time{}
}
b.overrideUntil = time.Now().Add(b.overrideHold)
log.Infof("[%s] Priority override active for source %q at priority %d for %s",
d.cfg.Name, b.name, b.overridePriority, b.overrideHold)
return b.overrideUntil
}
// PriorityOverride reports whether a source's temporary priority override is
// running and, if so, when its window closes.
func (d *Device) PriorityOverride(b *sourceBinding) (bool, time.Time) {
d.dataMu.Lock()
defer d.dataMu.Unlock()
if b.overriding(time.Now()) {
return true, b.overrideUntil
}
return false, time.Time{}
}
// ApplyZoneLevels sets the target level for a set of 1-indexed zones on behalf of
// a source, leaving every other zone untouched (a source only ever drives the
// zones it owns). It returns false when the caller can't take control — either a
@ -806,16 +947,9 @@ func (d *Device) handleLine(line string) {
case strings.HasPrefix(upper, "~ERROR"):
d.handleError(line)
case d.cfg.DisableComponent != 0 && line == d.disableSignal():
log.Infof("[%s] Received disable signal", d.cfg.Name)
d.dataMu.Lock()
d.controlDisabled = true
d.dataMu.Unlock()
d.setControlDisabled(true, "panel signal")
case d.cfg.EnableComponent != 0 && line == d.enableSignal():
log.Infof("[%s] Received enable signal", d.cfg.Name)
d.dataMu.Lock()
d.controlDisabled = false
d.sendAll = true
d.dataMu.Unlock()
d.setControlDisabled(false, "panel signal")
case strings.HasPrefix(line, d.devicePrefix()):
d.handleDeviceFeedback(line)
}

View file

@ -171,6 +171,80 @@ func TestApplyZoneLevelsTouchesOnlyMappedZones(t *testing.T) {
}
}
// TestPriorityOverrideTakesControl verifies a temporary override lets a
// lower-priority source take control from a streaming DMX source and hold it
// without sending anything further — including against the DMX release to 0, so
// the console can be powered off without the zones dropping — and that control
// reverts once the window closes.
func TestPriorityOverrideTakesControl(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
dmx := d.RegisterSource("dmx", 10, time.Second, "")
// A 1 ms hold means only the override itself can keep the MQTT source active.
mq := d.RegisterSource("mqtt", 1, time.Millisecond, "")
d.ConfigurePriorityOverride(mq, 1000, 120*time.Millisecond)
// The live DMX stream owns the zones, locking the MQTT source out.
if !d.ApplyZoneLevels(dmx, map[int]byte{1: 200}) {
t.Fatal("ApplyZoneLevels returned false for the DMX source")
}
if d.ApplyZoneLevels(mq, map[int]byte{1: 50}) {
t.Error("expected the MQTT source to be locked out while DMX is active")
}
// With the override running the MQTT source takes over and DMX is locked out.
if until := d.SetPriorityOverride(mq, true); until.IsZero() {
t.Fatal("SetPriorityOverride did not start a window")
}
if !d.ApplyZoneLevels(mq, map[int]byte{1: 50}) {
t.Fatal("override did not take control from DMX")
}
time.Sleep(10 * time.Millisecond) // Outlive the MQTT source's own hold.
if d.ApplyZoneLevels(dmx, map[int]byte{1: 0}) {
t.Error("expected DMX to be locked out during the override")
}
if tg := d.ZoneTargets(); tg[0] != 50 {
t.Errorf("zone 1 = %d, want 50 (DMX release must not black out the zone)", tg[0])
}
// The window closes on its own and DMX regains control.
time.Sleep(130 * time.Millisecond)
if active, _ := d.PriorityOverride(mq); active {
t.Error("expected the override window to have closed")
}
if !d.ApplyZoneLevels(dmx, map[int]byte{1: 0}) {
t.Error("expected DMX to regain control once the override expired")
}
}
// TestPriorityOverrideToggleOff verifies an override can be cleared before its
// window closes, and that it stays unavailable when no window length is set.
func TestPriorityOverrideToggleOff(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
dmx := d.RegisterSource("dmx", 10, time.Second, "")
mq := d.RegisterSource("mqtt", 1, time.Millisecond, "")
// Unconfigured: the toggle is a no-op rather than a permanent override.
if until := d.SetPriorityOverride(mq, true); !until.IsZero() {
t.Error("expected no override window without a configured length")
}
d.ConfigurePriorityOverride(mq, 1000, time.Minute)
d.ApplyZoneLevels(dmx, map[int]byte{1: 200})
d.SetPriorityOverride(mq, true)
if !d.ApplyZoneLevels(mq, map[int]byte{1: 50}) {
t.Fatal("override did not take control from DMX")
}
// Toggling off hands control straight back rather than waiting out the window.
d.SetPriorityOverride(mq, false)
if active, _ := d.PriorityOverride(mq); active {
t.Error("expected the override to be off")
}
if d.ApplyZoneLevels(mq, map[int]byte{1: 10}) {
t.Error("expected the MQTT source to be locked out again after clearing the override")
}
}
// TestApplyZoneLevelsRefusedWhenControlDisabled verifies commands are rejected
// (not silently swallowed) while the panel has integration control disabled.
func TestApplyZoneLevelsRefusedWhenControlDisabled(t *testing.T) {
@ -186,6 +260,58 @@ func TestApplyZoneLevelsRefusedWhenControlDisabled(t *testing.T) {
}
}
// TestSetControlEnabledNotifiesSubscribers verifies a source can park and resume
// integration control, that subscribers are notified for both source-driven and
// panel-driven changes (and not for a no-op), and that resuming re-asserts every
// zone.
func TestSetControlEnabledNotifiesSubscribers(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1,
DisableComponent: 74, EnableComponent: 75})
b := d.RegisterSource("s", 0, time.Second, "")
var states []bool
d.OnControlChange(func(enabled bool) { states = append(states, enabled) })
// Parking control stops the bridge driving the panel.
d.SetControlEnabled(false)
if d.ControlEnabled() {
t.Error("expected integration control to be disabled")
}
if d.ApplyZoneLevels(b, map[int]byte{1: 100}) {
t.Error("expected zone levels to be refused while control is disabled")
}
// A repeat of the same state is not a change and must not notify.
d.SetControlEnabled(false)
// Resuming re-asserts every zone so the panel matches our targets again.
d.dataMu.Lock()
d.sendAll = false
d.dataMu.Unlock()
d.SetControlEnabled(true)
if !d.ControlEnabled() {
t.Error("expected integration control to be enabled")
}
d.dataMu.Lock()
resend := d.sendAll
d.dataMu.Unlock()
if !resend {
t.Error("expected a full resend to be armed when control resumes")
}
// The panel's own disable signal reaches subscribers through the same path.
d.handleLine("~DEVICE,1,74,3")
want := []bool{false, true, false}
if len(states) != len(want) {
t.Fatalf("notified %v, want %v", states, want)
}
for i, w := range want {
if states[i] != w {
t.Errorf("notification %d = %v, want %v", i, states[i], w)
}
}
}
// TestControlSignalRequiresConfiguredComponent verifies the enable/disable
// phantom-button interception is off unless a non-zero component is configured.
func TestControlSignalRequiresConfiguredComponent(t *testing.T) {

View file

@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"os"
"runtime/debug"
)
// Flags supplied to the cli.
@ -13,20 +14,47 @@ type Flags struct {
Verbose bool
}
// printVersion emits version and build information to stdout.
func printVersion() {
fmt.Printf("%s: %s (%s)\n", Name, Version, Mode)
if Commit != "" {
fmt.Printf(" commit: %s\n", Commit)
}
if Date != "" {
fmt.Printf(" built: %s\n", Date)
}
// Without build stamps, a module-aware build still records the revision
// it was built from, so fall back to what the toolchain embedded.
if Commit == "" {
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
fmt.Printf(" commit: %s\n", s.Value)
case "vcs.time":
fmt.Printf(" built: %s\n", s.Value)
}
}
}
}
}
// ParseFlags parses the supplied command-line flags.
func (a *App) ParseFlags() {
app.flags = new(Flags)
flag.Usage = func() {
fmt.Printf(serviceName + ": " + serviceDescription + ".\n\nUsage:\n")
fmt.Printf(Name + ": " + Description + ".\n\nUsage:\n")
flag.PrintDefaults()
}
// Print the version and exit when requested.
var printVersion bool
flag.BoolVar(&printVersion, "v", false, "Print version")
var showVersion bool
usage := "Print version information and quit"
flag.BoolVar(&showVersion, "version", false, usage)
flag.BoolVar(&showVersion, "v", false, usage+" (shorthand)")
// Override the configuration path.
usage := "Load configuration from `FILE`"
usage = "Load configuration from `FILE`"
flag.StringVar(&app.flags.ConfigPath, "config", "", usage)
flag.StringVar(&app.flags.ConfigPath, "c", "", usage+" (shorthand)")
@ -42,8 +70,8 @@ func (a *App) ParseFlags() {
flag.Parse()
if printVersion {
fmt.Println(serviceName + ": " + serviceVersion)
if showVersion {
printVersion()
os.Exit(0)
}
}

16
info.go Normal file
View file

@ -0,0 +1,16 @@
package main
// Build identifiers populated at build time via -ldflags. A plain go build
// leaves them at their defaults, so the binary reports itself as a dev build.
var (
Version = "dev"
Commit = ""
Date = ""
Mode = "dev"
)
// Application identifiers used by the CLI and the system service.
const (
Name = "lutron-control"
Description = "Bridges DMX (sACN/Art-Net) and MQTT control to Lutron GRAFIK Eye QS zones over serial or telnet"
)

View file

@ -11,12 +11,6 @@ import (
log "github.com/sirupsen/logrus"
)
const (
serviceName = "lutron-control"
serviceDescription = "Bridges DMX (sACN/Art-Net) and MQTT control to Lutron GRAFIK Eye QS zones over serial or telnet"
serviceVersion = "0.1.0"
)
// App is the global application structure tying together configuration, the
// Lutron devices, and the control sources that drive them.
type App struct {
@ -76,7 +70,7 @@ func main() {
}
log.Infof("%s %s started with %d device(s) and %d source(s)",
serviceName, serviceVersion, len(app.devices), len(app.sources))
Name, Version, len(app.devices), len(app.sources))
// Notify systemd we're ready and begin feeding its watchdog.
daemon.SdNotify(false, daemon.SdNotifyReady)

157
mqtt.go
View file

@ -56,6 +56,16 @@ type MQTTSource struct {
sceneTopic string
sceneSet string
// Priority override switch state, when enabled. overrideExpiry publishes the
// switch back off once the override window closes.
overrideTopic string
overrideSet string
overrideExpiry overrideTimer
// Integration control switch state, when enabled.
controlTopic string
controlSet string
mu sync.Mutex
scene int
sentScene int
@ -85,7 +95,7 @@ func newMQTTSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *MQTT
// otherwise (multi-light setups omit it) fall back to the source name.
availBase := cfg.MQTT.Topic
if availBase == "" {
availBase = serviceName + "/" + mqttSlug(cfg.Name)
availBase = Name + "/" + mqttSlug(cfg.Name)
}
s := &MQTTSource{
@ -97,6 +107,10 @@ func newMQTTSource(cfg *SourceConfig, dev *Device, binding *sourceBinding) *MQTT
monitorPrefix: cfg.MQTT.MonitorPrefix,
sceneTopic: cfg.MQTT.Topic + "/scene",
sceneSet: cfg.MQTT.Topic + "/scene/set",
overrideTopic: cfg.MQTT.Topic + "/override",
overrideSet: cfg.MQTT.Topic + "/override/set",
controlTopic: cfg.MQTT.Topic + "/control",
controlSet: cfg.MQTT.Topic + "/control/set",
scene: -1, // Unknown until reported.
sentScene: -2,
}
@ -171,6 +185,12 @@ func (s *MQTTSource) Start(ctx context.Context) error {
})
}
// Mirror integration control into the switch, so it follows the panel's own
// enable/disable signals and not just commands from here.
if s.cfg.MQTT.IntegrationControl {
s.dev.OnControlChange(func(bool) { s.publishControl() })
}
// Relay the panel's raw monitoring out to MQTT topics, requesting the desired
// monitoring types from the panel first. Enabled when the source configures
// any monitoring types or a family filter.
@ -181,7 +201,7 @@ func (s *MQTTSource) Start(ctx context.Context) error {
clientID := s.cfg.MQTT.ClientID
if clientID == "" {
clientID = fmt.Sprintf("%s-%s-%d", serviceName, s.cfg.Name, os.Getpid())
clientID = fmt.Sprintf("%s-%s-%d", Name, s.cfg.Name, os.Getpid())
}
opts := mqtt.NewClientOptions()
@ -221,6 +241,7 @@ func (s *MQTTSource) Start(ctx context.Context) error {
// Stop disconnects from the broker.
func (s *MQTTSource) Stop() {
s.overrideExpiry.cancel()
if s.client != nil && s.client.IsConnected() {
// Publish offline explicitly: a graceful disconnect doesn't trigger the
// Last Will, so without this the entities would stay "available".
@ -423,6 +444,31 @@ func (s *MQTTSource) setupControls(c mqtt.Client) {
}
}
// Integration control switch. Like the override it carries real state, and it
// also tracks the panel's enable/disable signals.
if s.cfg.MQTT.IntegrationControl {
if token := c.Subscribe(s.controlSet, 0, s.controlHandler); token.Wait() && token.Error() != nil {
log.Errorf("[%s] MQTT integration-control subscribe failed: %s", s.cfg.Name, token.Error())
}
if s.cfg.MQTT.Discovery {
s.publishControlDiscovery()
}
s.publishControl()
}
// Priority override switch. Unlike the other controls it carries real state,
// so its current value is published on connect and again when the window
// closes.
if s.cfg.MQTT.PriorityOverride {
if token := c.Subscribe(s.overrideSet, 0, s.overrideHandler); token.Wait() && token.Error() != nil {
log.Errorf("[%s] MQTT override subscribe failed: %s", s.cfg.Name, token.Error())
}
if s.cfg.MQTT.Discovery {
s.publishOverrideDiscovery()
}
s.publishOverride()
}
// Sequence selector.
if s.cfg.MQTT.Sequence {
set := base + "/sequence/set"
@ -480,6 +526,113 @@ func (s *MQTTSource) lockHandler(scene bool) mqtt.MessageHandler {
}
}
// controlHandler enables or disables integration control from an ON/OFF switch
// command. Off parks the bridge — it stops driving the panel entirely, leaving it
// to its keypads — and on resumes, re-asserting every zone.
func (s *MQTTSource) controlHandler(_ mqtt.Client, msg mqtt.Message) {
on := strings.EqualFold(strings.TrimSpace(string(msg.Payload())), mqttStateOn)
log.Debugf("[%s] MQTT integration control RX %s", s.cfg.Name, msg.Payload())
s.dev.SetControlEnabled(on)
// SetControlEnabled only notifies on a real change, so publish here to confirm
// a no-op command back to Home Assistant as well.
s.publishControl()
}
// publishControl publishes the current state of the integration-control switch.
func (s *MQTTSource) publishControl() {
if s.client == nil || !s.client.IsConnected() {
return
}
state := mqttStateOff
if s.dev.ControlEnabled() {
state = mqttStateOn
}
token := s.client.Publish(s.controlTopic, 0, true, state)
if token.Wait() && token.Error() != nil {
log.Warnf("[%s] MQTT integration-control publish failed: %s", s.cfg.Name, token.Error())
return
}
log.Debugf("[%s] Published integration control %s to %s", s.cfg.Name, state, s.controlTopic)
}
// publishControlDiscovery publishes a Home Assistant switch for integration
// control. It is not optimistic: the bridge owns the state and republishes it
// when the panel's own enable/disable buttons move it.
func (s *MQTTSource) publishControlDiscovery() {
slug := mqttSlug(s.cfg.MQTT.Topic) + "_integration_control"
topic := fmt.Sprintf("%s/switch/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
payload, _ := json.Marshal(map[string]any{
"name": s.cfg.MQTT.DeviceName + " Integration Control",
"unique_id": slug,
"command_topic": s.controlSet,
"state_topic": s.controlTopic,
"payload_on": mqttStateOn,
"payload_off": mqttStateOff,
"availability_topic": s.availabilityTopic,
"payload_available": mqttAvailable,
"payload_not_available": mqttNotAvailable,
"device": s.haDevice(),
})
s.publishConfig(topic, payload, "switch")
}
// overrideHandler toggles this source's temporary priority override from an
// ON/OFF switch command. Turning it on takes control from a live DMX stream for
// the configured window, so the lighting board can be powered off without the
// zones dropping; the switch is published back off once the window closes.
func (s *MQTTSource) overrideHandler(_ mqtt.Client, msg mqtt.Message) {
on := strings.EqualFold(strings.TrimSpace(string(msg.Payload())), mqttStateOn)
log.Debugf("[%s] MQTT override RX %s", s.cfg.Name, msg.Payload())
until := s.dev.SetPriorityOverride(s.binding, on)
if until.IsZero() {
s.overrideExpiry.cancel()
} else {
s.overrideExpiry.arm(time.Until(until)+overrideExpirySlack, s.publishOverride)
}
s.publishOverride()
}
// publishOverride publishes the current state of the priority override switch.
func (s *MQTTSource) publishOverride() {
if s.client == nil || !s.client.IsConnected() {
return
}
state := mqttStateOff
if active, _ := s.dev.PriorityOverride(s.binding); active {
state = mqttStateOn
}
token := s.client.Publish(s.overrideTopic, 0, true, state)
if token.Wait() && token.Error() != nil {
log.Warnf("[%s] MQTT override publish failed: %s", s.cfg.Name, token.Error())
return
}
log.Debugf("[%s] Published override %s to %s", s.cfg.Name, state, s.overrideTopic)
}
// publishOverrideDiscovery publishes a Home Assistant switch for the priority
// override. Unlike the lock switches it is not optimistic: the bridge owns the
// state and flips it back off when the override window closes.
func (s *MQTTSource) publishOverrideDiscovery() {
slug := mqttSlug(s.cfg.MQTT.Topic) + "_priority_override"
topic := fmt.Sprintf("%s/switch/%s/config", s.cfg.MQTT.DiscoveryPrefix, slug)
payload, _ := json.Marshal(map[string]any{
"name": s.cfg.MQTT.DeviceName + " Priority Override",
"unique_id": slug,
"command_topic": s.overrideSet,
"state_topic": s.overrideTopic,
"payload_on": mqttStateOn,
"payload_off": mqttStateOff,
"availability_topic": s.availabilityTopic,
"payload_available": mqttAvailable,
"payload_not_available": mqttNotAvailable,
"device": s.haDevice(),
})
s.publishConfig(topic, payload, "switch")
}
// sequenceHandler sets the sequence state from a select command.
func (s *MQTTSource) sequenceHandler(_ mqtt.Client, msg mqtt.Message) {
choice := strings.TrimSpace(string(msg.Payload()))

55
osc.go
View file

@ -7,6 +7,7 @@ import (
"net"
"strconv"
"strings"
"time"
"github.com/hypebeast/go-osc/osc"
log "github.com/sirupsen/logrus"
@ -25,6 +26,8 @@ import (
// <prefix>/lock/zone i zone lock (0 off, 1 on)
// <prefix>/lock/scene i scene lock (0 off, 1 on)
// <prefix>/sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16)
// <prefix>/override i temporary priority override (0 off, 1 on)
// <prefix>/control i integration control (0 off, 1 on)
//
// Movement and trigger addresses (raise/lower/stop, scene/off) act on receipt and
// ignore their arguments.
@ -35,6 +38,9 @@ type OSCSource struct {
server *osc.Server
conn net.PacketConn
// overrideExpiry streams the override state back out once its window closes.
overrideExpiry overrideTimer
// Monitoring feedback streaming. dests are the resolved destinations the
// panel's reports are sent to; families filters which "~" families to forward
// (empty forwards all); levelFloat picks the zone-level encoding.
@ -96,6 +102,10 @@ func (s *OSCSource) Start(ctx context.Context) error {
if len(s.dests) > 0 {
s.dev.NoteMonitoring(s.cfg.OSC.Monitor.Enable...)
s.dev.OnMonitor(s.streamEvent)
// Integration control is bridge state rather than a panel report, so mirror
// it out separately; a controller's toggle then follows the panel's own
// enable/disable signals.
s.dev.OnControlChange(func(bool) { s.sendControl() })
log.Infof("[%s] Streaming OSC monitoring to %d destination(s)", s.cfg.Name, len(s.dests))
}
@ -117,6 +127,7 @@ func (s *OSCSource) Start(ctx context.Context) error {
// Stop closes the UDP socket, unblocking Serve.
func (s *OSCSource) Stop() {
s.overrideExpiry.cancel()
if s.conn != nil {
s.conn.Close()
}
@ -167,9 +178,53 @@ func (s *OSCSource) route(msg *osc.Message) {
if mode, ok := oscInt(msg); ok {
s.dev.SetSequence(mode)
}
case "override":
on, _ := oscBool(msg)
s.setOverride(on)
case "control":
on, _ := oscBool(msg)
s.dev.SetControlEnabled(on)
// The device only notifies on a real change, so echo here to confirm a
// no-op command back to the controller as well.
s.sendControl()
}
}
// sendControl streams the current integration-control state under
// <prefix>/control, so a controller's toggle follows the panel's own
// enable/disable signals too.
func (s *OSCSource) sendControl() {
state := int32(0)
if s.dev.ControlEnabled() {
state = 1
}
s.send(strings.TrimRight(s.cfg.OSC.Prefix, "/")+"/control", state)
}
// setOverride toggles this source's temporary priority override and streams the
// resulting state back out. Turning it on takes control from a live DMX stream
// for the configured window, so the lighting board can be powered off without the
// zones dropping; the state is streamed again once the window closes.
func (s *OSCSource) setOverride(on bool) {
until := s.dev.SetPriorityOverride(s.binding, on)
if until.IsZero() {
s.overrideExpiry.cancel()
} else {
s.overrideExpiry.arm(time.Until(until)+overrideExpirySlack, s.sendOverride)
}
s.sendOverride()
}
// sendOverride streams the current override state under <prefix>/override, so a
// controller's toggle follows the window closing on its own.
func (s *OSCSource) sendOverride() {
state := int32(0)
if active, _ := s.dev.PriorityOverride(s.binding); active {
state = 1
}
s.send(strings.TrimRight(s.cfg.OSC.Prefix, "/")+"/override", state)
}
// routeZone handles the "/zone/<n>/..." addresses.
func (s *OSCSource) routeZone(parts []string, msg *osc.Message) {
if len(parts) < 3 {

View file

@ -33,11 +33,16 @@ func NewSource(cfg *SourceConfig, dev *Device) (Source, error) {
fade = "00:00"
}
binding := dev.RegisterSource(cfg.Name, cfg.Priority, hold, fade)
// Only MQTT and OSC expose the temporary priority override; it is how control
// is handed over from a DMX console that is about to be powered off.
override := time.Duration(cfg.OverrideSec * float64(time.Second))
switch cfg.Type {
case "mqtt":
dev.ConfigurePriorityOverride(binding, cfg.OverridePriority, override)
return newMQTTSource(cfg, dev, binding), nil
case "osc":
dev.ConfigurePriorityOverride(binding, cfg.OverridePriority, override)
return newOSCSource(cfg, dev, binding), nil
case "sacn":
disp, err := newDMXDispatcher(dev, binding, cfg.SACN.DMXMap)
@ -56,6 +61,40 @@ func NewSource(cfg *SourceConfig, dev *Device) (Source, error) {
}
}
// overrideExpirySlack is added to a priority override's remaining time before the
// expiry notification fires, so the device reads the window as closed rather than
// racing the deadline.
const overrideExpirySlack = 100 * time.Millisecond
// overrideTimer notifies a source when its priority override window closes so the
// source can publish the toggle going back off. Re-arming supersedes the pending
// notification; a callback must therefore report the device's current state
// rather than assume the override ended.
type overrideTimer struct {
mu sync.Mutex
timer *time.Timer
}
// arm schedules fn to run once the window closes, replacing any pending timer.
func (t *overrideTimer) arm(d time.Duration, fn func()) {
t.mu.Lock()
defer t.mu.Unlock()
if t.timer != nil {
t.timer.Stop()
}
t.timer = time.AfterFunc(d, fn)
}
// cancel drops a pending notification.
func (t *overrideTimer) cancel() {
t.mu.Lock()
defer t.mu.Unlock()
if t.timer != nil {
t.timer.Stop()
t.timer = nil
}
}
// dmxBinding maps a DMX channel (0-indexed offset into the universe) to a target
// zone for level control.
type dmxBinding struct {