420 lines
14 KiB
Go
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
|
|
}
|