Skip to content

p2plink: range-first 1:1 video + IP link with opportunistic hardware ACK (v1, pre-hardware) - #4

Draft
snokvist wants to merge 40 commits into
masterfrom
ccr-2c660d65-jhhngr
Draft

snokvist wants to merge 40 commits into
masterfrom
ccr-2c660d65-jhhngr

Conversation

@snokvist

Copy link
Copy Markdown
Owner

What

A new self-contained subproject p2plink/ that builds against the sibling devourer tree: a standalone 1:1 radio layer (drone RTL8812EU → ground 8812EU and/or MT7612U ears) carrying an H.265 stream from the waybeam encoder frame ring plus an IP tunnel. Range and link robustness are the objective, not throughput.

Nothing outside p2plink/ is touched. The branch sits on upstream OpenIPC/devourer master f46c28a (the fork was fast-forwarded first).

Mechanism

  • addr1 is the ACK policy. Video and reports go to a group DA (never ACKed, never retried); the 100 ms probe and in-range IP go unicast to the ground ACK target (hardware ACK, retry limit 3). On Realtek the radiotap NOACK bit is ignored and BMC derives from addr1, so the DA switch is the only per-frame lever.
  • The probe's hardware ACK is the primary uplink sensor; the 10 Hz LinkReport is secondary. ACKs OK + no reports = HOLD the rung (ground hears us, its reports don't reach us); no ACKs + no reports = descend to the floor.
  • Ladder of 7 rungs, 20/40 MHz frames only, ground parked at 40 MHz, drone picks width per frame (a 40-parked receiver decodes a 20 MHz frame on its primary with no retune). No 5/10 MHz anywhere (the MT7612U ear cannot decode it).
  • Blind descent: report timeout 500 ms → encoder budget + FEC drop two rungs immediately, MCS follows 0.5 s later, floor within ~2.5 s. Promotion needs 5 s dwell + loss/RSSI/probe-rung evidence.
  • N-ear merge: first copy wins per (stream, block, symbol); separate emit/retire cursors; silent-ear exclusion; Reed-Solomon erasure FEC (zfec-compatible).

Contents

piece files tested
wire format + LinkReport/StatsSnapshot include/p2p/wire.h, report.h wire_test
RS erasure FEC fec.h, src/fec.cpp fec_test (every C(12,4)/C(11,3) erasure pattern)
N-ear merge rx_merge.h merge_test (132 checks)
link controller + DA machine + scheduler link_ctl.h, budget.h link_ctl_test, budget_test (186 checks)
encoder frame ring (waybeam-compatible), tun, stats shm/NDJSON frame_shm.*, tun.*, stats_pub.* frame_shm_test
devourer glue radio.h, src/radio.cpp never run on hardware
apps src/p2p_tx.cpp, src/p2p_rx.cpp, src/p2p_feed.cpp --help/error paths only
docs docs/DESIGN.md (spec as built), docs/HANDOVER.md (bench cells C1–C9), README.md —

Build: cmake -S p2plink -B build-p2p -DCMAKE_BUILD_TYPE=Release -DP2P_WITH_MT7612U=ON && cmake --build build-p2p -j && ctest --test-dir build-p2p -L p2p — warning-free, 6/6 suites pass.

Not yet verified (why this is a draft)

All of radio.cpp and both apps have never touched an adapter. docs/HANDOVER.md lists the six design assumptions the bench must confirm first: 8812EU as the soliciting transmitter (only ever measured as responder), 8812EU→MT7612U ACK, 20 MHz frames into ears parked at 40 MHz on these dies, the per-unit wide_tx_igi_floor edge and its unmeasured uplink cost, MT7612U 40 MHz receive, frame-length PER at the floor. Review and hardware trials happen in a local session against this branch; fixes land as further commits here.

Known gaps carried as follow-ups

Frame size fixed at 1400 B (not a rung); ground IP broadcast-only; no encryption; no channel migration; the "no probe ACK for 30 s → cap duty" rule; encoder floor (resolution/fps) co-design.


Generated by Claude Code

snokvist and others added 19 commits October 29, 2026 08:23
…ts publisher

New self-contained subproject under p2plink/ (builds against the sibling
devourer tree): a range-first 1:1 video + IP link with opportunistic hardware
ACK. This commit carries the shared interfaces and the device-free io glue;
the pure cores (FEC, N-ear merge, link controller, scheduler) and the radio
glue follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
…rmonde)

RsCodec(k,n) with precomputed repair rows, allocation-free encode/decode,
submatrix inversion on erasure; block_symbol_len helper. Test sweeps every
C(12,4) and C(11,3) erasure pattern plus the k=n / k=1 edges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
…ck assembly)

BlockMerger: per-(stream,block,idx) dedup across ears, emit-on-decodable with
a separate retire cursor so trailing repair symbols are never counted lost,
three gap rules, silent-ear exclusion, per-rung ok/lost accounting and the
LinkReport builder; SeqDedup for the IP/probe/report classes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
Ladder table, LinkController (TRACK/BLIND/HOLD/FLOOR), DaMachine and the
airtime scheduler headers as they stand mid-build; the final form and the
budget test follow in the next commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
…tests

LinkController TRACK/BLIND/HOLD/FLOOR with budget-leads-phy demotes and
dwell-gated promotes; DaMachine ratio-based ACK mode; TxScheduler strict
priority with interactive token bucket and airtime-bounded video queue.
186 checks across link_ctl_test and budget_test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
…DOVER greps)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
…lease build

Adapter (per-adapter libusb context, RX fan-in off the event thread, TX
mutex, tx.report tag join), p2p_tx (frame ring + tun -> scheduler -> FEC
blocks; probes; LinkController/DaMachine; 20/40 tune follow), p2p_rx
(N ears -> BlockMerger -> UDP/shm/tun; 10 Hz LinkReport on the uplink),
p2p_feed (synthetic / Annex-B replay into the ring). Sentinels replace the
optionals GCC -O3 flagged; tests labelled p2p; DESIGN.md implementation notes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dRefy4rsgbUwdnp6m6K3W
claim_interface_then_reset leaves the handle stale when the reset
re-enumerates the adapter; the reopen variant waits for the same bus/port
to come back and replaces the handle in place, which is what every demo
that opens arbitrary dies uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p starts

StopRxLoop only sets a flag, and both Jaguar3 and MT7612U StartRxLoop clear
that flag on entry (the loop is restartable). A stop that lands while an
rx-only ear's Init is still in bring-up, or before the duplex worker thread
reaches StartRxLoop, is wiped and the join waits forever. Re-assert the stop
every 50 ms until the worker is seen to exit. The stopped flag becomes
atomic: send/retune read it from the TX thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ery frame

Only probes recorded their SW_DEFINE tag, and only after send_packet
returned. The tag is 8 bits and advances on every frame (Jaguar3 stamps it
whatever the sampling), so a probe whose report was lost left a valid slot
that a unicast IP frame's report could claim 256 frames later, and a report
consumed before the post-send write was dropped as not-a-probe.

Adapter::send now takes a hook it calls under the TX mutex immediately
before send_packet with the tag the frame will carry; the drone records
every frame's kind there, so a slot always names the latest frame with that
tag, and a send that fails clears its slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… symbol

First light (8812EU drone, MT7612U ground, R2, 30 fps synthetic feed with
an IDR every second) lost exactly one block per second: every IDR. Rule (3)
declared a block lost 20 ms after its FIRST symbol, but a block's own
airtime is k + repair frames back to back, which exceeds 20 ms for every
IDR at every rung and for most blocks at R0 (~1.8 ms per 1400 B frame). The
block was declared while its symbols were still arriving.

Blocks now carry last_ms (any copy, duplicates included) and rule (3) fires
20 ms after it; a gap in front of a block is still dated from that block's
first symbol. New merge_test case (c3): symbols at 7 ms spacing complete the
block, and a block that goes quiet is declared at last + 20 ms. Rerun:
760/760 blocks delivered (25 via FEC), 0 failed. DESIGN.md "RX merge"
updated in the same commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HANDOVER told the bench to back power off with DEVOURER_TX_PWR, but the
library reads no environment and p2plink builds its own DeviceConfig, so
that variable never reached the chip and the apps had no power lever at
all. RadioCfg gains txpwr_offset_qdb, applied with SetTxPowerOffsetQdb right
after InitWrite on duplex adapters (the drone; the ground uplink), with the
applied value logged. HANDOVER's near-field paragraph and CLI contract
updated in the same commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --tun and --uplink none the tun fd was polled but never read, so poll()
returned at once forever and the ground spun at 100 % CPU. The tun is now
polled only when there is an uplink to send it on. The ground's tx_timeouts
was last_was_timeout as 0/1; it now accumulates the same way the drone's
does (failures that landed while the last failure was a bulk-OUT timeout).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
First light at --fixed-rung 2 received 255 reports yet published
report_age_ms = UINT32_MAX and walked TRACK -> BLIND -> HOLD -> FLOOR,
because reports were only fed when the controller was adaptive. The DA
machine needs TRACK, so a pinned drone could never reach Ucast — which is
one of HANDOVER's first-light criteria. Rungs stay pinned (the controller's
rung outputs are ignored in fixed mode); its state and ages now reflect the
uplink. Rerun: report_age ~20 ms, TRACK throughout, Ucast at +1.5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
devourer logs every successful bulk-OUT at info (UsbTransport
"bulk_send EP n OK", one stderr line + flush per frame): ~11.4k lines in a
30 s R2 first-light run, and proportionally more at the top rungs, for no
information. Everything p2plink itself reports at info is also carried by
its NDJSON events (p2p.ready has the ack_target_mac). Upstream note: that
line belongs at trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@snokvist

Copy link
Copy Markdown
Owner Author

Milestone: review + first light (hardware, 2026-10-29). Bench has one 8812EU, so every result is 8812EU drone (1-1, USB2) → MT7612U ground (8-1). C1 and C3's 8812EU ear are deferred until the second 8812EU is available.

Review fixes (one commit each):

commit defect
fe3b003 open through claim_interface_reset_reopen (a re-enumerating reset left the handle stale)
457af4b stop() could hang forever: StartRxLoop clears a StopRxLoop that lands before it
f66b2e0 TX-report tag join: stale probe slot could claim a unicast frame's report after the 8-bit wrap; slot now written before send_packet
00192cf merger rule (3) dated from the first symbol → every IDR block lost; now a quiet dwell from the last symbol (+ test, DESIGN.md)
1c09184 --txpwr-offset-qdb on both apps; DEVOURER_TX_PWR was inert (the library reads no env)
c5ce1d6 ground spun at 100 % CPU with --tun and --uplink none; tx_timeouts count
70814cf --fixed-rung drone never fed reports → FLOOR, no Ucast
8adb0a1 default --log-level warn

First light, ch 100 HT40+, R2, −10 dB power offset, near field (−37 dBm), run 2:

criterion result pass
uniq vs video frames sent 434/s vs 436/s; 760/760 blocks (25 via FEC) PASS (single ear, saturation not excludable: MT7612U has no EVM)
fec_fail‰ 0 PASS
ack8 / retries / report coverage 8 / 0.00 / 250 of 250 PASS (ON arm only)
report_age_ms 19–21, TRACK PASS
p2p.da Ucast at +1.5 s PASS
tx_timeouts / tx_failed 0 / 0 PASS (25 s)
ping via tun 39/40 fwd, 19/20 rev PASS (1 loss each way unexplained)
diversity n/a, one ear NOT RUN

Upstream note (not changed here): UsbTransport logs every successful bulk-OUT at info (bulk_send EP n OK), one stderr line + flush per frame. That line probably belongs at trace.

snokvist and others added 5 commits October 29, 2026 10:34
Every per-sender counter (probe/IP seq, LinkReport epoch) begins again at
0 when a process restarts, but the receivers treated them as monotonic for
the life of the link: after a ground restart the drone refused every report
as stale until the new epoch overtook the old one (minutes into a session;
C5(c) "restore -> TRACK" could not pass), and SeqDedup refused a restarted
peer's probes and IP until its seq passed the old window. Found by the
independent review of the glue.

Header byte 11 (reserved, always 0 until now) now carries a session nonce
Adapter::send stamps on every frame (random 1..255 per adapter open; 0 =
none, so old senders still decode as one fixed session). SeqDedup restarts a
window on a session change; p2p_tx calls LinkController::on_peer_restart on
a LinkReport from a new session, which accepts the next report as the first.
And a forward epoch whose cumulative uniq went backwards no longer
underflows uniq_delta into a false acute floor. Tests: wire (byte 11 round
trip), merge (g) restart, link_ctl restart + uniq-backwards. DESIGN.md
implementation notes updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…by readback

The TX thread picks a frame's rung (and so its width) before it blocks on
the adapter's TX mutex, and the main loop published tune_bw = 20 only after
the 40 -> 20 retune returned, so a frame picked while tune_bw still read 40
could air at 40 MHz on the freshly narrowed tune. tune_bw now drops to 20
before the retune (restored on failure); 20 -> 40 keeps publishing after,
since a 20 MHz frame on a 40 MHz tune is fine.

SetMonitorChannel is void, and the MT7612U logs and returns on a refused
width; Adapter::retune now reads GetSelectedChannel back and fails the call
instead of recording a width the radio does not hold. Both from the
independent review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- p2p_rx: a spectator (no --tun) counted every received IP packet as a
  tun write failure and published it as dropped_ip_pkts.
- parse_adapter_spec: an over-long @bus value reached std::stoi, which
  throws out of main instead of printing the usage error.
- Adapter::stop: ClearAckResponder before Stop when the arm succeeded — the
  arm's documented mirror; Jaguar2's Stop() does not de-init the MAC, so the
  port identity would otherwise outlive the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C5(a) is "kill the uplink but keep the ACK". When one adapter is both the
ACK target and the uplink (an MT7612U-only ground), restarting p2p_rx with
--uplink none also removes the ACK for the whole bring-up, which is a
different cell. SIGUSR1 toggles report transmission while the adapter keeps
running; each toggle emits a p2p.uplink_mute event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@snokvist

Copy link
Copy Markdown
Owner Author

Milestone: C1–C4 (+ C5/C6 run, 2026-10-29). Pairing as before: 8812EU drone (1-1) → MT7612U ground (8-1), ch 100 HT40+, near field (−36 dBm, no attenuator). Details + log names: HANDOVER ## Results.

cell assumption result verdict
C1 A1 (8812EU responder) needs a second 8812EU NOT RUN
C2 A2 8812EU → MT7612U ACK ON 223/223 ok, retries 0.01, coverage 1.00; OFF 283/283 pinned at 3; group decode 435/s unchanged PASS (one unit pair)
C3 A5 MT7612U at 40, 20-in-40 R3 99.8 %, R5 99.7 % of submitted, 0 failed blocks PASS (MT7612U ear only; 8812EU ear NOT RUN)
C4 A4 floor edge unit 1-1 clears 3/3 soft starts at floor 0 edge 0 (soft re-init, not VBUS cold; one unit)
C4 A4 floor cost needs attenuation (~50 dB margin here) NOT RUN
C5 (b) blind descent R5→FLOOR on the 500 ms grid, +2.05 s after kill PASS
C5 (c) restore, no snap-back TRACK +1.06 s after restart, R0 kept PASS (needed afdb65c, the session-nonce fix)
C5 (a) HOLD with ACK alive HOLD at report timeout, no R−2 step first CONTRADICTS HANDOVER (code/test intend it)
C5 promotion never fires: no R5→R6 in 20 s clean TRACK; parked at R0 after restore FINDING
C6 DA machine Bcast +0.31 s; duty unchanged before the PHY step; 0 IP drops PASS

Fixes since first light, from an independent review: afdb65c (peer restart = new session; byte 11 nonce), bdc3b56 (40 MHz frame on a 20 MHz tune race + retune readback), 14319ad (spectator IP drops, stoi throw, ClearAckResponder), plus a SIGUSR1 report-mute bench hook for C5(a).

Open design questions (no code changed for these):

  1. Promotion is dead. probe_evidence_ok vetoes unless the ground saw frames at r+1, and nothing is ever sent at r+1, because the probe is at the current rung. Proposal: while in TRACK with the good-report streak and dwell met, air each video block's repair symbols at r+1. A lost repair costs nothing while the sources arrive, and the ground's per-rung counters then measure r+1 PER in-band with no extra airtime. R3→R4 crosses 20→40 MHz on a 20 MHz tune; R4 has R3's PHY rate, so that step could take rssi + loss evidence alone. Alternative: drop the probe-rung condition and rely on loss < 20 ‰ + rssi margin + 5 s dwell + 30 s lock.
  2. HOLD entry. Proposal: keep the code (HOLD at the report timeout, no step) and fix HANDOVER/DESIGN. ACKs prove the forward link is fine, and descending the drone's rate does nothing for the lost reverse path.

snokvist and others added 4 commits October 29, 2026 10:59
C5 on hardware: the drone never promoted — not R5 -> R6 in 20 s of clean
TRACK, not off R0 after a restore — so any outage parked the link at the
floor for good. LinkController::probe_evidence_ok vetoes unless the ground
saw frames at r+1, and nothing ever aired at r+1 (the probe goes at the
current rung). The unit tests passed on synthetic reports carrying r+1
counters the system never produces.

Decided on PR #4: from 1 s before the promote dwell ends (TRACK, settled,
r+1 unlocked and strictly faster) LinkController::sample_rung() opens and the
drone airs each video block's REPAIR symbols at r+1 — a lost repair costs
nothing while the sources arrive. Every symbol's flags now carry the repair
rung (bits 4..6, valid 0x80; P2pHeader::rung stays the source rung), so the
merger charges each lost symbol to the rung it aired at instead of to the
block's rung, which would have read r+1 PER as zero. A step that is not
faster on air (R3 -> R4, 26 = 26 Mb/s, 20 -> 40 MHz) needs no r+1 evidence.
Tests: wire flag round trip, merge (g2) per-air-rung accounting, link_ctl
(7b) sampling window + equal-PHY promote. DESIGN.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HANDOVER C5(a) expected one R-2 step before HOLD when reports stop but
probes stay ACKed; the code and link_ctl_test hold at once, and the bench
(c5-run1) did exactly that. Decided on PR #4: keep the code — ACKs prove the
forward link, and stepping the drone's rate down does nothing for a lost
reverse path. DESIGN.md's HOLD wording and HANDOVER C5(a) now say so (and
name the SIGUSR1 report mute for a one-adapter ground).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HT40 carries 108 data subcarriers, not 2 x 52, so 40 MHz MCS1/3/5 run at
27/54/108 Mb/s (LGI). The ladder had 26/52/104, which made the airtime model
overstate every 40 MHz frame (R6: 198 us, really 194).

R4 is now faster than R3, which broke the promotion-evidence rule from
13db3fc ("a step that is not faster on air needs no r+1 evidence"): R3 -> R4
would have demanded R4 repairs, which cannot air on a 20 MHz tune, and the
link would have stalled at R3. The rule now reads the ladder's own
sensitivity column: a step needs evidence when the next rung has a higher
rssi_floor_dbm (R3 -> R4: -84 -> -86 dBm, needs none). Tests and DESIGN
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
video_budget_kbps was PHY x duty x k/n x 0.97, which treats every us of the
duty cap as payload. The scheduler charges each frame PLCP 36 us + data +
IFS 50 us, so feeding exactly the budget overran the cap that the budget was
derived from. C7 measured it: duty p50 707 permille at R5 against a 500 cap,
822 at R6 against 450, peaks above 1000.

The budget is now the payload that duty x wall-clock airtime carries in full
frames by frame_airtime_us(), x k/n x 0.97 - reserve, so the duty cap and the
reported tx_duty_permille are one quantity. It moves to budget.h, next to the
model. R0 2.0, R3 8.5, R4 8.1, R5 14.1, R6 19.6 Mb/s (was 2.2 / 10.7 / 10.2 /
20.6 / 37.4). est_capacity_kbps and ip_budget_kbps used the same PHY x share
form and now use the model too (ip_budget at the PHY rung, which is where the
interactive bucket charges).

The cap stays the encoder's average, not a scheduler limit: bursts above it
go to the existing 60 ms video queue bound. New test: feeding the budget in
whole k_r-symbol blocks charges 0.95..1.0 x cap at every rung. DESIGN and
HANDOVER updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
snokvist and others added 6 commits October 29, 2026 11:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
Each encoder frame is one FEC block whose repair count is the rung's ratio,
ceil(k x (n_r - k_r) / k_r), floored at 1 until now. A small frame (k <= 4 at
R5/R6, k <= 2 at R0-R4: the common P-frame on a static scene) then carried a
single repair and died on a second lost symbol. waybeam-link floors repairs at
min_r = 2 for the same reason; p2plink now does too.

video_repair_count() in budget.h is the one rule: the TX encoder uses it and
so does video_item_airtime_us(), so the scheduler still charges exactly what
airs. Cost: one more frame of airtime per small frame, nothing on blocks large
enough for the ratio to give >= 2 (the budget test's 8-symbol blocks are
unchanged, 0.95..1.0 x cap). The video-bound test now derives its counts from
the blocks that fit (10 one-symbol R0 blocks in 60 ms, was 16). DESIGN
updated: one block per frame, k/n is a ratio, the floor and its cost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
…l bring-up

Outside p2plink/ on purpose: the stall is inside the MT7612U HAL's bring-up,
before any caller gets the device back, so p2plink has no seam to work around
it. To go upstream as its own PR.

On a busy channel a fresh bring-up took ~31 s instead of ~1 s about half the
time: 10-11 MCU commands in a row hit their 3 s reply budget, then all the
replies appeared within 7 ms of the RX ring's first transfer. The command that
starts it is CMD_CALIBRATION_OP / MCU_CAL_RXDCOC: the calibration opens the
receive path with MAC RX still off, frames on the channel land in EP 4, and
while data is parked there the chip does not deliver the EP 5 replies either.

mcu_wait_resp() now drains EP 4 (mt_rx_flush, which returns the bytes it
drained) on each 300 ms timeout whenever no RX ring owns EP 4, and logs what
it drained with the command id. Nothing changes on a reply that arrives in
time. RXDCOC runs on every channel set, so this also covers a ring-less
channel set under traffic.

Measured on one MT7612U restarted under an RTL8812EU airing ~1.7 k frames/s
of HT40 MCS3 at 50 cm (p2plink bench, ch 100): before, 16 of 32 restarts took
31-34 s; after, 21 of 21 took 0.94-1.26 s, each slow-path case logging
7-19 kB drained at cmd 31 arg 3. A second flush at the end of mt_mac_stop()
was tried first and did not help (7 of 10 still slow): the data comes from
RXDCOC, not from the MAC stop window. src/mt7612u/CLAUDE.md's "bring this one
up before the interferer" note is replaced with the mechanism.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
… floor, MT7612U restart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
…ucer

Outside p2plink/ for the same reason as 3d7c081: the stall is inside the
MT7612U HAL's bring-up, before any caller gets the device back. This brings
the branch to the revision going upstream as its own PR, after three
independent reviews:

- the drain's ownership test read d->a->rx_active without a lock from the PHY
  tick, which can overlap StopRxLoop's lock-free ring teardown (use-after-free
  window) and missed MT_RX_DRAIN_SYNC callers. Now mt7612u_dev::ep4_owner
  (NONE/RING/SYNC, compare-exchange transitions; the ring claims it under
  io_lock before its first RX submit and releases it once nothing is in
  flight);
- drain once per command (bounded cost), mt_rx_flush counts partial reads;
- docs: src/mt7612u/CLAUDE.md "MCU replies and EP 4" (current state, the
  mechanism worded as far as it is observed), docs/mt7612u.md "Bring-up on a
  busy channel" (A/B on upstream code via the new tests/mt7612u_busy_bringup.sh:
  master 6/6 + 6/6 stalled at 27.5 s, fixed 10/10 + 10/10 at 1.18 s);
- 3d7c081's message miscounted the p2plink bench baseline: it is 22 of 32
  slow restarts before (not 16) and 8 of 10 with the mt_mac_stop attempt
  (not 7); p2plink/docs/HANDOVER.md now carries the recount.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@snokvist

Copy link
Copy Markdown
Owner Author

Milestone: C5–C9 (2026-10-29), same pairing: 8812EU drone (1-1) → MT7612U ground (8-1), ch 100 HT40+, near field (−36 dBm, 50 cm, no attenuator). Detail, logs and counterparts: HANDOVER ## Results.

cell result verdict
C5 re-run: promotion three climbs R0→R6 in one session, one rung per 5 s dwell, 0 failed sends PASS (fixed by 13db3fc + 435c414)
C5 re-run: 20↔40 retunes frames continued through every R3→R4 retune, no empty 100 ms in 240 s PASS (the mid-session IGI-floor path, never exercised on air before)
C5 (a) HOLD HOLD at the report timeout, no step: now the documented design PASS (docs b386b0f)
C6 Bcast +0.31 s, no duty storm, 0 IP drops PASS (earlier)
C7 R5 / R6, 30 min each longest flat tx_submitted 0 ms; 0 failed / 0 timeouts; q_video ≤ 7 / 12 ms; 53 / 167 failed blocks of ~215k / ~431k PASS. Probe report coverage 0.88 / 0.76 (CCX path saturates with frame rate)
C8 static R0 1400 B baseline: fec_fail 0, 0 failed blocks, loss p99 41 ‰ NOT RUN as designed (no size knob, no motion); baseline recorded
C9 actuator ordering on 22 re-targets: budget + command 500 ms before each demote PHY step, after each promote PARTIAL (no encoder on the bench)

Fixed along the way:

  • c8d8408: the video budget now comes from the scheduler's own airtime model. PHY × duty ignored the 86 µs that every frame pays, so C7 ran at 707‰ against a cap of 500 and 822‰ against 450. With the new budget, R5 runs at 495‰ against its cap of 500.
  • 435c414: HT40 rates corrected to 27/54/108 Mb/s. Confirmed on air: R4/R3 frames/s = 1.032 at saturation.
  • 86594f5: a repair floor of 2 per block, as in waybeam-link's min_r. Verified on air at 5.66 symbols per block, against 5.85 predicted.
  • MT7612U restart stall (3d7c081 → f63d895, devourer core):
    • Symptom: a ground restart under drone traffic took 27–34 s in 22 of 32 tries.
    • Cause: frames reach EP 4 during RXDCOC with MAC RX off, and that holds back the MCU's EP 5 replies.
    • Fix: drain EP 4 on a late MCU reply. Restarts are now 0.9–1.3 s in 31 of 31.
    • Upstream: mt7612u: drain EP 4 when an MCU reply is late, so RXDCOC cannot stall bring-up OpenIPC/devourer#510, with a reproducer and an A/B on upstream code (master 6/6 + 6/6 stalled, fix 10/10 + 10/10 at 1.18 s).
    • It is outside p2plink/ because there is no seam on the p2plink side. The drone-side "no ACK 30 s → duty 0.3" rule is not needed for this.

8812EU HT40 TX (the libc0607/rtl88x2eu#7 stall):

  • Not reproduced in devourer's TX path. 0 sagging bring-ups in 28 runs without a ground: txdemo across base, CCA off, 40-in-80, TX reports, and RX thread, plus p2p_tx alone.
  • One unexplained sag. p2p_tx at saturation, −20 qdB, with the MT7612U ground 50 cm away sagged in 3 of 8 bring-ups, never under budget load. It is recorded for a re-check at real separation.
  • Model calibration. The model's 50 µs inter-frame space is an upper bound on this unit: measured 30–53 µs at saturation.

Open:

  • C1, C3's 8812EU ear, and the C4 edge need the second 8812EU.
  • C4's cost, C9's demote half, and C8's sweep need attenuation, an encoder, or a size knob.
  • R3→R4 lowers the budget by 4.9 %, under the actuator's 5 % threshold. The ladder or the threshold is your call.

snokvist and others added 2 commits October 29, 2026 18:27
…era drone

R4 (40 MHz MCS1, 27 Mb/s) had a 0.55 cap against R3's 0.60, so the R3 -> R4
promote dropped the video budget 8529 -> 8111 kb/s (-4.9 %), under the
actuator's 5 % threshold: the re-target was never sent (C9 partial) and the
encoder ran ~5 % over R4's budget. The cap is demote headroom, and what a
demote costs is the PHY-rate ratio to the rung below: R5 -> R4 doubles the
airtime, R4 -> R3 adds ~4 %. R4 now takes R3's cap; its budget is 8.9 Mb/s,
above R3's 8.5, so every promote raises it.

HANDOVER: the ssc338q camera's 8812EU as a second drone unit (cross-built
p2p_tx/p2p_feed from /tmp). C1 PASS (ack8 8 / coverage 1.00 ON, 203/203
pinned OFF), C3 PASS with both ears (8812EU 99.4-99.5 %, MT7612U 99.9 %,
adapters 2 throughout), C4 edge 0 on the second unit too (21/21 starts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@snokvist

Copy link
Copy Markdown
Owner Author

Addendum — second 8812EU (the ssc338q camera as drone), 2026-10-29 evening. This closes cells the two milestone comments left NOT RUN or PARTIAL.

  • Drone: the camera's own RTL8812EU, running p2p_tx cross-built for armv7 from /tmp, with waybeam-hub stopped during the cells and restarted afterwards.
  • Ground: the bench host.
  • Conditions: ch 100 HT40+, near field (−23 to −45 dBm). Details are in HANDOVER ## Results.
cell result verdict
C1: 8812EU drone → 8812EU ACK target ON: ack8 8, retries 0.01, report coverage 1.00. OFF: ack8 0, 203/203 reports pinned at 3 PASS
C3: two ears, R3 (20 MHz) and R5 (40 MHz) 8812EU ear 99.4 / 99.5 %, MT7612U ear 99.9 / 99.9 % of frames sent; adapters = 2 throughout; 1 / 0 failed blocks PASS
C4 edge: second unit all 21 starts aired at once at every --wide-igi-floor from 0x35 down to 0 edge 0 on both units (soft starts)
C9: encoder in the loop the camera's venc (720p100) through its frame ring, re-targeted live by --bitrate-cmd. The encoder settles within ~0.5 s of each command, so the 500 ms lead holds. q_video max 9 ms, 0 drops, 0 failed sends, 0 mid-run FEC failures PASS on ordering and the bound. The loss-driven half still needs attenuation

Also:

  • R4's duty cap is raised to 0.60 (6399431), so R3→R4 no longer lowers the budget.
  • The Jaguar2 read-fail side issue was handled in a separate session. Fix branch: jaguar2-read-fail-survive.

Still open:

  • C4's cost, and C9's loss-driven demote. Both need an attenuator.
  • C8's frame-size sweep. Needs a size knob and motion.

snokvist and others added 4 commits October 29, 2026 18:30
…e (jaguar2-read-fail-survive)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
The drone keeps its own copy of the ladder with frame_bytes set from
--frame-bytes (200..1400, default 1400, every rung), so block sizing, the
airtime charge, the video budget and the oversize ceiling all follow from the
one Rung field they already read. The tun MTU follows the frame. The ground
needs no knob: rx_merge takes each block's symbol length from its frames;
p2p_rx now reports what it receives as frame_bytes instead of a hard-coded
1400, and the drone's p2p.ready names its size.

Short frames pay the per-frame PLCP + IFS more often, so the budget falls:
R0 2018 -> 1786 kb/s at 600 B (-11 %), R6 19600 -> 11833 (-40 %).
budget_test checks the duty-fill property at 1400, 600 and 200 B.

HANDOVER's C8 spec said an MCS0 frame is 4.7 ms; the airtime model gives
1850 us at 1400 B and 866 us at 600 B (R0, HT20 MCS0) — corrected there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
send_video drops a frame above kMaxK x S whole, unsent, but the TX loop then
charged its full block airtime to the scheduler anyway, so tx_duty_permille
(and the interactive bucket's window) over-read whenever keyframes exceeded
the block limit. send_video now says whether anything aired; a dropped item
is not charged.

Camera drone, R6 saturation, 100 Mb/s synthetic feed with a keyframe every
30 frames (472 oversize drops in 60 s): meter p50 1068 permille before, 551
after, against 554 for the frames actually aired (2853 frames/s x 194 us).
The bench R5/R6 saturation readings carried ~1 such charge/s (116 in 115 s);
HANDOVER's IFS-overcharge note now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
BlockMerger treated a sender restart as such only when the block_id fell more
than kResyncBlocks (1024) behind its cursor. A p2p_tx restarted under a running
ground starts block_id at 0 again, so unless the old sender had aired more than
1024 blocks, every new block was discarded as late until the new count caught
up — up to ~34 s of video at 30 fps. The C8 walk hit it on every leg: 18 drone
restarts under one ground, ~560 blocks each, and the ground decoded only the
first leg while both ears received ~120 000 frames.

The header's session nonce (random per p2p_tx, already used by SeqDedup) now
drives it: a new nonce flushes the pending set and rebases the cursors; a copy
from the session just replaced is late, not a second restart. The id rule
remains for senders without a nonce. A rebase now also forgets every ear's
position — an old-session position made rule (1) close the new session's gaps
before the slower ear had spoken (merge_test (f2) fails without it).

Hardware: three drone restarts under one ground, each leg decoded from its
first symbol, 0 failed blocks (walk-restartfix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge
@snokvist

Copy link
Copy Markdown
Owner Author

p2plink development has moved to its own repository: snokvist/waybeam-p2p. It is private, and registered in waybeam-coordination (snokvist/waybeam-coordination#217). This PR stays open as the record of the hardware bring-up. New p2plink work goes to the new repo, not to this branch.

What moved

What did not move: the devourer-side commits on this branch (3d7c081, f63d895). They go upstream through OpenIPC#510 instead.

Last milestone on this branch (also in the new repo):

  • 28e8790: p2p_tx --frame-bytes, the frame-size knob C8 needs.
  • 99888e4: the drone no longer charges airtime for a video frame dropped as oversize. On the camera at R6 the duty meter read 1068‰ before and 551‰ after, against 554‰ for the frames actually aired.
  • 925e878: the ground's block merger now rebases on the sender's session nonce.
    • Before, a drone restart under a running ground discarded video for up to 1024 blocks. The first untethered C8 walk hit this on all 18 legs: two ears received ~120 000 frames each, and only leg 1 decoded.
    • A new merge test fails on the old code. On hardware, three restarts each decoded from their first symbol.
    • The walk is void and needs repeating.

🤖 Generated with Claude Code

https://claude.ai/code/session_011dReg1L6xtBhf95NpAeuge

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.

1 participant