← RFC

Announced PHY-change — the unified grace-period mechanism (#66 + #65)

Status: design (chosen 2026-06-22). Implements the operator-selected "announced grace-period" approach for changing a cell's modulation (#66 adaptive SF) and/or channel (#65 DFS) while members are joined, without forcing them through a blind re-scan.

Why

The PHY-agility foundation lets a client discover + adopt a cell's announced (freq, SF, BW). But once joined, if the coordinator changes that PHY, a member at the old PHY stops hearing the beacon and must re-acquire. The discovery-latency result (discovery-latency-result.md) measured that re-acquisition at tens of seconds (blind (channel×SF) grid scan is linear in grid size). Doing that on every adaptive-SF or DFS adjustment would strand the whole cell each time — unacceptable.

The adaptive-SF result (adaptive-sf-selection-result.md) shows per-cell SF changes are rare (~0 thrash with 2.5 dB hysteresis), so the cost is not frequency but depth per event. The fix is to make each (rare) change cheap: the coordinator announces the upcoming change ahead of time and members pre-tune to the new PHY at the announced moment — O(1), no scan.

One mechanism serves both layers: #66 announces a new (sf, bw); #65/DFS announces a new freq_offset; either/both at once. This is also exactly the "guided roam" primitive #65 wanted.

Wire format (beacon)

Beacon byte0 = version(3) << 5 | flags(5). Reserve one flag bit:

BCN_FLAG_PHY_CHANGE = 0x01   // a PHY change is pending; the announced-change block is present

When set, append a 4-byte announced-change block after the CellDesc (bytes 10–11), before the PACT-lite block:

+0  target_sf_bw   : sf<<3 | bw      (same packing as CellDesc byte 9)
+1..2 target_freq_offset : u16 BE    (same units as CellDesc.freq_offset)
+3  frames_until_switch : u8         (beacons remaining at the OLD PHY before the switch; counts down)

The block is absent (and airtime unchanged) whenever no change is pending — the common case. The switch epoch is implicit: switch_epoch = this_beacon.epoch + frames_until_switch (epoch counts superframes). Announcing the count rather than an absolute epoch keeps it self-correcting for a member that joins mid-announcement.

Beacon-format change ⇒ coordinated reflash, no mixed-version cells (same discipline as #60/#63; VERSION stays 0b010 since the field is flag-gated and old parsers that ignore unknown flags still read the base beacon — but to be safe we treat any cell running announced-change as requiring the new firmware).

Coordinator state machine (generic over SF and channel)

STEADY:   run at committed PHY. Each evaluation interval, compute target PHY:
            - SF:   mac::sf_select::commit_cell_sf(current_sf, member_snrs, M, H)
            - chan: DFS pick_quietest(scan)          (#65)
          if target != committed  -> enter ANNOUNCING with K = ANNOUNCE_FRAMES (e.g. 4)
ANNOUNCING: each beacon: set BCN_FLAG_PHY_CHANGE, block = {target, K}; K -= 1
          when K reaches 0 on a beacon boundary:
            - transmit the final beacon (frames_until_switch=0) at the OLD PHY
            - re-init radio to target PHY (freq/sf/bw); RECOMPUTE geometry (slot widths from the new SF,
              via lora_toa_ms at the new sf/bw) and the runtime slot-fit gate (refuse a target that
              overruns — fall back to STEADY, log, keep current PHY)
            - committed = target; clear the flag; -> STEADY

ANNOUNCE_FRAMES (K): enough lead time for a member to hear ≥1 announcing beacon even after a missed beacon or two. K=4 at a ~2 s idle frame ≈ 8 s of notice; cheap because changes are rare.

Client behaviour

On decoding a beacon with BCN_FLAG_PHY_CHANGE: - record pending = {target_sf, target_bw, target_freq_offset, switch_epoch = epoch + frames_until_switch}. - continue normal operation at the current PHY until epoch >= switch_epoch. - at the switch boundary: pre-tune the radio (setFrequency/SF/BW to target) and recompute_geometry() for the new SF — a DIRECT follow, NOT a return to Scanning. The very next beacon should arrive at the new PHY; if it does not (missed the switch), fall back to the existing abandon→Scanning path (the foundation's grid scan re-acquires — the safety net, not the normal path).

This reuses the foundation's adopt path (cc_adopt already retunes freq+SF+BW+geometry); the only new client logic is "schedule a pre-tune at switch_epoch" instead of "adopt on first beacon heard."

Verification plan

  1. Mechanism (bench, single client): coordinator announces an SF7→SF9 change; the joined client pre-tunes at the announced epoch and stays joined (no Scanning, telemetry continues) — contrast with the abrupt path that would show a ~tens-of-seconds gap.
  2. Same for a channel change (DFS), proving the one mechanism covers #65.
  3. Selection driving it (#66): feed varied member SNR (attenuator/distance) and confirm the committed SF tracks commit_cell_sf with no flapping.

Reuse map