← RFC

Per-link mixed-SF slots — firmware design (#66 Phase 2)

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.

What changes vs per-cell (Phase 1)

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:

  1. The beacon stays at the SLOWEST member's SF (every member must hear it) — same SF as per-cell would pick. This is the fixed cost the sim charged; it's why per-link only wins at N≥5 / ≥2-step spread.
  2. Slots become VARIABLE WIDTH (an SF7 slot ≈270 ms, an SF11 slot ≈3.4 s), so slot offsets are no longer k·SLOT_W — each member's offset is the sum of the preceding slots' (per-SF) widths.

The gate (coordinator, per frame)

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.

Slot timing — the variable-width math (the crux)

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

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).

Implementation order

  1. Wire: BCN_FRAMET_PER_LINK flag + per-SF roster entry (proto encode/decode + firmware decode + round-trip test). Per-cell path untouched.
  2. Coordinator 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.
  3. Client: prefix-sum slot offset over the roster SFs; TX own slot at own SF (the foundation already retunes/recomputes geometry on adopt — here the cell SF for RX is the slowest/beacon SF, and the member TXes its slot at its own SF, then returns to the beacon SF for RX).
  4. Bench: members FORCED to different SFs (a build flag, since the bench is all high-SNR) — verify the per-link beacon, the variable-width slots line up, and two mixed-SF members both deliver telemetry.

Idealized / risks