lutron-control/device_test.go
2026-06-28 08:32:22 -05:00

420 lines
14 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)
}
}
// 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])
}
}
// 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
}