Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 26 additions & 26 deletions internal/lpmeter/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ""
}

Expand Down
49 changes: 20 additions & 29 deletions internal/lpmeter/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
Loading