Add scene-select channels, MQTT override, and release tooling; bump to 0.1.1
Some checks failed
Go package / build (push) Has been cancelled
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:
parent
a61031f5ef
commit
b39600f64a
19 changed files with 909 additions and 39 deletions
31
.github/workflows/release.yaml
vendored
Normal file
31
.github/workflows/release.yaml
vendored
Normal 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
24
.github/workflows/test_golang.yaml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ config.yaml
|
|||
# Logs.
|
||||
*.log
|
||||
*.log.gz
|
||||
|
||||
# Build artifacts.
|
||||
dist/
|
||||
|
|
|
|||
74
.goreleaser.yaml
Normal file
74
.goreleaser.yaml
Normal 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
39
Makefile
Normal 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
|
||||
62
README.md
62
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 `<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 1–4, 2 scenes 5–16) |
|
||||
| `<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 (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 `<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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.1
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
48
config.go
48
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
|
||||
// <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))
|
||||
|
|
|
|||
|
|
@ -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
162
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)
|
||||
}
|
||||
|
|
|
|||
126
device_test.go
126
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) {
|
||||
|
|
|
|||
40
flags.go
40
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
info.go
Normal file
16
info.go
Normal 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"
|
||||
)
|
||||
8
main.go
8
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)
|
||||
|
|
|
|||
157
mqtt.go
157
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()))
|
||||
|
|
|
|||
55
osc.go
55
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 (
|
|||
// <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 {
|
||||
|
|
|
|||
39
source.go
39
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue