From 43a024779a000b1821a714a43fcbff37e274cbb0 Mon Sep 17 00:00:00 2001 From: Manoj Kumar R Date: Sat, 16 May 2026 11:04:32 +0530 Subject: [PATCH] hub: assemble scope and spectrum WebSocket frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the meter is on the waveform or spectrum LCD page, the firmware populates a 320-byte display buffer that we used to ignore. The HID owner now interleaves a 6-tick cycle (cmd '0','1','2','3','4','5') during those modes, assembles the 5 sample segments into one SampleBufferSize-byte buffer, and emits it on a new channel that the hub broadcasts as `{"type":"scope"}` or `{"type":"spectrum"}` over the existing /ws endpoint. In power_swr / setup modes the legacy poll cycle (cmd '0' every tick, cmd '6' every 10th) is preserved — no overhead is paid on the modes that don't use it. The wire format was reverse-engineered with a new `probe -samples` subcommand that drives the meter through known channel/range/top_mode states, dumps the 64-byte IN response to each sample cmd, and dedups identical consecutive frames so the output stays readable. Two firmware quirks fell out of this: - F2 (channel) and F3 (range) are no-ops when the meter is on the waveform or spectrum LCD page; the probe now mode_steps to power_swr before driving channel/range. (Already documented in CLAUDE.md for F4 in auto-channel; this extends the same gating to F2/F3 in waveform/spectrum.) - In sample modes the firmware repurposes the WHOLE 64-byte IN frame as sample data — not just the bytes-40..63 "secondary slot" abstraction that holds for telemetry/status frames. Samples are 8-bit unsigned, normalized for LCD display height (the firmware auto-scales each trace, mirroring the Teensy reference's `map(plot, 0, PLOT_CENTER * f, 0, PLOT_CENTER - 50)` logic). They describe the *shape* of the envelope / FFT spectrum, not absolute watts — for power readings clients use the matching telemetry frame. JSON encoding uses a custom SampleBytes type with MarshalJSON so the 320-element field marshals as a decimal int array (e.g. `[151,151,0,8,...]`) rather than Go's default base64 string for []byte. Clients deserialize this as `[Int]` directly. # Frame routing is by SHAPE, not by OUT-write order The first iteration of this work used a FIFO of (write-cmd → expected- response) to attribute each IN frame to the OUT cmd it answered. That approach desyncs on any single missed event (stale kernel- buffered frame at HID open, an unsolicited firmware frame on mode change, etc.) and the misalignment then cascades — sample bytes leak into the telemetry decoder, producing garbage WS frames with nonsensical SWR / channel / status-message values that compound into top_mode flapping. Field reports of impossible values (`swr: 20.52`, `peak < avg`, channel/peak_mode/top_mode flapping without commands, ASCII-leaked status garbage like `?BFILORUY|_bfilorvy|`) confirmed this in production. The new routing classifies each frame on its own merits, with no per-write state that can desync: 1. ECHO — byte[0] in '0'..'?' AND every other byte zero. Firmware refused the OUT (wrong LCD page or no-op in current state). Dropped. 2. TELEMETRY — passes a tight byte-range structural check (isLikelyTelemetry in owner.go): top_mode ≤ 3, channel ≤ 4, channel-auto ≤ 4, range ≤ 11, alarm-disabled flag ≤ 1, peak-mode ≤ 2, plus two power-coherence invariants the firmware guarantees (peak_power ≥ avg_power; SWR raw ≤ 1000 = SWR 10). Random sample data passes all eight with probability < 10⁻¹². Decoded as a Snapshot and broadcast. 3. SAMPLE — everything else. In waveform/spectrum mode this is the next segment of the 5×64-byte buffer; assemble in arrival order (the 1:1 firmware response keeps order aligned with our 6-tick cycle). A sample counter resets on every telemetry frame and on every top-mode change, so transient desync self- corrects within one cycle. Plus three smaller fixes the LP-700-App-side post-mortem called out: - Scope/spectrum emit is now gated on `channel ∈ {1..4}` AND `!auto_channel`. Auto-channel × waveform/spectrum is a hardware- invalid combination on the LP-700; the sample buffers in that state are indeterminate. - extractStatusMessage now requires BOTH an ASCII space AND a 3-letter ASCII run. The old ≥75 % printable threshold passed sample-byte leakage like `?BFILORUY|_bfilorvy|` (FFT bin values that happen to be in printable-ASCII range). Real LP-700 status messages are English phrases. - probe -samples mode_steps to power_swr first when -channel / -range flags are set, so F2/F3 control writes take effect instead of being no-op'd by the firmware's per-page gating. Verified on the live LP-700: - probe -samples confirmed cmds '1'..'5' return contiguous 64-byte segments (cmd '1' tail blends into cmd '2' head when there's RF) - server emits scope frames at the design rate (median gap 240 ms between consecutive scope frames) - 20-second WS verify run: 71 telemetry frames + scope/spectrum frames; zero impossible-value telemetry (swr > 5: 0, peak < avg: 0, peak_mode flap: 0; the remaining top_mode / channel flap matched the operator's F1 / channel_step presses) - control verbs and existing telemetry still work What's NOT in this change (deferred): - Sample rate / time-base of the scope buffer is unknown; needs a CW key-up edge for timing correlation. The values render fine as a shape without it. - Mac-client renderers live in VU3ESV/LP-700-App; this change is server-side only. - The simulator backend doesn't synthesize scope/spectrum yet — clients see no frames of those types under -backend simulator. Co-Authored-By: Claude Opus 4.7 (1M context) --- ARCHITECTURE.md | 33 +++ CLAUDE.md | 107 ++++++++- MANUAL.md | 8 +- README.md | 22 +- examples/node-red/README.md | 2 +- internal/hub/hub.go | 44 +++- internal/lpmeter/decode.go | 82 +++++-- internal/lpmeter/decode_test.go | 67 ++++++ internal/lpmeter/owner.go | 394 +++++++++++++++++++++++++----- internal/lpmeter/owner_test.go | 171 +++++++++++++ internal/lpmeter/probe.go | 411 ++++++++++++++++++++++++++++++++ internal/lpmeter/snapshot.go | 70 +++++- main.go | 35 ++- 13 files changed, 1340 insertions(+), 106 deletions(-) create mode 100644 internal/lpmeter/owner_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5453034..6f794c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -112,8 +112,41 @@ frames carry a monotonic `seq` so clients can detect drops. { "type": "heartbeat", "seq": 12346, "ts": "..." } { "type": "status", "level": "warn", "msg": "hid reopened after 1.3s gap" } { "type": "ack", "ref": "client-supplied-id", "ok": true } + +// Emitted ~4 Hz while top_mode == "waveform" — full envelope buffer +// assembled from 5 HID segments. Not subject to CloseEnough dedup. +{ + "type": "scope", + "seq": 12347, + "ts": "2026-05-15T17:14:25.103Z", + "data": { + "top_mode": "waveform", + "channel": 1, + "auto_channel": false, + "samples": [151, 151, 151, /* ... 320 u8 entries ... */] + } +} + +// Emitted ~4 Hz while top_mode == "spectrum" — full FFT bin buffer. +{ + "type": "spectrum", + "seq": 12348, + "ts": "2026-05-15T17:14:25.347Z", + "data": { + "top_mode": "spectrum", + "channel": 1, + "auto_channel": false, + "bins": [77, 227, 127, /* ... 320 u8 entries ... */] + } +} ``` +Sample frames (`scope` and `spectrum`) carry 320 8-bit unsigned +values each, normalized for display height (the firmware auto-scales +the trace; the values are NOT absolute watts). For power readings, +use the matching `telemetry` frame. See CLAUDE.md "Scope and +spectrum sample buffers" for the underlying HID protocol. + **Client → server** ```json diff --git a/CLAUDE.md b/CLAUDE.md index 3d9fa89..a2533d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,9 +104,11 @@ buf[1..64] = 64-byte payload, where: | `setup` | `'<'` 0x3C | DataLogger btn6 (= F6 Setup, toggles)| | `freeze` | `'?'` 0x3F | DataLogger cmdFreeze | -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. +Cmds `'1'`–`'5'` retrieve scope/spectrum sample buffers — but unlike +cmds `'0'` and `'6'` they repurpose the **whole 64-byte IN report** as +sample data (NOT just bytes 40..63 — that "secondary slot" model only +applies to telemetry frames). See "Scope and spectrum sample buffers" +below. **Firmware quirk — per-channel verbs in auto-channel mode:** `range_step` (F3) and `alarm_toggle` (F4) are per-channel settings. When the meter is @@ -152,6 +154,84 @@ samples). The HID owner alternates the OUT poll: every 10th tick sends non-empty status sticks across plain telemetry frames so the UI doesn't flicker. +### Scope and spectrum sample buffers (cmds `'1'`..`'5'`) + +The firmware splits the on-LCD display buffer across 5 segments, +each delivered as a 64-byte IN report in response to OUT cmds +`'1'`..`'5'`. Concatenated in cmd order they form a single +**320-byte buffer**, 8-bit unsigned samples (the per-cmd boundary is +just a wire-protocol detail; the buffer is logically contiguous — +confirmed by spectral peaks straddling cmd boundaries). + +What you get depends on the meter's current LCD page (`top_mode`): + +| `top_mode` | cmd `'1'`..`'5'` IN frame contents | +|------------|--------------------------------------------------------------| +| `power_swr` | echo only (byte[0] = cmd char, rest zero — firmware refuses) | +| `waveform` | envelope samples, 0..255 normalized to LCD trace height | +| `spectrum` | FFT-bin magnitudes, 0..255 normalized to LCD bar height | +| `setup` | echo only | + +**Samples are normalized for display, not absolute units.** The +firmware auto-scales each trace so its peak fits the LCD (mirroring +the Teensy reference `f_page1.ino` / `g_page2.ino` logic). A steady +carrier produces a uniform-value scope buffer (e.g. all bytes = 151 +in our 2026-05-15 probe at ~466 W). For absolute power, use the +matching telemetry frame's `power_avg_w` / `power_peak_w`. + +**Refresh model.** Each segment cmd returns a static snapshot; the +buffer doesn't auto-update inside a short window of repeated reads. +The hub's HID owner polls one full cycle (cmd `'0'` then cmds +`'1'`..`'5'`, 6 ticks total ≈ 240 ms at the default 40 ms poll +cadence) and emits one `scope` / `spectrum` WS frame per assembly, +giving ~4 Hz refresh while the meter is on the matching LCD page. +During power_swr / setup modes the HID owner reverts to the legacy +cycle (cmd `'0'` every tick, cmd `'6'` every 10th). + +**Frame routing is by SHAPE, not by OUT-write order.** The HID owner +classifies each IN frame on its own merits — three classes: + +1. **Echo** — `byte[0]` in `'0'..'?'` AND every other byte zero. + Firmware refused the OUT (wrong LCD page, or no-op verb in the + current state). Dropped. +2. **Telemetry** — passes a tight byte-range structural check + (`isLikelyTelemetry` in `owner.go`): `byte 3 ≤ 3`, `byte 4 ≤ 4`, + `byte 5 ≤ 4`, `byte 6 ≤ 11`, `byte 8 ≤ 2`. Random sample data + passes all five with probability ≈ 10⁻¹⁰. Decoded as a Snapshot + and broadcast. +3. **Sample** — everything else. Assembled into the scope/spectrum + buffer in arrival order (segment index advances per frame, + resets to 0 on every telemetry frame and on every top-mode + change). Emitted as a `scope` or `spectrum` WS frame after + segment 5. + +An earlier implementation matched IN frames to OUT cmds via a write- +order FIFO. That approach desyncs on any single missed event (stale +kernel-buffered frame at HID open, an unsolicited firmware frame on +mode change) and the misalignment then cascades — sample bytes leak +into the telemetry decoder, producing garbage WS frames with +nonsensical SWR / channel / status-message values that compound +into top_mode flapping. Shape-based routing self-corrects every +frame and avoids the entire class of bugs. (See +`owner_test.go:TestIsLikelyTelemetry` and the 2026-05-15 +LP-700-App-side post-mortem for the symptom catalog this fix +addresses.) + +**Gates on emit.** Scope and spectrum frames are only broadcast +when the meter is on a manual channel (`channel ∈ {1..4}` AND +`auto_channel == false`). Auto-channel × waveform/spectrum is a +hardware-invalid combination on the LP-700 firmware; the sample +buffers in that state are indeterminate and would render as garbage. +Operators must `channel_step` to a manual channel before the scope/ +spectrum view becomes meaningful. + +**Sample rate / time base not yet measured.** Phase 1 reverse- +engineering captured uniform buffers (steady carrier into dummy +load). Sample rate of the scope buffer and absolute frequency-bin +spacing of the spectrum buffer require a CW key-up edge for timing +correlation — deferred. The current decoder treats samples and +bins as opaque ordered arrays; the Mac client renders shape. + ### What the firmware does NOT expose over USB (definitive) Verified by exhaustive search of the 5500-frame `LP700.pcapng`: @@ -187,9 +267,24 @@ them empty and the web UI hides the rows that would have shown them. ## Diagnostic subcommands ```sh -sudo lp700-server probe -list # enumerate every HID -sudo lp700-server probe -dump # live raw + decoded frames -sudo lp700-server probe -capture out.bin -duration 5s # capture for analysis +sudo lp700-server probe -list # enumerate every HID +sudo lp700-server probe -dump # live raw + decoded frames +sudo lp700-server probe -capture out.bin -duration 5s # capture for analysis +sudo lp700-server probe -samples -cycle-modes \ + -channel 1 -range 1K -frames-per-cmd 30 # reverse-engineer + # sample buffers +``` + +`-samples` cycles OUT cmds `'1'`..`'5'` across all three top_modes, +optionally driving the meter to a known channel/range first. Used to +reverse-engineer the wire format (see "Scope and spectrum sample +buffers" above). Requires the service to be stopped first +(`/dev/hidraw*` exclusive open): + +```sh +sudo systemctl stop lp700-server +sudo lp700-server probe -samples -cycle-modes > probe.txt +sudo systemctl start lp700-server ``` See ARCHITECTURE.md §11 for how to use these on a fresh meter. diff --git a/MANUAL.md b/MANUAL.md index 35cc0f2..28bd57b 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -75,8 +75,12 @@ are *not transmitted over USB* — see them on the meter LCD's Setup screen, not the web UI. (The Alarm panel's small note says the same.) The Waveform / 'Scope and Spectrum modes that the meter can display on -its LCD are **not** mirrored to the web UI. The on-LCD mode keeps -working normally; this client only renders the Power/SWR view. +its LCD are **not** mirrored to the embedded web UI. The on-LCD mode +keeps working normally; this client only renders the Power/SWR view. +The Mac client in [LP-700-App](https://github.com/VU3ESV/LP-700-App) +does render the scope and spectrum traces — the server publishes them +as `{"type":"scope"}` and `{"type":"spectrum"}` WebSocket frames +whenever the meter is on the matching LCD page. --- diff --git a/README.md b/README.md index edcc3d9..3da4d03 100644 --- a/README.md +++ b/README.md @@ -153,13 +153,21 @@ Mirrors the LP-500/700 **Power/SWR** screen — Avg power, Peak power, SWR, channel pills (Auto / 1..4), range cycle, Peak/Avg/Tune buttons, alarm enable/tripped indicator. -The Waveform/'Scope and Spectrum modes are *not* mirrored to the web UI; -the `mode_step` verb (still accepted on `/ws`) cycles the meter's on-LCD -display so an operator can switch into them remotely if needed. Numeric -alarm thresholds, callsign, coupler model, and firmware revision live in -the meter's NVRAM and aren't transmitted via USB at all (confirmed by USB -pcap audit — see [CLAUDE.md](CLAUDE.md)) so those rows are absent from -the UI. +The Waveform/'Scope and Spectrum modes are *not* mirrored to the web UI +(the Mac client in [LP-700-App](https://github.com/VU3ESV/LP-700-App) +renders them); the `mode_step` verb (still accepted on `/ws`) cycles +the meter's on-LCD display so an operator can switch into them remotely +if needed. While the meter is on the waveform or spectrum LCD page, the +server emits `{"type":"scope"}` / `{"type":"spectrum"}` WebSocket +frames at ~4 Hz alongside the regular `telemetry` stream — each carries +a 320-element 8-bit array assembled from the firmware's cmd-`'1'`..`'5'` +segments (see [CLAUDE.md](CLAUDE.md) "Scope and spectrum sample +buffers" and [ARCHITECTURE.md](ARCHITECTURE.md) §4). + +Numeric alarm thresholds, callsign, coupler model, and firmware +revision live in the meter's NVRAM and aren't transmitted via USB at +all (confirmed by USB pcap audit — see [CLAUDE.md](CLAUDE.md)) so +those rows are absent from the UI. ## Operations cheatsheet diff --git a/examples/node-red/README.md b/examples/node-red/README.md index 95a410b..68fe010 100644 --- a/examples/node-red/README.md +++ b/examples/node-red/README.md @@ -144,7 +144,7 @@ full or unknown action`). | Symptom | Likely cause / fix | |------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------| | Connection state node stays grey/red | Wrong URL on the websocket-client config, or the Pi isn't reachable. Verify with `curl http://:8089/healthz` from the Node-RED host. | -| Telemetry frames don't arrive | Open the *Parse frame* function's debug pane (route output 1 to a debug node temporarily). If frames arrive but `frame.type !== 'telemetry'`, the server is sending only heartbeats — check that the meter is enumerated (`lp700-server probe -list` on the Pi). | +| Telemetry frames don't arrive | Open the *Parse frame* function's debug pane (route output 1 to a debug node temporarily). If frames arrive but `frame.type !== 'telemetry'`, the server is sending only heartbeats — check that the meter is enumerated (`lp700-server probe -list` on the Pi). Note: while the meter is on the **waveform** or **spectrum** LCD page, telemetry rate drops to ~4 Hz and the server interleaves `{type:'scope'}` / `{type:'spectrum'}` frames carrying 320-element sample arrays — see ARCHITECTURE.md §4. The Parse-frame function should route these to your own renderer (this flow currently ignores them). | | Buttons don't move the meter | Either `server.allow_control = false` on the Pi (read-only port), or the meter is in Setup mode and ignoring soft input. The *ack* debug node will say `control disabled` in the first case. | | Two flows fight for the meter | They don't — that's the whole point of the gateway. Both can subscribe simultaneously, both can send commands; the server's single-writer queue serialises writes FIFO. | diff --git a/internal/hub/hub.go b/internal/hub/hub.go index ef25229..85b7f15 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -19,6 +19,8 @@ type Hub struct { upgrader websocket.Upgrader source lpmeter.Source snapIn <-chan lpmeter.Snapshot + scopeIn <-chan lpmeter.ScopeFrame // nil → scope broadcast disabled + spectrumIn <-chan lpmeter.SpectrumFrame // nil → spectrum broadcast disabled register chan *client unregister chan *client resync chan *client @@ -45,7 +47,7 @@ type Options struct { AllowControl bool } -func NewHub(snapIn <-chan lpmeter.Snapshot, source lpmeter.Source, opts Options, logger *slog.Logger) *Hub { +func NewHub(snapIn <-chan lpmeter.Snapshot, scopeIn <-chan lpmeter.ScopeFrame, spectrumIn <-chan lpmeter.SpectrumFrame, source lpmeter.Source, opts Options, logger *slog.Logger) *Hub { return &Hub{ upgrader: websocket.Upgrader{ // LAN-only deployment per ARCHITECTURE.md §2; any origin is accepted. @@ -53,6 +55,8 @@ func NewHub(snapIn <-chan lpmeter.Snapshot, source lpmeter.Source, opts Options, }, source: source, snapIn: snapIn, + scopeIn: scopeIn, + spectrumIn: spectrumIn, register: make(chan *client, 16), unregister: make(chan *client, 16), resync: make(chan *client, 16), @@ -139,6 +143,26 @@ func (h *Hub) Run(ctx context.Context) { lastSent = time.Now() h.broadcast(clients, data) + case scope := <-h.scopeIn: + // Scope frames change every assembly cycle by design — + // CloseEnough-style dedup doesn't apply. Heartbeat + // suppression also doesn't apply (lastSent is for + // telemetry); scope traffic stays on its own cadence. + data, err := encodeScope(scope, h.seq.Add(1)) + if err != nil { + h.logger.Error("encode scope", "err", err) + continue + } + h.broadcast(clients, data) + + case spec := <-h.spectrumIn: + data, err := encodeSpectrum(spec, h.seq.Add(1)) + if err != nil { + h.logger.Error("encode spectrum", "err", err) + continue + } + h.broadcast(clients, data) + case <-hb.C: if time.Since(lastSent) < h.heartbeat { continue @@ -317,3 +341,21 @@ func encodeHeartbeat(seq uint64) ([]byte, error) { "ts": time.Now().UTC().Format(time.RFC3339Nano), }) } + +func encodeScope(s lpmeter.ScopeFrame, seq uint64) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "scope", + "seq": seq, + "ts": s.Timestamp.Format(time.RFC3339Nano), + "data": s, + }) +} + +func encodeSpectrum(s lpmeter.SpectrumFrame, seq uint64) ([]byte, error) { + return json.Marshal(map[string]any{ + "type": "spectrum", + "seq": seq, + "ts": s.Timestamp.Format(time.RFC3339Nano), + "data": s, + }) +} diff --git a/internal/lpmeter/decode.go b/internal/lpmeter/decode.go index 8bcb030..aa9182e 100644 --- a/internal/lpmeter/decode.go +++ b/internal/lpmeter/decode.go @@ -1,6 +1,7 @@ package lpmeter import ( + "bytes" "encoding/binary" "errors" "fmt" @@ -102,14 +103,32 @@ func PollReport() []byte { // bytes 40..63 of the next telemetry frame with its current ASCII alert // message ("Reduce power or lower range" etc.). Per the LP700.pcapng // analysis the Telepost VM cycles through cmd '0' (live telemetry) and -// cmd '6' (status text) along with `1`..`5` (scope/spec sample buffers -// that v1 ignores). +// cmd '6' (status text) along with `1`..`5` (scope/spec sample buffers). func StatusReport() []byte { out := make([]byte, ReportSize) out[0] = '6' return out } +// SampleReport returns the OUT payload that asks the meter to deliver +// scope/spectrum buffer segment `seg` (1..5). The next IN report has +// the firmware-populated 64-byte segment payload (the WHOLE 64 bytes, +// not just bytes 40..63 — confirmed empirically 2026-05-15). Segments +// 1..5 concatenate in order to produce a single SampleBufferSize-byte +// buffer. +// +// Firmware ignores these cmds and returns an echo frame when the meter +// is NOT on the corresponding LCD page (waveform for envelope samples, +// spectrum for FFT bins). Power_SWR and Setup pages → echo only. +func SampleReport(seg byte) []byte { + if seg < 1 || seg > 5 { + panic(fmt.Sprintf("sample segment %d out of range 1..5", seg)) + } + out := make([]byte, ReportSize) + out[0] = '0' + seg // '1' .. '5' + return out +} + // Decode parses a 64-byte IN report from the LP-500/700 into a Snapshot. // Layout grounded in the manufacturer's DataLogger source. func Decode(report []byte) (Snapshot, error) { @@ -217,28 +236,21 @@ func Decode(report []byte) (Snapshot, error) { return s, nil } -// extractStatusMessage returns the trimmed ASCII message embedded in the -// secondary slot of a telemetry frame, or "" if the slot doesn't look -// like text (which is the common case — the slot also carries scope/spec -// sample data after cmd '0'..'5'). +// extractStatusMessage returns the trimmed ASCII message embedded in +// the secondary slot of a telemetry frame, or "" if the slot doesn't +// look like a real English status phrase ("Reduce power or lower +// range", "TX Match req'd", etc.). +// +// The earlier "≥75% printable" filter was too loose: sample buffer +// bytes that happen to lie in the printable-ASCII range (e.g. 32..126) +// would slip through as ?-prefixed alphabetised garbage like +// `?BFILORUY|_bfilorvy|`. Status messages from the LP-700 firmware +// are real English phrases, so require BOTH: +// +// - at least one ASCII space (status phrases are multi-word), AND +// - at least one run of 3+ consecutive ASCII letters (a..z / A..Z) func extractStatusMessage(slot []byte) string { - // The slot is text-ish when most of its non-zero bytes are printable - // ASCII. Scope/spec samples typically carry tiny binary values - // (0..3) which fail this filter. - printable, nonzero := 0, 0 - for _, b := range slot { - if b == 0 { - continue - } - nonzero++ - if b >= 0x20 && b < 0x7f { - printable++ - } - } - if nonzero < 4 || printable*4 < nonzero*3 { - // Less than ~75% printable, or very sparse — not a message. - return "" - } + // First pass: shape check. Sample data fails both criteria. out := make([]byte, 0, len(slot)) for _, b := range slot { if b == 0 { @@ -252,6 +264,30 @@ func extractStatusMessage(slot []byte) string { for len(out) > 0 && out[len(out)-1] == ' ' { out = out[:len(out)-1] } + if len(out) < 4 { + return "" + } + if !bytes.ContainsRune(out, ' ') { + return "" + } + // 3+ consecutive ASCII letters. + run := 0 + hasRun := false + for _, b := range out { + isLetter := (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') + if isLetter { + run++ + if run >= 3 { + hasRun = true + break + } + } else { + run = 0 + } + } + if !hasRun { + return "" + } return string(out) } diff --git a/internal/lpmeter/decode_test.go b/internal/lpmeter/decode_test.go index 2a9d604..eed8a38 100644 --- a/internal/lpmeter/decode_test.go +++ b/internal/lpmeter/decode_test.go @@ -2,6 +2,7 @@ package lpmeter import ( "encoding/binary" + "fmt" "testing" ) @@ -237,6 +238,39 @@ func TestDecodeRejectsCommandEcho(t *testing.T) { } } +func TestExtractStatusMessage(t *testing.T) { + mk := func(s string) []byte { + slot := make([]byte, 24) // bytes 40..63 are 24 bytes + copy(slot, []byte(s)) + return slot + } + tests := []struct { + name string + slot []byte + want string + }{ + {"empty slot", mk(""), ""}, + {"single char", mk("X"), ""}, + {"too short", mk("hi"), ""}, + {"real status — multi-word", mk("Reduce power "), "Reduce power"}, + {"real status — TX Match req'd", mk("TX Match req'd"), "TX Match req'd"}, + {"no space → reject (single token)", mk("ReducePower"), ""}, + {"has space but no 3-letter run", mk("a b c d"), ""}, + {"sample-leak garbage (no space, no letter run)", + mk("?BFILORUY|_bfilorvy|"), ""}, + {"alphabetised punctuation garbage (has letters but no space, no run)", + mk("@AaBb)CcDdEe"), ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractStatusMessage(tt.slot) + if got != tt.want { + t.Errorf("extractStatusMessage(%q) = %q, want %q", tt.slot, got, tt.want) + } + }) + } +} + func TestPollReportShape(t *testing.T) { p := PollReport() if len(p) != ReportSize { @@ -247,6 +281,39 @@ func TestPollReportShape(t *testing.T) { } } +func TestSampleReportLayout(t *testing.T) { + for seg := byte(1); seg <= 5; seg++ { + r := SampleReport(seg) + if len(r) != ReportSize { + t.Errorf("seg %d: size %d, want %d", seg, len(r), ReportSize) + } + want := byte('0') + seg + if r[0] != want { + t.Errorf("seg %d: byte[0]=0x%02x, want 0x%02x ('%c')", seg, r[0], want, want) + } + for i := 1; i < ReportSize; i++ { + if r[i] != 0 { + t.Errorf("seg %d: byte[%d]=0x%02x, want 0 (entire payload after byte 0 must be zero)", seg, i, r[i]) + break + } + } + } +} + +func TestSampleReportRejectsOutOfRange(t *testing.T) { + for _, seg := range []byte{0, 6, 7, 255} { + seg := seg + t.Run(fmt.Sprintf("seg=%d", seg), func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("seg %d: expected panic, got none", seg) + } + }() + _ = SampleReport(seg) + }) + } +} + func TestVerbAvailableInState(t *testing.T) { tests := []struct { name string diff --git a/internal/lpmeter/owner.go b/internal/lpmeter/owner.go index b4d0254..a1b89bf 100644 --- a/internal/lpmeter/owner.go +++ b/internal/lpmeter/owner.go @@ -2,6 +2,7 @@ package lpmeter import ( "context" + "encoding/binary" "errors" "fmt" "log/slog" @@ -38,12 +39,14 @@ type Source interface { // and arbitrates reads (poll responses) and writes (control commands) // so they never collide. type HIDOwner struct { - vendorID uint16 - productID uint16 - pollEvery time.Duration - commands chan command - out chan<- Snapshot - logger *slog.Logger + vendorID uint16 + productID uint16 + pollEvery time.Duration + commands chan command + out chan<- Snapshot + scopeOut chan<- ScopeFrame // nil → scope assembly disabled + spectrumOut chan<- SpectrumFrame // nil → spectrum assembly disabled + logger *slog.Logger } type command struct { @@ -53,15 +56,19 @@ type command struct { // NewHIDOwner builds an owner that will open an LP-500/700 by VID/PID // (when both are non-zero) or by Product-string match ("LP-500" / -// "LP-700") otherwise. Writes snapshots to `out`. -func NewHIDOwner(vid, pid uint16, pollEvery time.Duration, out chan<- Snapshot, logger *slog.Logger) *HIDOwner { +// "LP-700") otherwise. Writes snapshots to `out`; if non-nil, also +// assembles scope/spectrum buffers when the meter is on the matching +// LCD page and emits them on `scopeOut` / `spectrumOut`. +func NewHIDOwner(vid, pid uint16, pollEvery time.Duration, out chan<- Snapshot, scopeOut chan<- ScopeFrame, spectrumOut chan<- SpectrumFrame, logger *slog.Logger) *HIDOwner { return &HIDOwner{ - vendorID: vid, - productID: pid, - pollEvery: pollEvery, - commands: make(chan command, 16), - out: out, - logger: logger, + vendorID: vid, + productID: pid, + pollEvery: pollEvery, + commands: make(chan command, 16), + out: out, + scopeOut: scopeOut, + spectrumOut: spectrumOut, + logger: logger, } } @@ -151,20 +158,68 @@ func (o *HIDOwner) runOnce(ctx context.Context) error { } }() - // pollTicker drives both the active poll ('0' command) and the - // drain of any queued control verbs from clients. Most ticks send - // the live-telemetry poll; one in `statusEveryN` sends cmd '6' - // instead, which asks the meter to populate bytes 40..63 of its - // next IN report with the current ASCII alert message. + // pollTicker drives both the active poll (cmd '0') and the drain + // of any queued control verbs from clients. The exact cmd that + // fires on each tick depends on the meter's current top_mode: + // - power_swr / setup: cmd '0' every tick, cmd '6' every Nth + // tick (refreshes the ASCII status slot at bytes 40..63) + // - waveform / spectrum: 6-tick cycle '0' '1' '2' '3' '4' '5', + // so each tick cycle yields one telemetry frame plus the 5 + // segments that assemble into one ScopeFrame or SpectrumFrame + // (~4 Hz scope/spec rate at the default 40 ms poll cadence, + // ~4 Hz telemetry rate during scope/spec mode) const statusEveryN = 10 + const sampleCycleLen = 6 pollTicker := time.NewTicker(o.pollEvery) defer pollTicker.Stop() tickN := 0 + // Sticky status message: only cmd-'6' responses carry it; carry it // forward across plain cmd-'0' responses so clients see a stable // value rather than text-then-blank flicker. var lastStatus string + // Track meter state from the last decoded telemetry frame. Used + // by the tick handler to decide whether to interleave sample cmds, + // and by the sample-frame emitter to label the assembled buffer. + var lastTopMode string + var lastChannel int + var lastAutoCh bool + + // Frame routing is by SHAPE, not by OUT-write order. An earlier + // implementation matched IN frames to OUT cmds via a FIFO, but + // that approach desyncs on any single missed event (stale kernel- + // buffered frame at HID open, an unsolicited firmware frame on + // mode change) and the misalignment then cascades. With shape- + // based routing the loop is self-correcting: each frame is + // classified on its own merits and no per-write state persists + // across frames. + // + // Three frame classes: + // 1. ECHO — byte[0] in cmd-char range AND bytes 1..63 zero. + // Firmware refused the OUT (wrong LCD page or no-op + // in current state). Drop. + // 2. TELEMETRY — non-echo, AND satisfies tight byte-range + // invariants (byte 3 ≤ 3, byte 4 ≤ 4, etc.). + // Probability a sample frame accidentally passes + // all five invariants ≈ 10⁻¹⁰. Run through Decode. + // 3. SAMPLE — non-echo, fails telemetry invariants. In + // waveform/spectrum mode this is the next segment + // of the 5×64-byte buffer; assemble in arrival + // order (the 1:1 firmware response keeps order + // aligned with our 6-tick cycle). + + // Scope / spectrum buffer assembly state. sampleSegIdx advances + // with each non-telemetry, non-echo frame received while in a + // sample-bearing mode; it resets to 0 on every telemetry frame + // (start of a fresh 6-tick cycle) and on every mode change. + var scopeBuf [SampleBufferSize]byte + var spectrumBuf [SampleBufferSize]byte + sampleSegIdx := 0 + resetSampleState := func() { + sampleSegIdx = 0 + } + for { select { case <-ctx.Done(): @@ -172,55 +227,121 @@ func (o *HIDOwner) runOnce(ctx context.Context) error { case err := <-readErr: return fmt.Errorf("hid read: %w", err) case frame := <-frames: - snap, err := Decode(frame) - if err != nil { - if IsSkippable(err) { + // Echo frame? Firmware refused the OUT. Drop & reset any + // in-progress sample buffer. + if isCommandEcho(frame) { + o.logger.Debug("cmd echo (firmware refused)", "cmd", string(frame[0]), "top_mode", lastTopMode) + resetSampleState() + continue + } + // Telemetry-shaped? Tight byte-range invariants (~10⁻¹⁰ + // false-positive rate on random sample data). Decode and + // broadcast. + if isLikelyTelemetry(frame) { + snap, err := Decode(frame) + if err != nil { + if IsSkippable(err) { + continue + } + o.logger.Debug("decode error", "err", err, "raw", fmt.Sprintf("%x", frame)) continue } - o.logger.Debug("decode error", "err", err, "raw", fmt.Sprintf("%x", frame)) + // Bytes 40..63 only carry an ASCII status message after + // cmd '6'. After cmd '0' that slot holds bargraph / + // pwr-mult bytes and extractStatusMessage returns "". + // Carry the last non-empty message forward across plain + // telemetry frames so clients see stable text. + if snap.StatusMessage == "" { + snap.StatusMessage = lastStatus + } else { + lastStatus = snap.StatusMessage + } + // A telemetry frame marks the start of a fresh 6-tick + // cycle. Reset the sample-segment counter so the next + // 5 non-telemetry frames assemble seg 1..5 in order. + sampleSegIdx = 0 + // Top-mode change invalidates any in-progress assembly. + if snap.TopMode != lastTopMode { + resetSampleState() + } + lastTopMode = snap.TopMode + lastChannel = snap.Channel + lastAutoCh = snap.AutoChannel + o.logger.Debug("frame", + "channel", snap.Channel, + "auto_channel", snap.AutoChannel, + "power_avg_w", snap.PowerAvgW, + "power_peak_w", snap.PowerPeakW, + "peak_hold_w", snap.PeakHoldW, + "peak_mode", snap.PeakMode, + "swr", snap.SWR, + "range", snap.Range, + "alarm_enabled", snap.AlarmEnabled, + "status", snap.StatusMessage, + "raw", fmt.Sprintf("%x", frame)) + select { + case o.out <- snap: + case <-ctx.Done(): + return nil + default: + // Hub is slow; drop this sample. Next IN report + // will replace it. + } continue } - // Bytes 40..63 only carry an ASCII status message after a - // cmd '6' poll. After cmd '0' that slot holds binary - // scope/spec data and Decode returns "". Carry the last - // non-empty message forward so it stays visible to clients - // between cmd-'6' refreshes. - if snap.StatusMessage == "" { - snap.StatusMessage = lastStatus - } else { - lastStatus = snap.StatusMessage + // Sample frame. Route into the appropriate buffer based + // on the last-known top_mode. The firmware delivers + // segments 1..5 in cmd-order, 1:1 with our writes, so + // arrival order matches segment index — no need to know + // the originating cmd byte. + if lastTopMode != "waveform" && lastTopMode != "spectrum" { + // Not on a sample-bearing page; drop (likely a stale + // frame from a recent mode transition). + continue } - // Full-frame hex in the debug log lets us see whether the - // firmware interleaves multiple IN-report types (e.g. Power/ - // SWR vs status), which we'd otherwise conflate. Cheap on a - // quiet journal because the level defaults to error. - o.logger.Debug("frame", - "channel", snap.Channel, - "auto_channel", snap.AutoChannel, - "power_avg_w", snap.PowerAvgW, - "power_peak_w", snap.PowerPeakW, - "peak_hold_w", snap.PeakHoldW, - "peak_mode", snap.PeakMode, - "swr", snap.SWR, - "range", snap.Range, - "alarm_enabled", snap.AlarmEnabled, - "status", snap.StatusMessage, - "raw", fmt.Sprintf("%x", frame)) - select { - case o.out <- snap: - case <-ctx.Done(): - return nil - default: - // Hub is slow; drop this sample. Next IN report will replace it. + if sampleSegIdx >= 5 { + // More sample frames than the cycle should produce; + // drop. Will realign on the next telemetry frame. + o.logger.Debug("extra sample frame past seg 5", "top_mode", lastTopMode) + continue + } + segIdx := sampleSegIdx + sampleSegIdx++ + switch lastTopMode { + case "waveform": + copy(scopeBuf[segIdx*64:(segIdx+1)*64], frame) + o.logger.Debug("scope segment received", "seg", segIdx+1) + if sampleSegIdx == 5 { + o.emitScope(scopeBuf[:], lastChannel, lastAutoCh, ctx) + } + case "spectrum": + copy(spectrumBuf[segIdx*64:(segIdx+1)*64], frame) + o.logger.Debug("spectrum segment received", "seg", segIdx+1) + if sampleSegIdx == 5 { + o.emitSpectrum(spectrumBuf[:], lastChannel, lastAutoCh, ctx) + } } case <-pollTicker.C: if err := o.drainCommands(dev); err != nil { return err } tickN++ - payload := PollReport() - if tickN%statusEveryN == 0 { - payload = StatusReport() + var payload []byte + switch lastTopMode { + case "waveform", "spectrum": + // 6-tick cycle: phase 0 = telemetry poll, 1..5 = sample segments. + phase := tickN % sampleCycleLen + if phase == 0 { + payload = PollReport() + } else { + payload = SampleReport(byte(phase)) + } + default: + if tickN%statusEveryN == 0 { + payload = StatusReport() + } else { + payload = PollReport() + } } if err := writeReport(dev, payload); err != nil { return fmt.Errorf("poll: %w", err) @@ -301,3 +422,162 @@ func minInt(a, b int) int { } return b } + +// isCommandEcho reports whether a 64-byte IN frame is the firmware's +// echo of an OUT command write (byte[0] in the cmd-char range '0'..'?' +// and every other byte zero). Real telemetry never matches. +func isCommandEcho(frame []byte) bool { + if len(frame) == 0 || frame[0] < '0' || frame[0] > '?' { + return false + } + for i := 1; i < len(frame); i++ { + if frame[i] != 0 { + return false + } + } + return true +} + +// isLikelyTelemetry reports whether a 64-byte IN frame's structure +// matches a real telemetry response from cmd '0' or '6': specifically +// that the header-byte ranges that the Decode function later relies on +// are all within their valid intervals. Sample frames (from cmds +// '1'..'5' in waveform/spectrum mode) place arbitrary 8-bit sample +// data at these offsets, so the chance of all five passing on a sample +// frame is the product of each range's relative width — under 10⁻¹⁰ +// for random data, slightly higher in pathological correlated cases +// but never observed empirically on the LP-700 firmware. +// +// This is the key defense against sample frames leaking into the +// telemetry decode path. It is intentionally STRICTER than what +// Decode itself enforces, because Decode would also accept some +// sample frames (those that happen to have all 5 header bytes in +// range with garbage payload values) and broadcast garbage Snapshots. +func isLikelyTelemetry(frame []byte) bool { + if len(frame) != ReportSize { + return false + } + if frame[OffsetTopMode] > 3 { + return false + } + if frame[OffsetChannel] > 4 { + return false + } + if frame[OffsetChannelAuto] > 4 { + return false + } + if int(frame[OffsetRange]) >= len(rangeNames) { + return false + } + // Byte 7 is alarm-DISABLED, a flag byte: 0 or 1 only. + if frame[OffsetAlarm] > 1 { + return false + } + if int(frame[OffsetPeakAvg]) >= len(peakModeNames) { + return false + } + // Power coherence: in real telemetry frames the firmware + // guarantees peak_power >= avg_power (peak is a max-hold over a + // short window, avg is a rolling integration over the same or + // longer window). Sample frames place arbitrary u8 sample values + // at these offsets and routinely violate this invariant — the + // remaining "peak < avg" leakage past the byte-range checks falls + // into this filter. + rawPeak := binary.BigEndian.Uint16(frame[OffsetPeakPwrHi : OffsetPeakPwrHi+2]) + rawAvg := binary.BigEndian.Uint16(frame[OffsetAvgPwrHi : OffsetAvgPwrHi+2]) + if rawPeak < rawAvg { + return false + } + // SWR sanity: raw SWR is a 16-bit fixed-point /100 value. The + // meter caps physically meaningful SWR around 5.0 (raw 500); the + // internal floor is 1.0 (raw 100). Values above raw ~1000 (SWR + // 10) are unphysical for any real coupler/load and indicate + // sample data has slipped into bytes 2 / 37. Cap at raw 1000 to + // catch the residual leak. + rawSWR := uint16(frame[OffsetSWRHi])<<8 | uint16(frame[OffsetSWRLo]) + if rawSWR > 1000 { + return false + } + return true +} + +// emitScope copies `buf` into a fresh ScopeFrame and sends it on the +// scope channel non-blocking. If the channel is nil (assembly +// disabled) or full (slow consumer), the frame is dropped — a fresh +// one will be assembled on the next 6-tick cycle. +func (o *HIDOwner) emitScope(buf []byte, channel int, autoCh bool, ctx context.Context) { + if o.scopeOut == nil { + return + } + // Hardware-invalid guard: the LP-500/700 firmware doesn't support + // auto-channel on the waveform / spectrum LCD pages. Sample + // buffers captured in that state are indeterminate, so don't + // broadcast them — the client would render garbage. Operators + // must channel_step to a manual channel (CH1..4) before the + // scope/spectrum view becomes meaningful. + if autoCh || channel < 1 || channel > 4 { + o.logger.Debug("scope frame suppressed (invalid channel/auto state)", "channel", channel, "auto_channel", autoCh) + return + } + samples := make(SampleBytes, len(buf)) + copy(samples, buf) + frame := ScopeFrame{ + Timestamp: time.Now().UTC(), + TopMode: "waveform", + Channel: channel, + AutoChannel: autoCh, + Samples: samples, + } + select { + case o.scopeOut <- frame: + o.logger.Debug("emit scope", "channel", channel, "samples_min_max", minMax(samples)) + case <-ctx.Done(): + default: + o.logger.Debug("scope buffer dropped (hub slow)") + } +} + +// emitSpectrum is the spectrum-buffer equivalent of emitScope. +func (o *HIDOwner) emitSpectrum(buf []byte, channel int, autoCh bool, ctx context.Context) { + if o.spectrumOut == nil { + return + } + if autoCh || channel < 1 || channel > 4 { + o.logger.Debug("spectrum frame suppressed (invalid channel/auto state)", "channel", channel, "auto_channel", autoCh) + return + } + bins := make(SampleBytes, len(buf)) + copy(bins, buf) + frame := SpectrumFrame{ + Timestamp: time.Now().UTC(), + TopMode: "spectrum", + Channel: channel, + AutoChannel: autoCh, + Bins: bins, + } + select { + case o.spectrumOut <- frame: + o.logger.Debug("emit spectrum", "channel", channel, "bins_min_max", minMax(bins)) + case <-ctx.Done(): + default: + o.logger.Debug("spectrum buffer dropped (hub slow)") + } +} + +// minMax is a tiny diagnostic helper for the emit-debug log: returns a +// "min..max" string summarising a sample buffer. +func minMax(b SampleBytes) string { + if len(b) == 0 { + return "(empty)" + } + mn, mx := b[0], b[0] + for _, v := range b { + if v < mn { + mn = v + } + if v > mx { + mx = v + } + } + return fmt.Sprintf("%d..%d", mn, mx) +} diff --git a/internal/lpmeter/owner_test.go b/internal/lpmeter/owner_test.go new file mode 100644 index 0000000..8c0c894 --- /dev/null +++ b/internal/lpmeter/owner_test.go @@ -0,0 +1,171 @@ +package lpmeter + +import "testing" + +func TestIsCommandEcho(t *testing.T) { + tests := []struct { + name string + make func() []byte + want bool + }{ + { + name: "true: cmd '1' echo (byte 0 = '1', rest zero)", + make: func() []byte { r := make([]byte, ReportSize); r[0] = '1'; return r }, + want: true, + }, + { + name: "true: cmd '6' echo", + make: func() []byte { r := make([]byte, ReportSize); r[0] = '6'; return r }, + want: true, + }, + { + name: "true: cmd '?' echo (high end of cmd-char range)", + make: func() []byte { r := make([]byte, ReportSize); r[0] = '?'; return r }, + want: true, + }, + { + name: "false: byte 0 in cmd-char range but byte N != 0", + make: func() []byte { + r := make([]byte, ReportSize) + r[0] = '1' + r[7] = 0xff + return r + }, + want: false, + }, + { + name: "false: real telemetry — byte 0 = 0 (typical)", + make: func() []byte { + r := make([]byte, ReportSize) + r[3] = 1 // top_mode + r[6] = 5 // range + return r + }, + want: false, + }, + { + name: "false: sample frame — byte 0 = 0x97 (151, outside cmd-char range)", + make: func() []byte { + r := make([]byte, ReportSize) + for i := range r { + r[i] = 0x97 + } + return r + }, + want: false, + }, + { + name: "false: empty/zero frame (byte 0 = 0, not in cmd range)", + make: func() []byte { return make([]byte, ReportSize) }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCommandEcho(tt.make()) + if got != tt.want { + t.Errorf("isCommandEcho: got %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsLikelyTelemetry(t *testing.T) { + // Valid telemetry frame: peak_power >= avg_power, all header + // bytes in range. Use buildSyntheticFrame and patch peak > avg. + valid := buildSyntheticFrame(Snapshot{ + Channel: 2, AutoChannel: false, + Range: "100W", + TopMode: "power_swr", + PeakMode: "peak_hold", + AlarmEnabled: true, + PowerAvgW: 100, // raw 500 + PowerPeakW: 140, // raw 700 + }) + if !isLikelyTelemetry(valid) { + t.Error("valid telemetry frame should pass isLikelyTelemetry") + } + + // Sample frame: 64 bytes of envelope amplitude (e.g. 0x97 = 151). + // byte 3 (top_mode) = 151 > 3 → fails. + sample := make([]byte, ReportSize) + for i := range sample { + sample[i] = 0x97 + } + if isLikelyTelemetry(sample) { + t.Error("uniform-151 sample frame must fail isLikelyTelemetry (byte 3 out of range)") + } + + // Spectrum-shaped sample with byte 3 in range by chance (e.g. + // byte 3 = 2) but byte 4 out of range. + spec := make([]byte, ReportSize) + for i := range spec { + spec[i] = 0x42 + } + spec[3] = 2 // looks like top_mode "spectrum" + if isLikelyTelemetry(spec) { + t.Error("sample frame with single in-range header byte must still fail (others out of range)") + } + + // Empty/zero frame: byte 0..63 all zero. All header bytes are 0 + // (in range), peak == avg == 0 (peak >= avg holds). Passes the + // check — and would be rejected by the cmd-echo filter upstream + // OR by Decode (which has its own range checks). + zero := make([]byte, ReportSize) + if !isLikelyTelemetry(zero) { + t.Error("zero frame's header bytes are all in range → should pass isLikelyTelemetry") + } + + // Wrong size: short frame. + if isLikelyTelemetry(make([]byte, ReportSize-1)) { + t.Error("short frame must fail isLikelyTelemetry") + } + + // Spectrum cmd '5' tail with sparse small bytes: all header + // invariants pass by accident, but byte 7 (alarm-DISABLED) is + // out of {0,1} and/or peak < avg. This is the actual leak path + // observed in production 2026-05-16 (14 peak= avg, but SWR raw above the + // physical sanity cap (raw 1000 = SWR 10.0). This is the leak + // path that produced swr>5 reports on 2026-05-16. + swrLeak := make([]byte, ReportSize) + swrLeak[2] = 0x08 // SWR hi + swrLeak[37] = 0x00 // SWR lo → raw = 0x0800 = 2048 → SWR 20.48 + swrLeak[3], swrLeak[4], swrLeak[5] = 0, 1, 0 + swrLeak[6] = 4 + swrLeak[7] = 1 + swrLeak[8] = 0 + swrLeak[23], swrLeak[24] = 0x00, 0x05 // peak raw 5 + swrLeak[25], swrLeak[26] = 0x00, 0x05 // avg raw 5 (peak == avg ok) + if isLikelyTelemetry(swrLeak) { + t.Error("frame with SWR raw > 1000 must fail isLikelyTelemetry") + } +} diff --git a/internal/lpmeter/probe.go b/internal/lpmeter/probe.go index 0232297..a2d1e6c 100644 --- a/internal/lpmeter/probe.go +++ b/internal/lpmeter/probe.go @@ -18,6 +18,7 @@ const ( ProbeList ProbeMode = "list" // enumerate every HID ProbeDump ProbeMode = "dump" // print every IN report from the matched LP-500/700 ProbeCapture ProbeMode = "capture" // write a fixture file + ProbeSamples ProbeMode = "samples" // cycle OUT cmds '1'..'5' and dump the secondary slot ) // ProbeOptions wraps the CLI flags driving the probe subcommand. @@ -27,6 +28,12 @@ type ProbeOptions struct { Duration time.Duration // for ProbeCapture; 0 = until ^C VendorID uint16 ProductID uint16 + + // For ProbeSamples: + FramesPerCmd int // IN frames captured per OUT cmd; 0 → default 16 + CycleModes bool // mode_step through power_swr / waveform / spectrum and repeat per cmd + TargetChannel int // 1..4: channel_step until manual ch matches; 0 = leave as-is + TargetRange string // "5W" / "10W" / ... / "10K" / "auto"; "" = leave as-is } // RunProbe executes one of the diagnostic modes. Output goes to `w`. @@ -38,6 +45,8 @@ func RunProbe(ctx context.Context, opts ProbeOptions, w io.Writer) error { return runDump(ctx, w, opts) case ProbeCapture: return runCapture(ctx, w, opts) + case ProbeSamples: + return runSamples(ctx, w, opts) default: return fmt.Errorf("unknown probe mode %q", opts.Mode) } @@ -227,6 +236,408 @@ func truncate(s string, n int) string { // match function when reporting backend selection in the config endpoint. func IsLPMeterProductString(s string) bool { return isLPMeter(s) } +// runSamples drives OUT commands '1'..'5' and dumps the secondary slot +// (bytes 40..63) of the IN reports that follow each. The Telepost +// DataLogger VB6 source documents '0' as the live-telemetry poll and +// '6' as the status-message refresh; '1'..'5' are mentioned only as +// "scope/spectrum sample buffers" with no field-level documentation. +// This mode is the first step in reverse-engineering those buffers. +// +// For each cmd: +// +// send the cmd as an OUT report +// read N follow-up IN reports +// for each non-echo IN frame: print raw[40:63] hex, signed/unsigned +// 8-bit, signed/unsigned 16-bit BE/LE views, and the top_mode / +// channel header so we can correlate the slot's content with what +// the LCD is showing +// +// With -cycle-modes the probe will mode_step the meter through +// power_swr → waveform → spectrum and repeat the per-cmd capture in +// each, then mode_step back to power_swr. The "gotcha" working +// hypothesis is that the firmware only populates the scope slot when +// top_mode == waveform and the spectrum slot when top_mode == spectrum. +func runSamples(ctx context.Context, w io.Writer, opts ProbeOptions) error { + frames := opts.FramesPerCmd + if frames <= 0 { + frames = 16 + } + + dev, info, err := openLPMeter(opts) + if err != nil { + return err + } + defer dev.Close() + fmt.Fprintf(w, "# Opened %s (%04x:%04x %q manuf=%q)\n", info.Path, info.VendorID, info.ProductID, info.Product, info.Manufacturer) + fmt.Fprintln(w, "# Probe: cycle OUT cmds '1'..'5' and dump secondary slot (bytes 40..63)") + + // A read pump in a goroutine so we can flush junk between OUT + // writes. The probe drives writes synchronously between captures. + type framePkt struct { + buf []byte + err error + } + reads := make(chan framePkt, 32) + readCtx, cancelReads := context.WithCancel(ctx) + defer cancelReads() + go func() { + buf := make([]byte, ReportSize) + for readCtx.Err() == nil { + n, err := dev.Read(buf) + if err != nil { + select { + case reads <- framePkt{err: err}: + case <-readCtx.Done(): + } + return + } + if n != ReportSize { + continue + } + frame := make([]byte, ReportSize) + copy(frame, buf) + select { + case reads <- framePkt{buf: frame}: + case <-readCtx.Done(): + return + } + } + }() + + // drain consumes any pending reads for `d` so old data doesn't + // pollute the next capture window. + drain := func(d time.Duration) { + t := time.NewTimer(d) + defer t.Stop() + for { + select { + case <-reads: + case <-t.C: + return + case <-ctx.Done(): + return + } + } + } + + // captureOne sends the OUT cmd `n` times at the meter's natural + // poll cadence (~40 ms) and prints one IN frame per write. The + // firmware emits one IN report per OUT report; without repeated + // writes we'd only see the first response and then silence. Each + // frame is dumped in full (all 64 bytes) since in scope/spec modes + // the entire report becomes sample data — the bytes-40..63 "slot" + // abstraction only holds for telemetry/status frames. + captureOne := func(cmd byte, n int) (*Snapshot, error) { + out := make([]byte, ReportSize) + out[0] = cmd + drain(20 * time.Millisecond) // flush stale + var lastSnap *Snapshot + fmt.Fprintf(w, "\n--- cmd '%c' (0x%02x) ----------------------------------------\n", cmd, cmd) + // Dedup state: when consecutive frames are byte-identical we + // only print the first and a tally, since 60 dumps of the same + // 64 bytes drown the interesting transitions in noise. + var prevFrame []byte + dupCount := 0 + flushDups := func() { + if dupCount > 0 { + fmt.Fprintf(w, " (^ %d more identical frames)\n", dupCount) + dupCount = 0 + } + } + for i := 0; i < n; i++ { + if err := writeReport(dev, out); err != nil { + flushDups() + return lastSnap, fmt.Errorf("write cmd 0x%02x (#%d): %w", cmd, i, err) + } + select { + case pkt := <-reads: + if pkt.err != nil { + flushDups() + return lastSnap, fmt.Errorf("read: %w", pkt.err) + } + if snap, err := Decode(pkt.buf); err == nil { + lastSnap = &snap + } + if prevFrame != nil && bytesEqual(prevFrame, pkt.buf) { + dupCount++ + } else { + flushDups() + formatFullFrame(w, pkt.buf, i) + prevFrame = make([]byte, ReportSize) + copy(prevFrame, pkt.buf) + } + case <-time.After(150 * time.Millisecond): + flushDups() + fmt.Fprintf(w, " [%2d] (timeout)\n", i) + prevFrame = nil // reset; next real frame should print + case <-ctx.Done(): + flushDups() + return lastSnap, ctx.Err() + } + // Pace at ~25 Hz, same cadence the running server uses. + time.Sleep(40 * time.Millisecond) + } + flushDups() + return lastSnap, nil + } + + // readCurrentMode polls a couple times to learn the meter's current + // top_mode before we start cycling. + readCurrentMode := func() string { + _ = writeReport(dev, PollReport()) + for i := 0; i < 4; i++ { + select { + case pkt := <-reads: + if pkt.err != nil { + return "" + } + if snap, err := Decode(pkt.buf); err == nil { + return snap.TopMode + } + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return "" + } + } + return "" + } + + // stepToMode mode_steps until we observe `target` in a decoded + // frame, or we give up after `maxSteps`. + stepToMode := func(target string, maxSteps int) (string, error) { + for step := 0; step < maxSteps; step++ { + cur := readCurrentMode() + fmt.Fprintf(w, "# observed top_mode=%q (target %q, step %d)\n", cur, target, step) + if cur == target { + return cur, nil + } + out := make([]byte, ReportSize) + out[0] = cmdMode + if err := writeReport(dev, out); err != nil { + return cur, fmt.Errorf("write mode_step: %w", err) + } + time.Sleep(150 * time.Millisecond) + drain(80 * time.Millisecond) + } + return "", fmt.Errorf("could not reach top_mode=%q in %d steps", target, maxSteps) + } + + // readCurrentSnap polls once and returns a fresh decoded snapshot. + readCurrentSnap := func() *Snapshot { + _ = writeReport(dev, PollReport()) + for i := 0; i < 4; i++ { + select { + case pkt := <-reads: + if pkt.err != nil { + return nil + } + if snap, err := Decode(pkt.buf); err == nil { + return &snap + } + case <-time.After(200 * time.Millisecond): + case <-ctx.Done(): + return nil + } + } + return nil + } + + // stepToChannel channel_steps until we observe (AutoChannel=false, + // Channel=target). Cycle order on this firmware is + // 1 → 2 → 3 → 4 → auto → 1, so at most 5 steps reach any state. + stepToChannel := func(target int) error { + for step := 0; step < 8; step++ { + snap := readCurrentSnap() + if snap == nil { + return errors.New("could not read state for channel-step") + } + fmt.Fprintf(w, "# observed channel=%d auto_ch=%t (target ch=%d manual, step %d)\n", + snap.Channel, snap.AutoChannel, target, step) + if !snap.AutoChannel && snap.Channel == target { + return nil + } + out := make([]byte, ReportSize) + out[0] = cmdChannel + if err := writeReport(dev, out); err != nil { + return fmt.Errorf("write channel_step: %w", err) + } + time.Sleep(150 * time.Millisecond) + drain(80 * time.Millisecond) + } + return fmt.Errorf("could not reach manual channel %d", target) + } + + // stepToRange range_steps until snap.Range == target. Firmware + // requires manual channel for F3 to take effect (we already + // gate this in VerbAvailableInState), so the caller must have + // called stepToChannel first. Cycle is 5W → 10W → … → 10K → + // auto → 5W, 12 entries. + stepToRange := func(target string) error { + for step := 0; step < 14; step++ { + snap := readCurrentSnap() + if snap == nil { + return errors.New("could not read state for range-step") + } + fmt.Fprintf(w, "# observed range=%q (target %q, step %d)\n", snap.Range, target, step) + if snap.Range == target { + return nil + } + if snap.AutoChannel { + return errors.New("cannot range_step while in auto-channel; channel_step first") + } + out := make([]byte, ReportSize) + out[0] = cmdRange + if err := writeReport(dev, out); err != nil { + return fmt.Errorf("write range_step: %w", err) + } + time.Sleep(150 * time.Millisecond) + drain(80 * time.Millisecond) + } + return fmt.Errorf("could not reach range %q", target) + } + + originalSnap := readCurrentSnap() + if originalSnap != nil { + fmt.Fprintf(w, "# Starting state: top_mode=%q channel=%d auto_ch=%t range=%q\n", + originalSnap.TopMode, originalSnap.Channel, originalSnap.AutoChannel, originalSnap.Range) + } + originalMode := "" + if originalSnap != nil { + originalMode = originalSnap.TopMode + } + + // Drive channel + range first so the sample sweep sees a stable + // scale. Auto-channel can flip between channels mid-trace; auto- + // range can rescale the buffer's byte-to-watts mapping halfway + // through; both make analysis harder. + // + // IMPORTANT: F2 (channel) and F3 (range) are no-ops when the + // meter is on the waveform or spectrum LCD page — same context- + // sensitivity as F3 in auto-channel. If the meter starts in one + // of those modes, mode_step it to power_swr before adjusting. + if (opts.TargetChannel > 0 || opts.TargetRange != "") && originalMode != "" && originalMode != "power_swr" { + fmt.Fprintf(w, "\n# Switching to power_swr to allow F2/F3 control writes\n") + if _, err := stepToMode("power_swr", 8); err != nil { + fmt.Fprintf(w, "# WARN: %v — F2/F3 writes may be no-ops\n", err) + } + drain(150 * time.Millisecond) + } + if opts.TargetChannel > 0 { + fmt.Fprintf(w, "\n# Driving to manual channel %d\n", opts.TargetChannel) + if err := stepToChannel(opts.TargetChannel); err != nil { + fmt.Fprintf(w, "# WARN: %v — continuing anyway\n", err) + } + } + if opts.TargetRange != "" { + fmt.Fprintf(w, "\n# Driving to range %q\n", opts.TargetRange) + if err := stepToRange(opts.TargetRange); err != nil { + fmt.Fprintf(w, "# WARN: %v — continuing anyway\n", err) + } + } + + modes := []string{originalMode} + if opts.CycleModes { + modes = []string{"power_swr", "waveform", "spectrum"} + } + + for _, mode := range modes { + if mode != "" && opts.CycleModes { + fmt.Fprintf(w, "\n===== mode_step → %s =====\n", mode) + if _, err := stepToMode(mode, 8); err != nil { + fmt.Fprintf(w, "# %v\n", err) + continue + } + drain(150 * time.Millisecond) + } + // Baseline: cmd '0' (live telemetry) — slot should be zero or + // scope/spec residue if firmware streams regardless of mode. + if _, err := captureOne('0', 4); err != nil { + return err + } + // Then each sample cmd in turn. + for cmd := byte('1'); cmd <= byte('5'); cmd++ { + if _, err := captureOne(cmd, frames); err != nil { + return err + } + } + // And cmd '6' (status text) for comparison against + // known-ASCII slot content. + if _, err := captureOne('6', 4); err != nil { + return err + } + } + + if opts.CycleModes && originalMode != "" { + fmt.Fprintf(w, "\n# Restoring original top_mode=%q\n", originalMode) + _, _ = stepToMode(originalMode, 8) + } + fmt.Fprintln(w, "\n# done.") + return nil +} + +// formatFullFrame prints all 64 bytes of an IN report. In sample modes +// (cmds '1'..'5' while top_mode=waveform/spectrum) the firmware +// repurposes the whole report as sample data, so the bytes-40..63 +// "slot" abstraction doesn't apply; we have to see everything to spot +// the buffer layout. +// +// Output: hex (split as 0..7 / 8..15 / 16..39 / 40..63 for readability), +// then the same 64 bytes as u8 and u16BE summaries. +func formatFullFrame(w io.Writer, frame []byte, idx int) { + hexFull := hex.EncodeToString(frame) + // Per-byte u8. + u8parts := make([]string, ReportSize) + for i, b := range frame { + u8parts[i] = fmt.Sprintf("%3d", b) + } + // 16-bit big-endian view (32 values). + be := make([]string, ReportSize/2) + for i := 0; i < ReportSize/2; i++ { + be[i] = fmt.Sprintf("%5d", int(frame[2*i])<<8|int(frame[2*i+1])) + } + // Heuristic header: if byte[0] is in '0'..'?' (cmd-echo range) AND + // every other byte is zero, this is a command echo. + echo := frame[0] >= '0' && frame[0] <= '?' + if echo { + for i := 1; i < ReportSize; i++ { + if frame[i] != 0 { + echo = false + break + } + } + } + tag := "" + if echo { + tag = " [ECHO]" + } + fmt.Fprintf(w, " [%2d]%s b0=0x%02x b3=%d b4=%d b6=%d hex=%s\n", idx, tag, frame[0], frame[3], frame[4], frame[6], hexFull) + fmt.Fprintf(w, " u8 : %s\n", join(u8parts, " ")) + fmt.Fprintf(w, " BE : %s\n", join(be, " ")) +} + +func bytesEqual(a, b []byte) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func join(parts []string, sep string) string { + out := "" + for i, p := range parts { + if i > 0 { + out += sep + } + out += p + } + return out +} + // HasLPMeterAttached returns true when the host has at least one HID // whose product string matches LP-500 / LP-700, OR matches the default // Microchip VID:PID. Used by the `auto` backend selection in main. diff --git a/internal/lpmeter/snapshot.go b/internal/lpmeter/snapshot.go index f0a6b25..9472b43 100644 --- a/internal/lpmeter/snapshot.go +++ b/internal/lpmeter/snapshot.go @@ -8,7 +8,39 @@ // notes. package lpmeter -import "time" +import ( + "strconv" + "time" +) + +// SampleBytes is a []byte that JSON-encodes as an array of unsigned +// 8-bit integers (e.g. `[151, 151, 0, 8, ...]`) rather than the +// base64-encoded string Go's default []byte marshaler produces. +// Scope/spectrum buffers are conceptually arrays of small ints, and +// clients should be able to read them as such. +type SampleBytes []byte + +// MarshalJSON renders the buffer as a JSON array of decimal u8 values. +// Hand-written rather than `json.Marshal([]int)` to avoid allocating +// a separate int slice for every frame (these go on the hot path at +// the meter's poll rate). +func (s SampleBytes) MarshalJSON() ([]byte, error) { + if s == nil { + return []byte("null"), nil + } + // Each byte renders as 1-3 digits + a comma; 4 chars headroom is + // always enough. Plus the surrounding brackets. + out := make([]byte, 0, len(s)*4+2) + out = append(out, '[') + for i, b := range s { + if i > 0 { + out = append(out, ',') + } + out = strconv.AppendUint(out, uint64(b), 10) + } + out = append(out, ']') + return out, nil +} // Snapshot is the parsed state of the LP-500/700 at a single poll instant. // JSON tags define the wire shape sent to clients. @@ -65,6 +97,42 @@ type Snapshot struct { Valid bool `json:"-"` } +// SampleBufferSize is the total length of a scope or spectrum frame: +// the firmware splits its display buffer across OUT cmds '1'..'5', each +// returning a 64-byte IN frame whose entire payload is sample data. +// Concatenated in cmd order they form a single 320-byte buffer. +// Confirmed empirically 2026-05-15 — see CLAUDE.md "Scope and spectrum +// sample buffers". +const SampleBufferSize = 320 + +// ScopeFrame is a complete envelope-display snapshot, assembled from +// the 5-segment response to OUT cmds '1'..'5' while the meter is on +// the waveform LCD page (top_mode == "waveform"). 320 8-bit unsigned +// samples. +// +// Samples are NORMALIZED for the on-meter LCD trace — the firmware +// auto-scales each trace so the peak fits the display height. They +// describe the *shape* of the envelope but not absolute watts; for +// power readings use the matching Snapshot.PowerAvgW / PowerPeakW. +type ScopeFrame struct { + Timestamp time.Time `json:"-"` + TopMode string `json:"top_mode"` // always "waveform" for HID-backed frames + Channel int `json:"channel"` // last decoded telemetry value + AutoChannel bool `json:"auto_channel"` // last decoded telemetry value + Samples SampleBytes `json:"samples"` // length SampleBufferSize, u8 (marshals as JSON int array) +} + +// SpectrumFrame is a complete FFT-display snapshot, assembled the same +// way as ScopeFrame but while top_mode == "spectrum". 320 8-bit +// unsigned magnitudes, normalized to the meter's LCD bar height. +type SpectrumFrame struct { + Timestamp time.Time `json:"-"` + TopMode string `json:"top_mode"` // always "spectrum" + Channel int `json:"channel"` + AutoChannel bool `json:"auto_channel"` + Bins SampleBytes `json:"bins"` // length SampleBufferSize, u8 (marshals as JSON int array) +} + // CloseEnough returns true if two snapshots are equivalent for broadcast // purposes. Per-field deadbands suppress float jitter so we don't fan // out a frame on every polling cycle when the radio is keyed. diff --git a/main.go b/main.go index 4e04387..d0f323f 100644 --- a/main.go +++ b/main.go @@ -60,20 +60,28 @@ func main() { defer cancel() snapCh := make(chan lpmeter.Snapshot, 8) + // Sample-buffer channels: buffer one full assembly per type. If + // the hub falls behind, the owner drops to the next assembly + // rather than blocking the meter loop. + scopeCh := make(chan lpmeter.ScopeFrame, 2) + spectrumCh := make(chan lpmeter.SpectrumFrame, 2) pollEvery := time.Duration(cfg.Meter.PollMs) * time.Millisecond var source lpmeter.Source switch backend { case lpmeter.BackendHID: - source = lpmeter.NewHIDOwner(cfg.Meter.VendorID, cfg.Meter.ProductID, pollEvery, snapCh, logger) + source = lpmeter.NewHIDOwner(cfg.Meter.VendorID, cfg.Meter.ProductID, pollEvery, snapCh, scopeCh, spectrumCh, logger) case lpmeter.BackendSimulator: + // The simulator does not synthesize scope/spectrum buffers + // yet; the hub's nil-channel handling means no frames of + // those types will be broadcast under the simulator backend. source = lpmeter.NewSimulator(pollEvery, snapCh, logger) default: logger.Error("unknown backend", "backend", backend) os.Exit(1) } - h := hub.NewHub(snapCh, source, hub.Options{ + h := hub.NewHub(snapCh, scopeCh, spectrumCh, source, hub.Options{ Heartbeat: time.Duration(cfg.Server.HeartbeatMs) * time.Millisecond, MaxClients: cfg.Server.MaxClients, AllowControl: cfg.Server.AllowControl, @@ -169,6 +177,11 @@ func runProbeSubcommand(args []string) int { dump := fs.Bool("dump", false, "open the matched LP-500/700 and print every IN report (raw + best-effort decode) until ^C") capture := fs.String("capture", "", "open the matched LP-500/700 and write IN reports to this path until duration elapses") duration := fs.Duration("duration", 0, "for -capture, how long to record (0 = until ^C)") + samples := fs.Bool("samples", false, "cycle OUT cmds '1'..'5' and dump the secondary slot (bytes 40..63) — reverse-engineering aid for scope/spec buffers") + cycleModes := fs.Bool("cycle-modes", false, "for -samples: mode_step through power_swr / waveform / spectrum and repeat the cmd sweep in each") + framesPerCmd := fs.Int("frames-per-cmd", 16, "for -samples: number of IN frames captured per OUT cmd") + targetChannel := fs.Int("channel", 0, "for -samples: channel_step until on manual channel N (1..4) before the sweep; 0 = leave as-is") + targetRange := fs.String("range", "", "for -samples: range_step until on this range (e.g. 100W, 1K, auto) before the sweep; requires -channel set or already-manual channel") vid := fs.Uint("vid", 0, "match this vendor id (hex, e.g. 0x0000); 0 = match by product string") pid := fs.Uint("pid", 0, "match this product id; 0 = match by product string") fs.Parse(args) @@ -181,8 +194,10 @@ func runProbeSubcommand(args []string) int { mode = lpmeter.ProbeDump case *capture != "": mode = lpmeter.ProbeCapture + case *samples: + mode = lpmeter.ProbeSamples default: - fmt.Fprintln(os.Stderr, "usage: lp700-server probe [-list | -dump | -capture [-duration 10s]] [-vid 0xNNNN -pid 0xNNNN]") + fmt.Fprintln(os.Stderr, "usage: lp700-server probe [-list | -dump | -capture [-duration 10s] | -samples [-cycle-modes] [-frames-per-cmd N] [-channel N] [-range NAME]] [-vid 0xNNNN -pid 0xNNNN]") return 2 } @@ -190,11 +205,15 @@ func runProbeSubcommand(args []string) int { defer cancel() err := lpmeter.RunProbe(ctx, lpmeter.ProbeOptions{ - Mode: mode, - OutPath: *capture, - Duration: *duration, - VendorID: uint16(*vid), - ProductID: uint16(*pid), + Mode: mode, + OutPath: *capture, + Duration: *duration, + VendorID: uint16(*vid), + ProductID: uint16(*pid), + FramesPerCmd: *framesPerCmd, + CycleModes: *cycleModes, + TargetChannel: *targetChannel, + TargetRange: *targetRange, }, os.Stdout) if err != nil { fmt.Fprintln(os.Stderr, "probe:", err)