Skip to content

hub: assemble scope and spectrum WebSocket frames - #2

Merged
VU3ESV merged 1 commit into
VU3ESV:mainfrom
vu2cpl:fix/scope-spectrum-frames
May 23, 2026
Merged

hub: assemble scope and spectrum WebSocket frames#2
VU3ESV merged 1 commit into
VU3ESV:mainfrom
vu2cpl:fix/scope-spectrum-frames

Conversation

@vu2cpl

@vu2cpl vu2cpl commented May 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Surfaces the LP-500 / LP-700's on-LCD scope (waveform) and spectrum (FFT)
buffers over the existing /ws endpoint as two new frame types:

{"type":"scope",    "data":{"samples":[151,151,...len=320], "channel":1, ...}}
{"type":"spectrum", "data":{"bins":[77,227,...len=320],     "channel":1, ...}}

While the meter is on the waveform or spectrum LCD page, the HID owner
interleaves a 6-tick cycle (cmd '0', then sample cmds '1'..'5')
instead of the legacy cmd '0' / cmd '6' cycle, assembles the
5 × 64-byte segments into one 320-byte buffer, and emits ~4 Hz. In
power_swr / setup modes the legacy cycle is preserved — no
overhead is paid where it isn't useful.

Why

The firmware exposes the scope and spectrum buffers via cmds '1'..'5',
but until now we ignored them — the Mac and web clients couldn't
mirror the LCD's Waveform / Spectrum pages. The wire format wasn't
publicly documented; a new probe -samples subcommand was added to
reverse-engineer it against the bench LP-700, then this change
productionises it.

The interesting design call: frame routing is by SHAPE, not write order

An earlier iteration matched each IN frame to the OUT cmd that
produced it via a 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 and clients see
nonsensical telemetry (swr: 20.52, peak < avg, channel /
peak_mode / top_mode flapping without commands, ASCII-leaked status
garbage). All of those were observed in production with the FIFO.

This PR uses shape-based classification instead. Three classes:

  1. Echobyte[0] in '0'..'?' AND rest zero. Firmware refused
    the OUT. Dropped.
  2. Telemetry — passes isLikelyTelemetry in owner.go: byte-3
    ≤ 3 (top_mode), byte-4 ≤ 4 (channel), byte-5 ≤ 4, byte-6 ≤ 11
    (range), byte-7 ≤ 1 (alarm flag), byte-8 ≤ 2 (peak_mode), plus
    two power-coherence invariants (peak ≥ avg; SWR raw ≤ 1000).
    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. A sample counter resets on every telemetry frame and on
    every top-mode change, so transient desync self-corrects within
    one cycle.

No per-write state that can desync.

Secondary fixes

  • scope / spectrum emit is now gated on channel ∈ {1..4} AND
    !auto_channel. Auto-channel × waveform/spectrum is a
    hardware-invalid combination on this firmware; sample buffers in
    that state are indeterminate.
  • extractStatusMessage now requires both an ASCII space and a
    3-letter run, rejecting sample-byte leakage like
    ?BFILORUY|_bfilorvy| that the prior ≥75 %-printable threshold
    let through.
  • probe -samples mode_steps to power_swr before driving -channel
    / -range, since F2/F3 are no-ops on the waveform/spectrum LCD
    pages.

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.

Test plan

  • go vet ./... clean
  • go test -race ./... clean (new tests cover SampleReport,
    isCommandEcho, isLikelyTelemetry with the production
    leak-path frames, and extractStatusMessage rejecting
    sample-byte leakage)
  • probe -samples -cycle-modes against the bench LP-700
    confirmed the 320-byte buffer is contiguous across cmds and
    that segments straddle cmd boundaries (spectrum cmd '1' tail
    0b 0b blends into cmd '2' head 0b 0b 0b 0b 0b 0b 0b)
  • Cross-built linux/arm64, deployed to a live LP-700 over
    hidraw, ran a 20-second WS verify:
    - 71 telemetry frames + scope/spectrum frames
    - swr > 5: 0, peak < avg: 0, peak_mode flap: 0
    - samples / bins JSON-deserialize as list of length 320
    - Control verbs and the heartbeat still work as before

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
@VU3ESV
VU3ESV merged commit 81abd3b into VU3ESV:main May 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants