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
33 changes: 33 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 101 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -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.
8 changes: 6 additions & 2 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion examples/node-red/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>: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. |

Expand Down
44 changes: 43 additions & 1 deletion internal/hub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,14 +47,16 @@ 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.
CheckOrigin: func(r *http.Request) bool { return true },
},
source: source,
snapIn: snapIn,
scopeIn: scopeIn,
spectrumIn: spectrumIn,
register: make(chan *client, 16),
unregister: make(chan *client, 16),
resync: make(chan *client, 16),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
Loading
Loading