diff --git a/CLAUDE.md b/CLAUDE.md index 3d9fa89..59949f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,16 +108,26 @@ The VM also cycles `'1'`–`'5'` to retrieve scope/spectrum sample buffers into bytes 40..63 of the response. v1 ignores those modes; the on-meter LCD is the only display. -**Firmware quirk — per-channel verbs in auto-channel mode:** `range_step` -(F3) and `alarm_toggle` (F4) are per-channel settings. When the meter is -in auto-channel mode (IN byte 4 == 0), the firmware silently ignores -these presses — there is no single "current channel" to mutate. The hub -intercepts these in `lpmeter.VerbAvailableInState` and NACKs with a -reason instead of writing a guaranteed-no-op OUT report; clients should -prompt the user to `channel_step` to a manual channel (CH1–4) first. -Confirmed empirically on 2026-05-09 by sending each verb 5× in auto-ch -mode and observing no change to offsets 6 / 7. `mode_step`, `channel_step`, -`peak_toggle`, `setup`, `freeze` all work in auto-ch. +**Per-channel verbs in auto-channel mode (NOT a firmware quirk — +correcting an earlier misdiagnosis):** `range_step` (F3) and +`alarm_toggle` (F4) are per-channel settings. Each of CH1..CH4 +remembers its own range and alarm-enabled state. In auto-channel +mode, F3/F4 on the meter's front panel mutate the **auto-locked +channel's** setting — i.e. whichever channel auto-detection +currently has the signal locked to. + +An earlier probe (2026-05-09) sent 6 `range_step` writes in auto- +channel and saw the *displayed* range stay at 2.5K, which was read +as "F3 is a no-op in auto-channel mode" and a server-side NACK +was added. That observation was misleading: each press DID step +the auto-locked channel's range, but auto-channel kept re-locking +to a different channel between presses, and the displayed range +is always whichever channel is currently locked. Confirmed wrong +on the bench 2026-05-16 by VU2CPL — pressing F3 on the meter in +pwr/swr + auto-channel does visibly cycle the range. + +The NACK has been removed; `VerbAvailableInState` is kept as a +hook in case a real per-state firmware quirk surfaces later. ### IN-report decode diff --git a/internal/lpmeter/decode.go b/internal/lpmeter/decode.go index 8bcb030..40b477d 100644 --- a/internal/lpmeter/decode.go +++ b/internal/lpmeter/decode.go @@ -59,19 +59,23 @@ const ( OffsetTopMode = 3 // 0=Power/SWR, 1=Waveform, 2=Spectrum, 3=Setup OffsetChannel = 4 // 0=Auto, 1..4=CH1..CH4 OffsetChannelAuto = 5 // when channel==0 (Auto), the physical channel auto is locked to (1..4) - // OffsetRange: byte 6, 0..10 = 5W..10KW, 11 = Auto. Per-channel; the - // F3 (Range) press is a no-op when byte 4 == 0 (auto-channel), same - // gating as the alarm byte at offset 7 — see VerbAvailableInState. - OffsetRange = 6 + // OffsetRange: byte 6, 0..10 = 5W..10KW, 11 = Auto. Per-channel — + // each of CH1..CH4 carries its own range setting, so in auto- + // channel mode the displayed range is whichever channel auto- + // detection has currently locked to. F3 (Range) on the meter's + // front panel cycles the auto-locked channel's range; the + // displayed value may appear unstable simply because auto- + // channel keeps re-locking between presses. (An earlier + // investigation misread this as "F3 is no-op in auto-channel".) + OffsetRange = 6 // OffsetAlarm: byte 7 is an alarm-DISABLED flag (inverted polarity). // 0x00 = alarm armed on the LCD, 0x01 = alarm off. The DataLogger // VB6 source labelled it "Alarm State" without specifying polarity; - // confirmed inverted on 2026-05-08 by toggling F4 in CH1 manual mode - // (F4 has no effect on Ch-Auto on this firmware) while logging - // DEBUG. The decoder negates the byte before populating + // confirmed inverted on 2026-05-08 by toggling F4 in CH1 manual + // mode. The decoder negates the byte before populating // AlarmEnabled, so the JSON/UI layer keeps a positive // "alarm_enabled" semantic. - OffsetAlarm = 7 + OffsetAlarm = 7 OffsetPeakAvg = 8 // 0=peak_hold, 1=average, 2=tune OffsetAlarmSet = 9 // alarm setpoint index (encoding TBD) OffsetPeakPwrHi = 23 // 16-bit BE × 0.2 W — *live* envelope peak this poll cycle @@ -280,26 +284,22 @@ var KnownVerbs = map[string]bool{ // in the given state, or a reason string explaining why it will be // silently ignored. // -// LP-500/700 firmware quirk: F3 (Range) and F4 (Alarm) are per-channel -// settings. When the meter is in auto-channel mode (IN byte 4 == 0, -// snap.AutoChannel == true), the firmware ignores both presses — there -// is no single "current channel" to mutate. Confirmed empirically on -// 2026-05-09 by sending range_step / alarm_toggle 5× each in auto-ch -// mode and observing no change in offsets 6 / 7. The F4 case was -// already noted at decode.go OffsetAlarm; F3 has the same gating. +// Currently no verbs are gated. An earlier version of this function +// NACKed range_step / alarm_toggle in auto-channel mode based on a +// probe that observed "range doesn't change after 6 presses" — but +// that observation was actually the auto-channel detector re-locking +// to a different channel between presses (each channel has its own +// range, so the displayed range is whichever channel auto-locked to +// at that moment). On the meter's physical front panel, F3 in +// pwr/swr + auto-channel does cycle the auto-locked channel's range, +// and the firmware accepts the verb. Confirmed on the bench +// 2026-05-16 by VU2CPL. // -// snap may be nil (no telemetry observed yet on this connection), in -// which case we let the verb through and let the firmware decide. +// Kept as a hook in case a future quirk requires per-state gating. +// snap may be nil (no telemetry observed yet on this connection). func VerbAvailableInState(verb string, snap *Snapshot) string { - if snap == nil { - return "" - } - switch verb { - case "range_step", "alarm_toggle": - if snap.AutoChannel { - return verb + " is ignored by firmware while in auto-channel mode; channel_step to a manual channel (CH1–4) first" - } - } + _ = snap + _ = verb return "" } diff --git a/internal/lpmeter/decode_test.go b/internal/lpmeter/decode_test.go index 2a9d604..414e172 100644 --- a/internal/lpmeter/decode_test.go +++ b/internal/lpmeter/decode_test.go @@ -248,36 +248,27 @@ func TestPollReportShape(t *testing.T) { } func TestVerbAvailableInState(t *testing.T) { - tests := []struct { - name string - verb string - snap *Snapshot - wantBlock bool + // All verbs are currently always allowed; the function is kept as + // a future hook in case a real firmware quirk surfaces. (An earlier + // version gated range_step / alarm_toggle in auto-channel based on + // a misread probe; corrected on 2026-05-16 — see decode.go doc.) + cases := []struct { + verb string + snap *Snapshot }{ - {"nil snap lets anything through", "range_step", nil, false}, - {"range_step OK in manual channel", "range_step", - &Snapshot{AutoChannel: false, Channel: 2}, false}, - {"range_step blocked in auto-channel", "range_step", - &Snapshot{AutoChannel: true, Channel: 1}, true}, - {"alarm_toggle blocked in auto-channel", "alarm_toggle", - &Snapshot{AutoChannel: true, Channel: 1}, true}, - {"alarm_toggle OK in manual channel", "alarm_toggle", - &Snapshot{AutoChannel: false, Channel: 3}, false}, - {"channel_step always allowed", "channel_step", - &Snapshot{AutoChannel: true, Channel: 1}, false}, - {"mode_step always allowed", "mode_step", - &Snapshot{AutoChannel: true, Channel: 1}, false}, - {"peak_toggle always allowed (works in auto-ch)", "peak_toggle", - &Snapshot{AutoChannel: true, Channel: 1}, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := VerbAvailableInState(tt.verb, tt.snap) - if tt.wantBlock && got == "" { - t.Errorf("expected block reason, got empty") - } - if !tt.wantBlock && got != "" { - t.Errorf("expected no block, got %q", got) + {"range_step", nil}, + {"range_step", &Snapshot{AutoChannel: true, Channel: 1}}, + {"range_step", &Snapshot{AutoChannel: false, Channel: 2}}, + {"alarm_toggle", &Snapshot{AutoChannel: true, Channel: 1}}, + {"alarm_toggle", &Snapshot{AutoChannel: false, Channel: 3}}, + {"channel_step", &Snapshot{AutoChannel: true, Channel: 1}}, + {"mode_step", &Snapshot{AutoChannel: true, Channel: 1}}, + {"peak_toggle", &Snapshot{AutoChannel: true, Channel: 1}}, + } + for _, tt := range cases { + t.Run(tt.verb, func(t *testing.T) { + if got := VerbAvailableInState(tt.verb, tt.snap); got != "" { + t.Errorf("VerbAvailableInState(%q, %+v) = %q, want \"\"", tt.verb, tt.snap, got) } }) }