lutron-control/device_test.go
James Coleman b39600f64a
Some checks failed
Go package / build (push) Has been cancelled
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
2026-08-25 21:15:33 -05:00

546 lines
19 KiB
Go

package main
import (
"context"
"io"
"strings"
"sync"
"testing"
"time"
)
// fakeTransport records the time and payload of each write for pacing and
// content assertions.
type fakeTransport struct {
mu sync.Mutex
writes []time.Time
payloads []string
closed chan struct{}
}
func newFakeTransport() *fakeTransport {
return &fakeTransport{closed: make(chan struct{})}
}
func (f *fakeTransport) Write(p []byte) (int, error) {
f.mu.Lock()
f.writes = append(f.writes, time.Now())
f.payloads = append(f.payloads, string(p))
f.mu.Unlock()
return len(p), nil
}
// sent returns a snapshot of the payloads written so far.
func (f *fakeTransport) sent() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.payloads...)
}
func (f *fakeTransport) Read(p []byte) (int, error) {
<-f.closed
return 0, io.EOF
}
func (f *fakeTransport) Close() error {
close(f.closed)
return nil
}
// TestWritePacing verifies that consecutive zone-level sets burst together while
// queries and differing commands still honor the protocol's minimum inter-message
// delays: at least 100 ms after a command ("#") and 1500 ms after a query ("?").
func TestWritePacing(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
d.ctx = context.Background()
ft := newFakeTransport()
d.conn = ft
// Zone-value sets burst with no delay so every zone changes at once.
d.write("#DEVICE,1,1,14,50.00,00:00\r\n") // w0
d.write("#DEVICE,1,2,14,50.00,00:00\r\n") // w1: bursts after w0
// A zone query does not burst: the protocol mandates query spacing, so it must
// wait at least the command delay owed by the preceding set.
d.write("?DEVICE,1,1,14\r\n") // w2
// A differing command after a zone query must wait the longer query delay.
d.write("#MONITORING,5,1\r\n") // w3
// A second, differing command must wait the command delay.
d.write("#MONITORING,11,1\r\n") // w4
ft.mu.Lock()
writes := ft.writes
ft.mu.Unlock()
if len(writes) != 5 {
t.Fatalf("expected 5 writes, got %d", len(writes))
}
if gap := writes[1].Sub(writes[0]); gap >= commandInterMessageDelay {
t.Errorf("zone set->set should burst, gap %v >= %v", gap, commandInterMessageDelay)
}
if gap := writes[2].Sub(writes[1]); gap < commandInterMessageDelay {
t.Errorf("zone set->query must not burst, gap %v < %v", gap, commandInterMessageDelay)
}
if gap := writes[3].Sub(writes[2]); gap < queryInterMessageDelay {
t.Errorf("query->command gap %v < %v", gap, queryInterMessageDelay)
}
if gap := writes[4].Sub(writes[3]); gap < commandInterMessageDelay {
t.Errorf("command->command gap %v < %v", gap, commandInterMessageDelay)
}
}
// TestSolicitedReplyDoesNotOverrideTarget verifies a reply to our own zone query
// (e.g. the watchdog liveness probe) does not pull the maintained target to the
// reported level, even when it disagrees.
func TestSolicitedReplyDoesNotOverrideTarget(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
b := d.RegisterSource("s", 0, 5*time.Second, "")
// Source drives zone 1 to ~55%; a watchdog-style query then gets an "off" reply.
if !d.ApplyZoneLevels(b, map[int]byte{1: 140}) {
t.Fatal("ApplyZoneLevels returned false")
}
d.queryZone(1)
d.handleLine("~DEVICE,1,1,14,0.00")
if tg := d.ZoneTargets(); tg[0] != 140 {
t.Errorf("zone 1 target = %d, want 140 (solicited reply must not override)", tg[0])
}
}
// TestUnsolicitedReportFollowed verifies an unsolicited monitoring report (an
// external keypad or scene change) is adopted as the new target.
func TestUnsolicitedReportFollowed(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
d.RegisterSource("s", 0, time.Second, "")
d.handleLine("~DEVICE,1,2,14,100.00")
if tg := d.ZoneTargets(); tg[1] != 255 {
t.Errorf("zone 2 target = %d, want 255 (followed external change)", tg[1])
}
}
// TestEchoOfOwnWriteNotAdopted verifies a report arriving just after we wrote a
// zone is treated as our own echo and does not move the target, even though the
// panel quantized the reported level to a value that differs from what we sent.
func TestEchoOfOwnWriteNotAdopted(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
b := d.RegisterSource("s", 0, 5*time.Second, "")
// Source drives zone 1 to byte 7 (~2.75%); we then "write" it and the panel
// echoes back its own rounding (3.14% = byte 8).
if !d.ApplyZoneLevels(b, map[int]byte{1: 7}) {
t.Fatal("ApplyZoneLevels returned false")
}
d.lastZoneWrite[0] = time.Now()
d.handleLine("~DEVICE,1,1,14,3.14")
if tg := d.ZoneTargets(); tg[0] != 7 {
t.Errorf("zone 1 target = %d, want 7 (echo of our write must not override)", tg[0])
}
}
// TestApplyZoneLevelsTouchesOnlyMappedZones verifies a source only drives the
// zones it names, leaving the rest for another source to own.
func TestApplyZoneLevelsTouchesOnlyMappedZones(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
b := d.RegisterSource("s", 0, time.Second, "")
if !d.ApplyZoneLevels(b, map[int]byte{1: 100, 2: 100, 3: 100}) {
t.Fatal("ApplyZoneLevels returned false")
}
tg := d.ZoneTargets()
for z := 1; z <= 3; z++ {
if tg[z-1] != 100 {
t.Errorf("zone %d = %d, want 100", z, tg[z-1])
}
}
for z := 4; z <= 6; z++ {
if tg[z-1] != 0 {
t.Errorf("unmapped zone %d = %d, want untouched 0", z, tg[z-1])
}
}
// Driving the upper zones must not disturb the lower ones.
if !d.ApplyZoneLevels(b, map[int]byte{4: 50, 5: 50, 6: 50}) {
t.Fatal("ApplyZoneLevels returned false")
}
tg = d.ZoneTargets()
if tg[0] != 100 || tg[5] != 50 {
t.Errorf("zones = %v, want zone1=100 zone6=50", tg)
}
}
// 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) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6})
b := d.RegisterSource("s", 0, time.Second, "")
d.controlDisabled = true
if d.ApplyZoneLevels(b, map[int]byte{1: 100}) {
t.Error("expected ApplyZoneLevels to return false while control is disabled")
}
if tg := d.ZoneTargets(); tg[0] != 0 {
t.Errorf("zone 1 = %d, want unchanged 0", tg[0])
}
}
// 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) {
// Unconfigured (component 0): a matching-looking press is ignored.
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
d.handleLine("~DEVICE,1,0,3")
if d.controlDisabled {
t.Error("control disabled with no component configured")
}
// Configured: the disable press disables and the enable press re-enables.
d = NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1,
DisableComponent: 74, EnableComponent: 75})
d.handleLine("~DEVICE,1,74,3")
if !d.controlDisabled {
t.Error("expected control disabled after configured disable press")
}
d.handleLine("~DEVICE,1,75,3")
if d.controlDisabled {
t.Error("expected control re-enabled after configured enable press")
}
}
// TestBuildDMXMapSequential verifies the sequential layout lays the zones out
// contiguously from the start address, then a single scene-select channel.
func TestBuildDMXMapSequential(t *testing.T) {
zones, scenes, err := buildDMXMap(DMXMap{StartAddress: 2, Zones: 3, Scenes: 4}, 6)
if err != nil {
t.Fatal(err)
}
wantZones := []dmxBinding{{2, 1}, {3, 2}, {4, 3}}
wantScenes := []sceneSelectBinding{{channel: 5, maxScene: 4}}
if len(zones) != 3 || zones[0] != wantZones[0] || zones[2] != wantZones[2] {
t.Errorf("zones = %v, want %v", zones, wantZones)
}
if len(scenes) != 1 || scenes[0] != wantScenes[0] {
t.Errorf("scenes = %v, want %v", scenes, wantScenes)
}
}
// TestBuildDMXMapExplicit verifies an explicit channel map (1-indexed addresses)
// and that an out-of-range zone is rejected.
func TestBuildDMXMapExplicit(t *testing.T) {
zones, scenes, err := buildDMXMap(DMXMap{Channels: []DMXChannelConfig{
{Channel: 1, Type: "zone", Zone: 1},
{Channel: 7, Type: "scene"},
}}, 6)
if err != nil {
t.Fatal(err)
}
if len(zones) != 1 || zones[0] != (dmxBinding{0, 1}) {
t.Errorf("zones = %v, want [{0 1}]", zones)
}
if len(scenes) != 1 || scenes[0] != (sceneSelectBinding{channel: 6, maxScene: qseMaxScene}) {
t.Errorf("scenes = %v, want [{6 %d}]", scenes, qseMaxScene)
}
if _, _, err := buildDMXMap(DMXMap{Channels: []DMXChannelConfig{
{Channel: 1, Type: "zone", Zone: 99},
}}, 6); err == nil {
t.Error("expected error for out-of-range zone")
}
}
// containsSubstr reports whether any payload contains sub.
func containsSubstr(payloads []string, sub string) bool {
for _, p := range payloads {
if strings.Contains(p, sub) {
return true
}
}
return false
}
// TestPerSourceFade verifies a source's configured fade is used for its zone
// writes instead of the device default.
func TestPerSourceFade(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
d.ctx = context.Background()
ft := newFakeTransport()
d.conn = ft
b := d.RegisterSource("s", 0, time.Second, "00:04")
if !d.ApplyZoneLevels(b, map[int]byte{1: 255}) {
t.Fatal("ApplyZoneLevels returned false")
}
d.flushZones()
if !containsSubstr(ft.sent(), "#DEVICE,1,1,14,100.00,00:04") {
t.Errorf("expected zone write with source fade 00:04, got %v", ft.sent())
}
}
// TestRaiseSuppressesZoneWrites verifies that while a zone is being raised the
// writer leaves it alone, and that a stop holds the zone through its grace window
// so the writer can't yank it back to a stale target.
func TestRaiseSuppressesZoneWrites(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1, Fade: "00:00"})
d.ctx = context.Background()
ft := newFakeTransport()
d.conn = ft
b := d.RegisterSource("s", 0, time.Second, "")
// Establish a known sent level for zone 1.
d.ApplyZoneLevels(b, map[int]byte{1: 200})
d.flushZones()
// Begin raising zone 1, then create a target/sent divergence the writer would
// normally flush.
if !d.RaiseZone(b, 1) {
t.Fatal("RaiseZone returned false")
}
if !containsSubstr(ft.sent(), "#DEVICE,1,1,18") {
t.Errorf("expected a start-raising command, got %v", ft.sent())
}
d.dataMu.Lock()
d.target[0] = 10
d.dataMu.Unlock()
before := len(ft.sent())
d.flushZones()
if got := len(ft.sent()); got != before {
t.Errorf("writer wrote %d commands while ramping; expected none", got-before)
}
// Stop holds the zone through the grace window.
if !d.StopZone(b, 1) {
t.Fatal("StopZone returned false")
}
if !containsSubstr(ft.sent(), "#DEVICE,1,1,20") {
t.Errorf("expected a stop command, got %v", ft.sent())
}
before = len(ft.sent())
d.flushZones()
if got := len(ft.sent()); got != before {
t.Errorf("writer wrote %d commands during stop grace; expected none", got-before)
}
}
// TestShadeAndSequenceCommands verifies the shade and sequence helpers emit the
// expected integration commands.
func TestShadeAndSequenceCommands(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 2})
d.ctx = context.Background()
ft := newFakeTransport()
d.conn = ft
b := d.RegisterSource("s", 0, time.Second, "")
// Shade column 1 open is a press then release on component 38.
if !d.ApplyShade(b, 1, "open") {
t.Fatal("ApplyShade open returned false")
}
if !containsSubstr(ft.sent(), "#DEVICE,2,38,3") || !containsSubstr(ft.sent(), "#DEVICE,2,38,4") {
t.Errorf("expected press+release on shade component 38, got %v", ft.sent())
}
// Sequence through scenes 1-4 is scene controller action 17 with param 1.
if !d.SetSequence(1) {
t.Fatal("SetSequence returned false")
}
if !containsSubstr(ft.sent(), "#DEVICE,2,141,17,1") {
t.Errorf("expected sequence command, got %v", ft.sent())
}
if d.SetSequence(9) {
t.Error("SetSequence accepted an out-of-range mode")
}
}
// TestMonitorFanOut verifies that every "~" report is fanned out to monitor
// subscribers, parsed into a family and fields, including families the device
// does not model itself (e.g. ~GROUP occupancy and ~ERROR).
func TestMonitorFanOut(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
d.RegisterSource("s", 0, time.Second, "")
var got []MonitorEvent
d.OnMonitor(func(ev MonitorEvent) { got = append(got, ev) })
d.handleLine("~DEVICE,1,2,14,100.00") // Zone level.
d.handleLine("~GROUP,1,3,3") // Occupancy: group 1 occupied.
d.handleLine("~ERROR,4") // Error report.
d.handleLine("QSE>") // Prompt only: must not fan out.
if len(got) != 3 {
t.Fatalf("got %d events, want 3: %+v", len(got), got)
}
if got[0].Family != "DEVICE" || len(got[0].Fields) != 4 || got[0].Fields[3] != "100.00" {
t.Errorf("device event mismatch: %+v", got[0])
}
if got[1].Family != "GROUP" || got[1].Fields[2] != "3" {
t.Errorf("group event mismatch: %+v", got[1])
}
if got[2].Family != "ERROR" || got[2].Raw != "~ERROR,4" {
t.Errorf("error event mismatch: %+v", got[2])
}
}
// TestSetupMonitoringEnablesRequestedUnion verifies that source-requested
// monitoring types are enabled on connect alongside the built-in zone/reply
// defaults, deduped against them.
func TestSetupMonitoringEnablesRequestedUnion(t *testing.T) {
d := NewDevice(&DeviceConfig{Name: "test", Zones: 6, IntegrationID: 1})
d.ctx = context.Background()
ft := newFakeTransport()
d.conn = ft
// Request button (3) and zone (5) monitoring; zone is also a built-in default.
d.NoteMonitoring(qseMonitorZone, 3)
d.setupMonitoring()
sent := ft.sent()
if !containsSubstr(sent, "#MONITORING,5,1") || !containsSubstr(sent, "#MONITORING,11,1") {
t.Errorf("expected built-in zone+reply monitoring enabled, got %v", sent)
}
if !containsSubstr(sent, "#MONITORING,3,1") {
t.Errorf("expected requested button monitoring enabled, got %v", sent)
}
// Zone (5) must be enabled exactly once despite being both built-in and requested.
if n := countSubstr(sent, "#MONITORING,5,1"); n != 1 {
t.Errorf("zone monitoring enabled %d times, want 1: %v", n, sent)
}
}
// countSubstr counts how many payloads contain sub.
func countSubstr(payloads []string, sub string) int {
n := 0
for _, p := range payloads {
if strings.Contains(p, sub) {
n++
}
}
return n
}