From b39600f64a82636cef7b0074cf94651a1d91943b Mon Sep 17 00:00:00 2001 From: James Coleman Date: Tue, 25 Aug 2026 21:11:06 -0500 Subject: [PATCH] Add scene-select channels, MQTT override, and release tooling; bump to 0.1.1 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 --- .github/workflows/release.yaml | 31 ++++++ .github/workflows/test_golang.yaml | 24 +++++ .gitignore | 3 + .goreleaser.yaml | 74 +++++++++++++ Makefile | 39 +++++++ README.md | 62 ++++++++++- VERSION | 1 + artnet.go | 2 +- config.example.yaml | 36 ++++++- config.go | 48 +++++++-- config_test.go | 25 +++++ device.go | 162 ++++++++++++++++++++++++++--- device_test.go | 126 ++++++++++++++++++++++ flags.go | 40 +++++-- info.go | 16 +++ main.go | 8 +- mqtt.go | 157 +++++++++++++++++++++++++++- osc.go | 55 ++++++++++ source.go | 39 +++++++ 19 files changed, 909 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test_golang.yaml create mode 100644 .goreleaser.yaml create mode 100644 Makefile create mode 100644 VERSION create mode 100644 info.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9527d4f --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 }} diff --git a/.github/workflows/test_golang.yaml b/.github/workflows/test_golang.yaml new file mode 100644 index 0000000..0f83376 --- /dev/null +++ b/.github/workflows/test_golang.yaml @@ -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 ./... diff --git a/.gitignore b/.gitignore index a1366a2..f6c5a95 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ config.yaml # Logs. *.log *.log.gz + +# Build artifacts. +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..ff3b509 --- /dev/null +++ b/.goreleaser.yaml @@ -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:" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bb31c7a --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index d7f5b92..682bae2 100644 --- a/README.md +++ b/README.md @@ -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 `/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 /control/set, state on /control. +``` + +| | | +| --- | --- | +| MQTT | switch on `/control/set`, state published to `/control` (a Home Assistant switch with discovery) | +| OSC | `/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`: | `/lock/zone` | `i` | zone lock (0 off, 1 on) | | `/lock/scene` | `i` | scene lock (0 off, 1 on) | | `/sequence` | `i` | sequence (0 off, 1 scenes 1–4, 2 scenes 5–16) | +| `/override` | `i` | temporary priority override (0 off, 1 on) — see [Arbitration](#arbitration) | +| `/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 — `/zone//level`, `/scene`, `/group//occupancy` — and anything else falls back to `/monitor//`. `osc.level_as_float` (default true) - picks the zone-level encoding (0–1 float or 0–255 int). + picks the zone-level encoding (0–1 float or 0–255 int). The priority override + and integration control are bridge state rather than panel reports, so they are + echoed to the same destinations under `/override` and `/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 `///` (retained, `monitor_prefix` defaults to `monitor`). These are raw state topics for automations; no Home Assistant discovery is published for them. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/artnet.go b/artnet.go index 79fb910..13447e5 100644 --- a/artnet.go +++ b/artnet.go @@ -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) { diff --git a/config.example.yaml b/config.example.yaml index f12ffc4..b962088 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 + # (/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 /scene_lock/set (QS Standalone). sequence: false # Sequence select (Off / Scenes 1-4 / Scenes 5-16) # on /sequence/set (QS Standalone). + integration_control: false # Switch on /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 /control and follows the + # panel's own enable/disable buttons. + priority_override: false # Switch on /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 /override and flips back off by itself + # when the window closes. # Monitoring relay. Publishes the panel's "~" reports to # /// for use in automations @@ -196,6 +217,12 @@ sources: # /lock/zone i zone lock (0 off, 1 on) # /lock/scene i scene lock (0 off, 1 on) # /sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16) + # /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 + # /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: # /zone//level f|i reported zone level # /scene i reported active scene # /group//occupancy i occupancy state (3 occupied, 4 unoccupied) + # /override i override state, resent when the window closes + # /control i integration control state, resent when the + # panel's own enable/disable buttons move it # /monitor// 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. diff --git a/config.go b/config.go index 4999b23..58b2730 100644 --- a/config.go +++ b/config.go @@ -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 + // /control/set and the state is published to /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 + // /override/set and the state is published to /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)) diff --git a/config_test.go b/config_test.go index e7f273d..3b7237a 100644 --- a/config_test.go +++ b/config_test.go @@ -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) { diff --git a/device.go b/device.go index d29bcbc..3bd3f3d 100644 --- a/device.go +++ b/device.go @@ -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) } diff --git a/device_test.go b/device_test.go index 1fd16f1..76d76fb 100644 --- a/device_test.go +++ b/device_test.go @@ -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) { diff --git a/flags.go b/flags.go index 45272a7..cac52ab 100644 --- a/flags.go +++ b/flags.go @@ -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) } } diff --git a/info.go b/info.go new file mode 100644 index 0000000..062e46d --- /dev/null +++ b/info.go @@ -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" +) diff --git a/main.go b/main.go index ac8fc11..338af96 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/mqtt.go b/mqtt.go index 9e68ab6..63b055d 100644 --- a/mqtt.go +++ b/mqtt.go @@ -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())) diff --git a/osc.go b/osc.go index ff6d32f..759f1d0 100644 --- a/osc.go +++ b/osc.go @@ -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 ( // /lock/zone i zone lock (0 off, 1 on) // /lock/scene i scene lock (0 off, 1 on) // /sequence i sequence (0 off, 1 scenes 1-4, 2 scenes 5-16) +// /override i temporary priority override (0 off, 1 on) +// /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 +// /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 /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//..." addresses. func (s *OSCSource) routeZone(parts []string, msg *osc.Message) { if len(parts) < 3 { diff --git a/source.go b/source.go index aba9121..a2e81e9 100644 --- a/source.go +++ b/source.go @@ -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 {