Status: IMPLEMENTED + HW-VERIFIED (2026-06-22, branch feat/per-link). Net-justified by
per-link-sf-result.md (sim-result-v6): per-link gives ×1.6–2.0 NET capacity only when the cell spans ≥2
SF steps AND N≥5 — a single-SF cell is a net loss. So this is a gated extension of the
per-cell-auto path (Phase 1), not a replacement.
Bench verification (2026-06-22). Coordinator @SF9 with two real members forced to a mix — a Heltec V3
(093547) at SF7 and a Heltec V4 (AFD03C) at SF9 (LW_PER_LINK=1 LW_FORCE_SF=093547:7,AFD03C:9,
LW_LINK_MIN_N=2 for the bench, since the bench has no real SNR spread). Result:
[per-link] FRAME beacon@SF9 ["093547@SF7","AFD03C@SF9"] telem_region=446ms and the coordinator heard
both members' telemetry (093547 ×16, AFD03C ×20). Because LoRa SFs are quasi-orthogonal (a SF9 RX
cannot demodulate SF7), hearing a SF7 and a SF9 member in one cell proves the per-SF-block RX retune.
The variable telem region (446 ms vs ~640 ms uniform-SF9) is the SF7 slot being genuinely narrower — the
capacity win. Full chain verified end-to-end: gate → slowest-SF beacon → SF-sorted per-member roster →
coordinator per-block set_modulation retune → client prefix-sum slot offset + TX at its own SF.
Phase 1: the whole cell runs at ONE SF (worst-member-driven); all slots + the beacon at that SF; uniform
slot width. Phase 2: each rostered member's telemetry/data slot runs at its own best SF (sf_select
per-link), so close members go fast. Two consequences the firmware must handle:
k·SLOT_W — each member's offset is the sum of the preceding slots' (per-SF) widths.The coordinator already tracks per-member SNR + sf_select. Per frame, over the rostered pool:
- n = rostered count, spread = max_sf − min_sf across rostered members.
- per-link mode iff n ≥ LINK_MIN_N (=5) AND spread ≥ LINK_MIN_SPREAD (=2); else per-cell (Phase 1).
Both inputs are already in hand — the gate is a few lines. Per-cell stays the default; nothing changes for
small/tight cells (and the common single-SF deployment never pays the per-link wire/retune cost).
A beacon flag selects the variant (mixed-version cells are already forbidden; format change = coordinated
reflash). Reserve a second flag bit alongside BCN_FRAMET_PHY_CHANGE:
BCN_FRAMET_PER_LINK = 0x4000 // bit 14 of the frame_t field: roster entries carry a per-member SF
When set, each roster entry carries the member's SF (Phase-1 entry is addr24 ‖ slot; Phase-2 is
addr24 ‖ slot ‖ sf, +1 B/entry). The beacon's CellDesc.sf = the slowest rostered SF (the beacon's
own modulation). Per-cell beacons are byte-identical to today.
Telemetry region, from the post-beacon anchor t_end (mirrors the existing od_telem_end):
slot_start(member m) = t_end + GUARD + Σ_{r before m in roster order} telem_w(sf_r)
telem_w(sf) = lora_toa(sf, bw, TELEM_FRAME_B) + TELEM_MARGIN // the existing per-SF slot width
lora_toa_rt + recompute_geometry (the foundation); per-link just makes the offset a
prefix-sum instead of k·g_telem_w.radio.setSpreadingFactor(sf_r) then RX its
slot window, advancing the clock by telem_w(sf_r). One setSpreadingFactor per slot (cheap; no full
begin()), back to the slowest SF for the CW/advert region. (#63 on-demand MSG grants likewise carry a
per-grant SF and size their slot at it — same prefix-sum; Phase 2a can land telemetry-only first.)Per-slot slot-fit (#60): each slot must hold its member's largest frame at its SF — already true since
telem_w/msg_w are computed at that SF. The frame budget (frame_t) is the prefix-sum + beacon@slowest +
CW; the runtime slot-fit gate sums it and refuses a frame that overruns FRAME_T (fall back to per-cell).
BCN_FRAMET_PER_LINK flag + per-SF roster entry (proto encode/decode + firmware decode +
round-trip test). Per-cell path untouched.cell(): the gate (n≥5 ∧ spread≥2) → build the per-link roster (each member's sf_select
SF) + beacon@slowest; in the telemetry RX loop, retune per slot; slot-fit on the prefix-sum.setSpreadingFactor settle time is assumed ≪ GUARD; measure on hardware (folds into GUARD).