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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,12 @@ temporal layer and injects each at its ladder's rate
(RS outer code + corrupt-frame salvage) lives in `tools/precoder/`
(`docs/fused-fec.md`).

Per-frame TX-side timing for the stream link (`src/StreamTelemetry.h`,
`examples/common/stream_timing_tx.h`): what each frame carries, which parts
MAC-stamp an injected frame (`AdapterCaps::hw_injected_mgmt_txtsf`) and the
measured matrix are in `docs/stream-timing.md`; harness
`tests/stream_timing_onair.sh`.

## Frequency hopping

`IRadio::FastRetune(channel)` — lean intra-band, same-bandwidth retune on
Expand Down
7 changes: 7 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ add_library(devourer
src/hopset/HopsetState.h
src/hopset/HopsetTypes.h
src/hopset/HopsetWire.h
src/StreamTelemetry.h
src/hopset/HopsetAuthority.h
src/hopset/HopsetFollower.h
src/hopset/HopsetPolicy.h
Expand Down Expand Up @@ -1427,6 +1428,12 @@ add_executable(HopsetWireSelftest tests/hopset_wire_selftest.cpp)
target_link_libraries(HopsetWireSelftest PRIVATE devourer)
add_test(NAME hopset_wire_kat COMMAND HopsetWireSelftest)

# Headless guard for src/StreamTelemetry.h (the addr3 per-frame field + the
# timing marker IE): known-answer bytes, version rejection, TSF unwrap, latency.
add_executable(StreamTelemetrySelftest tests/stream_telemetry_selftest.cpp)
target_link_libraries(StreamTelemetrySelftest PRIVATE devourer)
add_test(NAME stream_telemetry_kat COMMAND StreamTelemetrySelftest)

add_executable(HopsetProtoSelftest tests/hopset_proto_selftest.cpp)
target_link_libraries(HopsetProtoSelftest PRIVATE devourer)
add_test(NAME hopset_proto_matrix COMMAND HopsetProtoSelftest)
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ per-chip quirks notes at the bottom.
controller design, [its validation](docs/adaptive-link-validation.md), and
the [building blocks](docs/adaptive-link-building-blocks.md): what each knob
(power, rate, bandwidth, hopping) measurably buys.
- [Stream timing](docs/stream-timing.md) — per-frame air-side timing in the
stream link: backlog, capture→send and a hardware-clocked one-way latency
per frame, carried in header bytes no receiver otherwise reads.
- [Fused FEC](docs/fused-fec.md) — the cross-layer error-protection stack:
per-layer PHY rates, corrupt-frame salvage, outer erasure code.
- [Aggregation & hardware ACK](docs/aggregation.md) — USB TX aggregation,
Expand Down
8 changes: 5 additions & 3 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets];
| ev | emitter | fields |
|---|---|---|
| `rx.pkt` | RX | n, len (first 10 + every 100th frame) |
| `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, paggr, ppdu, fc1 (FC flags byte; bit3 = 802.11 RETRY), sa hex, body hex; `tx_tsf` (sender's hardware egress TSF) on beacons/probe-responses only. SA gate: canonical-SA with `DEVOURER_RX_AGG_SA` unset; when set, that filter ("canon"/mac/"any") selects the stream instead |
| `rx.frame` | RX (`DEVOURER_STREAM_OUT`), duplex | rate, len, crc, icv, rssi[2], evm[2], snr[2], seq, tsfl, bw, stbc, ldpc, sgi, paggr, ppdu, fc1 (FC flags byte; bit3 = 802.11 RETRY), sa hex, fc0 (frame-control type/subtype byte: 0x40 stream data, 0x50 timing marker, 0x80 beacon), a3 hex (addr3 verbatim, frames ≥ 24 bytes), body hex; `tx_tsf` (sender's hardware egress TSF) on beacons/probe-responses only. When addr3 decodes as the stream timing field (`src/StreamTelemetry.h`, RX only): tel=1, depth (frames behind this one on the TX host), c2s_us (capture→send_packet; cap=1 when from the producer's CAPTURE_TS stamp, else from the stdin read), and once the receiver has fitted the transmitter's clock from its egress-stamped frames: lat_us (predicted submit→hardware arrival, one-way) and c2a_us (capture→arrival, cap=1 frames). SA gate: canonical-SA with `DEVOURER_RX_AGG_SA` unset; when set, that filter ("canon"/mac/"any") selects the stream instead |
| `rx.timing` | RX | the transmitter's stream timing marker (probe response, canonical SA) decoded: frames, tq_p50_us, tq_max_us (stdin read→send), tw_p50_us, tw_max_us (send_packet wall time), c2s_p50_us, c2s_max_us, depth_max, captured, tsf_pred, tx_fit_ppm, tx_fit_n, presp_stamped (this part's injected probe responses carry a hardware egress TSF), beacon (a TBTT beacon carries the clock instead), tx_async; plus this receiver's clock fit: tsfl, pairs, rx_fit_ppm, rx_resid_us, rx_resets (a transmitter restart resets its TSF; the fit starts over past 50 ms) |
| `rx.body` | RX (`DEVOURER_DUMP_BODY`) | rate, rssi[2], evm[2], snr[2], crc, len, body hex |
| `rx.corrupt` | RX (`DEVOURER_RX_DUMP_ALL`) | len, crc, icv, rate, bw, stbc, ldpc, sgi, rssi[2], evm[2], snr[2] |
| `rx.txhit` | RX, TX | hits, total_rx, len, seq, paggr, ppdu, rate, bw, stbc, ldpc, ppdu_type — canonical-SA (57:42:75:05:d6:00) matcher; rate/ldpc prove what encoding was decoded (8814A reports ldpc=0 always — no HW indicator); ppdu_type is the AX RXD format nibble (7=HE_SU, 8=HE_ERSU; 255 on pre-AX chips) |
Expand Down Expand Up @@ -185,8 +186,9 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets];
### Stream demos / misc
| ev | emitter | fields |
|---|---|---|
| `stream.rx` / `stream.ctl` / `stream.eof` / `stream.tx` | duplex (stdout), streamtx (stderr) | hits / op, len / tx_count, [bytes] / n, ok, psdu, [total] |
| `stream.done` | streamtx (stderr) | sent |
| `stream.rx` / `stream.ctl` / `stream.eof` / `stream.tx` | duplex (stdout), streamtx (stderr) | hits / op, len, [applied 0 — streamtx reports the live-knob opcodes it does not apply; op 4 CAPTURE_TS is consumed silently by both] / tx_count, [bytes] / n, ok, psdu, [total] |
| `stream.timing` | streamtx, svctx, duplex (`DEVOURER_STREAM_TIMING=N`, every N data frames) | ok (the marker frame's send), frames, tq_p50_us, tq_max_us, tw_p50_us, tw_max_us, c2s_p50_us, c2s_max_us, depth_max, captured, tsf_pred, fit_ppm, fit_n, fit_resid_us, fit_resets, fit_unsupported (the host↔TSF fit, one ReadTsf per 100 ms; a reset = the chip's TSF jumped under it; unsupported = ReadTsf returns 0 on this part), presp_stamped, beacon, tx_async — the same window the marker carries on air (`examples/common/stream_timing_tx.h`) |
| `stream.done` | streamtx (stderr) | sent, capture_dropped (CAPTURE_TS stamps that no record followed) |
| `svc.stats` | svctx | frames, crit, t0, t1, t2, t3plus |
| `doctor.verdict` | doctor | verdict, reasons "0x…", efuse_reads, efuse_mismatch, efuse_bad_id, efuse_id, fw_attempted, fw_ready, rx_ok, rx_crc, init |

Expand Down
163 changes: 163 additions & 0 deletions docs/stream-timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Per-frame air-side timing in the stream link

What the transmitter knew about a frame when it sent it — how long the frame
waited on the host, how deep the backlog behind it was, when the producer
captured it — travels with the frame, in header bytes no receiver otherwise
reads, so the ground station can show a measured latency per frame and per
stage instead of an estimate. The idea is kestrel-air's slice-header
telemetry; the clock underneath is devourer's own: the MAC TSF.

## What travels, and where

**Every stream frame** (`streamtx`, `svctx`, `duplex`) carries six bytes in
the 802.11 header's addr3 — the BSSID field of a probe request, which the
stream demos used to fill with the source address and which no consumer reads
(every one keys on addr2 and slices the body at +24). The FEC bodies are
byte-for-byte untouched and the MTU is unchanged:

| bytes | field |
|---|---|
| 0 | version and flags: capture stamp present, TSF present, depth present, async TX |
| 1 | depth: frames handed to the transport whose completion has not been reaped |
| 2–3 | the transmitter's predicted TSF at `send_packet`, low 16 bits in 10 µs units |
| 4–5 | capture → `send_packet`, 10 µs units, clipped at 655 ms |

The version is chosen so the old addr3 contents (the canonical SA) decode as
"no telemetry" rather than as garbage values.

**A periodic marker** (`DEVOURER_STREAM_TIMING=N`, every N data frames, its
own frame like the hop sync marker) carries the absolute pair (predicted TSF,
host clock), the state of the transmitter's host↔TSF fit, and the window since
the previous marker: p50/max of stdin-read→send, of the `send_packet` wall
time (kestrel-air's T_WRITE), of capture→send, the deepest backlog, and how
many frames had a producer stamp. The marker is a probe response with the
canonical SA, and on the parts whose MAC stamps an injected probe response
with its egress TSF, the marker's own timestamp field is the hardware pair
the receiver's clock fit needs.

**The producer's capture time** arrives through the stdin control escape
shared with the duplex demo's live knobs: a length word with its top bit set
introduces `<op><args>`; opcode 4 is `CAPTURE_TS <u64 LE ns, CLOCK_MONOTONIC>`
and applies to the next record. The Python producers emit it with
`--capture-ts` (and `--capture-delay N:MS` for the check below). A stamp taken
at record emission is a proxy; a real encoder stamps at capture.

## The clock, and why the marker must be hardware-stamped

The transmitter never reads a register per frame (the standing send-path
rule: a control transfer is ~200–340 µs on USB). A poller samples `ReadTsf()`
once per 100 ms against the host's monotonic clock and a least-squares line
predicts the TSF at every `send_packet` call. Its residual is the read-latency
jitter, tens of µs. A sample more than 50 ms off the line means the chip's TSF
was reset under the fit (arming the Jaguar1 beacon pulses it; a re-init zeroes
it), and the fit starts over rather than averaging across the jump: a fit
that averages across the beacon arm reads a 62 ms "latency" that decays over
the session as the poisoned intercept washes out (measured on the 8821AU,
which is why the beacon is armed before the fit samples).

The receiver maps each frame's hardware arrival (`tsfl`) onto the
transmitter's TSF with a fit of its own, and that fit is fed **only from
hardware egress pairs**: frames whose timestamp field the transmitter's MAC
wrote at the instant of transmission. A pair built from a software stamp would
fold the mean one-way latency into the fit's offset and leave only jitter.
Which frames qualify was measured, not assumed
(`tests/probe_resp_egress_tsf_check.sh`, a constant in the field and an
independent witness reading it back):

| transmitter | injected probe response / beacon | hardware TBTT beacon |
|---|---|---|
| Jaguar2 8812BU | stamped, 34 µs spread | stamped |
| Jaguar3 8812CU, 8812EU | stamped, 38–41 µs spread | stamped |
| Kestrel 8832CU | stamped, 35 µs spread | stamped |
| Jaguar1 8821AU | **not a TSF**: a counter that is neither TSF port, held ~7 frames at a time | stamped, 3.2 µs spread |

So on Jaguar2, Jaguar3 and Kestrel the marker frame itself is the pair, on
any channel, hopping included. On Jaguar1 the marker's flags say its stamp is
not to be trusted and, on a fixed channel, the transmitter arms the hardware
beacon with the same SA as the clock carrier (`AdapterCaps::hw_injected_mgmt_txtsf`
is the per-part fact). A hopping Jaguar1 session has no egress pair the
beacon can follow and reports stage durations without an absolute latency;
the 8812AU and 8814AU are unmeasured and treated as the 8821AU. The receiver
also restarts its fit when a pair lands more than 50 ms off the line: a
transmitter that restarts resets its TSF.

The per-frame one-way latency is then arrival − unwrapped predicted submit
TSF: host jitter on the transmit side only, hardware on the receive side.

## Measured

`tests/stream_timing_onair.sh`, one CF-924AC (8822BU) witness, 20 s runs,
producer paced at 2 ms. The floor is measured first: identical runs,
repeated, so a later difference has something to be judged against.

| transmitter | runs | submit→air p50 (µs) | run-to-run sd | capture→air p50 | capture→send p50 | depth max | fit residual |
|---|---|---|---|---|---|---|---|
| Jaguar3 8812CU, ch6 | 5 | 98–146 | 3–16 | 148–172 | 30 | 0 | < 1 µs |
| Kestrel 8832CU, ch6 | 2 | 90 | 0 | 126 | 30 | 0 | < 1 µs |
| Jaguar1 8821AU, ch6 (beacon clock) | 5 | 358–1337 | 34 (first three) | 436–1373 | 30–40 | 2–19 | < 1 µs |
| Jaguar3 8812EU, ch36, producer at 15 ms | 2 | 109–122 | 6 | 150 | 30 | 0 | < 1 µs |
| Jaguar3 8812CU, `svctx` (no producer stamp) | 1 | 155 | — | — | — | 0 | < 1 µs |
| Jaguar3 8812CU, `duplex` (TX+RX on one chip) | 1 | 124 | — | 150 | 30 | 0 | < 1 µs |

The checks every cell passes or fails on:

- **The producer delay is recovered.** With `--capture-delay 10:20` the
producer sleeps 20 ms between the stamp and every 10th record: the receiver
sees 10.2% (Jaguar3) and 9.8% (Kestrel) of stamped frames standing 20.08
and 20.09 ms above the rest. The field tracks the producer, not the pipe.
- **Hopping survives.** A slot-hopping Jaguar3 transmitter (1/6/11 at 50 ms)
keeps the marker and the clock through retunes — 4837 frames with an
absolute latency in 20 s — and the window's stdin-read→send maximum, 5.3 ms,
is the retune showing up where it should.
- **Corrupted frames never feed the clock.** With the witness keeping
CRC-failed frames the fit's residual stays below 1 µs.

The adversarial readings, in the same breath:

- **The depth field is honest about the transport.** Jaguar1's asynchronous
bulk-OUT shows a backlog of 2 to 19 URBs and a 400 µs to 1.3 ms one-way
figure, run to run, where the synchronous families show 0 and ~100 µs. That
is the transport's shape (the async path blocks only at 256 in flight), not
a defect the telemetry found — and the reason the field exists.
- **A hardware beacon outlives the process that armed it.** On Jaguar1 the
timing clock rides `StartBeacon`, and a transmitter killed by SIGTERM left
its beacon airing at 100 TU with the canonical SA; the next transmitter's
witness then saw two clock sources and its fit reset on every pair. The TX
demos now end on SIGINT/SIGTERM through their ordinary exit path (beacon
disarmed, device stopped — verified: zero canonical-SA beacons on the air
after a timeout-ended run), and the receiver takes beacon pairs only when
the live marker says a beacon carries the clock.
- **A producer that outruns the chip pins capture→send at its clip.** The
8812EU on 5 GHz stalls its synchronous send for the full 20 ms bulk-OUT
timeout now and then (the window's send-time maximum reads 20.99 ms, the
known 5 GHz flood behaviour of that module, `docs/8822e-quirks.md`); at a
2 ms producer pace the stdin pipe fills and every capture→send reads
655 ms — a true reading of the backlog, and useless as a steady-state
number (that run's submit→air median was 206–235 µs, with a 10 ms p99 from
frames queued behind the stalls). The harness runs that part at a 15 ms
pace (`PACE_US`), where it reads like the 8812CU.
- **A record pushed during bring-up wedges the duplex TXDMA.** The duplex
demo spawns its TX thread before the chip is up; a feeder that writes at
once gets every send timed out for the whole run, marker or no marker. The
harness's feeder idles 12 s as the ARQ harness's does, and the timing fit
arms itself on the first record rather than at thread start, so no
register read or beacon arm lands inside a bring-up on any demo.
- **The first seconds are the pipe, not the link.** The producer fills the
stdin pipe while the chip is brought up, so the first ~1000 records arrive
with stamps seconds old. The analyzer drops a 4 s warm-up for that reason.
- **svctx has no producer stamp.** It pre-reads and replays a clip, so its
capture→send is each NAL's loop iteration; a live stdin mode is a follow-up.

## Reading it

`rxdemo` with `DEVOURER_STREAM_OUT=1`: every `rx.frame` carries `fc0` (so a
consumer can keep the marker and a Jaguar1 beacon out of its video
accounting: only `0x40` frames carry the stream envelope) and `a3`, and when
addr3 decodes, `tel`, `depth`, `c2s_us`, `cap`, and — once the receiver has
the transmitter's clock — `lat_us` and `c2a_us`. A receiver without a
hardware RX stamp (`hw_rx_timestamp` false, the MT7612U) decodes the field
and the marker but never fits a clock or reports a latency. Each marker is an `rx.timing`
event with the transmitter's window and this receiver's fit state; the
transmitter logs the same window as `stream.timing`. Schema: `docs/logging.md`.
`tests/stream_timing_analyze.py` turns one capture into a summary line and the
checks above.
Loading
Loading