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 }