From f34ef6910e813c6160039c32c1a4c88205a8e638 Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Fri, 13 Nov 2026 02:21:27 +0300 Subject: [PATCH] =?UTF-8?q?cell=20M5:=20move=20a=20running=20cell=20to=20a?= =?UTF-8?q?nother=20channel=20=E2=80=94=20CU-commanded,=20beacon-announced?= =?UTF-8?q?,=20DU=20and=20UEs=20retune=20together?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coordination planner's spare-channel proposals were only logged. With RAN_RETUNE_APPLY=1 the CU now applies one: F1 v15 ChannelSwitch to that DU, RAN_CS_LEAD_SF (20) ahead; the DU stages it (refusing too little lead, its own channel, a handover in progress, or a retune that would not fit the idle window), announces it in its beacon (src/cell/ChannelSwitch.h: CsIe, tagged with the cell key, CRC; CsReceiver rejects replays and spent instants), and in the idle part of the superframe before the instant it and each UE stop the beacon, FastRetune and restart it - no TSF change, so the grid and slots keep their phase. A UE not hearing its cell there within 10 superframes goes back. The CU fences moves against handovers, times one out 50 sf after its instant and backs a refusing DU off RAN_CS_BACKOFF_S (30 s). Rig (testrig m5_retune.sh, cell 2's DL on UE1's uplink): the move done in both apply runs, 0/1 uplinks and 0 DL frames lost across it, uplinks in slot from the first superframe on 157, on-air cross-cell overlaps stop; a DU that can't fit the retune refuses and is not asked every round. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/cell-m5.md | 83 ++++++++++- examples/cell/main.cpp | 267 +++++++++++++++++++++++++++++++++- src/cell/ChannelSwitch.h | 171 ++++++++++++++++++++++ src/cell/F1.h | 53 ++++++- tests/cell_m5_selftest.cpp | 37 +++++ tools/ran-controller/main.cpp | 78 +++++++++- 6 files changed, 669 insertions(+), 20 deletions(-) create mode 100644 src/cell/ChannelSwitch.h diff --git a/docs/cell-m5.md b/docs/cell-m5.md index cf3ccd34..0e01c2ec 100644 --- a/docs/cell-m5.md +++ b/docs/cell-m5.md @@ -112,9 +112,75 @@ The messages are UDP datagrams with an HMAC-SHA1 tag, keyed by `CELL_F1_KEY` / the DUs are actually on. Spare-channel proposals come from a separate advisory plan. -**Not applied:** the planner's channel proposals are logged (`ran.retune`) -but not applied, because a DU changes channel only at start. Moving a running -cell and its UEs to another channel is M6 territory. +**Applied with `RAN_RETUNE_APPLY=1`:** the proposal moves the running cell +and its UEs (next section). Without it, proposals are only logged +(`ran.retune`). + +## Moving a running cell to another channel + +When the planner can't separate two co-channel cells in time (no +conflict-free position left), it proposes a spare channel +(`RAN_SPARE_CHANNELS`) for one DU. With `RAN_RETUNE_APPLY=1` the CU applies +it, one move at a time, never across a handover +(`src/cell/ChannelSwitch.h`, F1 v15 `ChannelSwitch`): +- **The command:** the CU tells the DU to serve on the new channel from a + network superframe `RAN_CS_LEAD_SF` (20) ahead (`ran.cs_cmd`). It resends + until the DU acks it Staged (`ran.cs_ack`). The DU refuses a move with too + little lead, to its own channel, while a handover involves it, or when its + retune would not fit the idle window (`no_window`). A refused or timed-out + move holds that DU off for `RAN_CS_BACKOFF_S` (30 s), so a DU that can't + move is not asked every round. A staged move makes the DU refuse handover + commands. The CU resends the command until the instant, staged or not, so + a DU that restarted after staging stages it again; an id it already holds + arriving with another channel or instant is refused (`id_reused`), as after + a CU restart within the same second. +- **The announcement:** the DU carries a channel-switch IE in its beacon + until the instant: id, cell, channel, and the first superframe on the new + channel (`CsIe`, tagged with the cell key, CRC). A UE takes it once + (`CsReceiver`): a newer id, with the instant still ahead, so a replayed + beacon can't move it. +- **The move:** in the idle part of the superframe before the instant, after + every slot (`cs_retune_at_us`: the active span + 1 ms, if the retune + budget `CELL_CS_RETUNE_US` (26 ms) and a guard fit before the next beacon), + the DU and each of its UEs stop their beacon, retune (`FastRetune`) and + restart it (`cell.cs.switch`, `cell.ue.cs_switch`). The TSF does not move, + so the beacon grid and every slot keep their phase: no re-sync and no new + grant. +- **Only an announced move is made:** the DU moves only if a published + beacon carried the IE in at least 3 superframes before the instant (not, + say, after losing sync, when the IE is not aired); otherwise it stays + (`why` `unannounced`). A beacon that won't restart on the new channel (3 + tries) takes it back to the old one. Either way it acks the outcome to the + CU: Applied, or Invalid (refused and backed off). +- **A UE with a handover pending** does not take the move, and doesn't mark + it seen either: the cell's next beacons offer it again once the handover + is resolved. A UE whose own retune failed is still on the old channel, and + its fall-back is dropped (it is already where it would go back to). +- **The way back:** a UE that moved but doesn't hear its cell on the new + channel within 10 superframes returns to the old one + (`cell.ue.cs_fallback`). A move the DU did not make times out at the CU 50 + superframes after its instant (`ran.cs_timeout`). Not exercised on the rig + since the staging check: a DU now refuses a move it can't make, and nothing + else on the rig makes one fail after it was announced. Before that check + (DU1 with a budget that did not fit), the UE fell back every time and was + heard on the old channel 1 superframe later. +- **Done:** the CU sees the DU report the new channel at or after the instant + (`ran.cs_done`); the planner then plans on the new channels. + +**Rig result** (testrig `scripts/m5_retune.sh`): two cells on 149, capacity 1 each, cell 2 at phase 5000 +us. Its DL slot then starts at cell 1's UL slot, so DU2's DL frames land on +UE1's uplink, and each DU hears the other's UE. The planner can't resolve +that by position and proposes 157 for DU1 (`RAN_SPARE_CHANNELS=157`). + +| case (runs) | move | UE1 across the switch | witness on 149: cross-cell overlaps | UE1's uplinks lost, last ~440 sf | +|---|---|---|---|---| +| apply (2) | done; DU 23.7 ms retune, UE 70–116 µs after its window | 0 / 1 uplinks lost, 0 DL frames lost; 147/147 uplinks after, all in slot | 20, 1 (before the move; none after) | 0, 0 | +| fault: DU1 can't fit the retune (2) | refused at staging (`no_window`), never announced; 3 commands in 90 s (backoff) | the UE never moved | 59, 61 | 1, 3 | +| baseline, `RAN_RETUNE_APPLY=0` (1) | proposed, not made | — | 68 | 1 | + +- **The switch costs about nothing:** the DU and the UE retune in the idle part of the superframe, the beacon grid keeps its phase, and the UE's uplink is in its slot from the first superframe on the new channel. +- **What the move buys here:** the on-air overlap stops. On this rig at these levels the overlap costs UE1 only a few uplinks (1–3 of ~440 in the baseline and fault runs; 35 of 864 in one exploratory run), so the gain is the conflict removed, not a large delivery loss recovered. +- **First attempt at the scenario:** at phase 3000 the planner saw a conflict (slot spans overlapping), but the witness counted 0 frame overlaps on air. The planner plans on whole slots, conservatively. Phase 5000 makes the frames actually meet. ## Rig result @@ -168,8 +234,9 @@ code. ## Not covered -- Applying a channel change at runtime: frequency separation is proposed, - not applied. -- A USB (coarse-tier) DU: the rig has none. The tier rule is selftest-only. -- More than two DUs on the rig: the planner handles N, and the selftest runs - 2–4. +- A USB (coarse-tier) DU: the rig has none. The tier rule is selftest-only + (#565). +- More than three DUs: the planner handles N (the selftest runs 2–4); the rig + has run three under this CU (M6, `docs/cell-m6.md`, three cells). +- A channel move for a cell whose UE is mid-handover is refused, not queued: + the planner proposes it again once the handover is over. diff --git a/examples/cell/main.cpp b/examples/cell/main.cpp index d57aefad..4d23c8da 100644 --- a/examples/cell/main.cpp +++ b/examples/cell/main.cpp @@ -97,6 +97,9 @@ // CELL_JOINER_MAC the joiner's MAC (default: derived from the host // name + DEVOURER_PCIE_BDF, so a restart re-joins // into the same position instead of a new one) +// CELL_CS_RETUNE_US [26000] a channel move's retune budget (DU and UE): +// the move needs it, + 2 ms, after every slot and +// before the next beacon (ChannelSwitch.h) // CELL_RELEASE_SF [50] DU: release a JOINED UE whose uplink has been // absent this many superframes (static UEs stay) // CELL_GAP_SUSPEND_SF [0] DU and UEs alike: suspend gaps (MeasGap.h). A @@ -243,6 +246,7 @@ #include "cell/SlotGrid.h" #include "logger.h" #include "radio_open.h" +#include "cell/ChannelSwitch.h" #include "cell/Coord.h" #include "cell/F1.h" #include "cell/Keys.h" @@ -627,6 +631,14 @@ int run_du(IRadio *dev, const Cfg &c) { const size_t pad = static_cast(envl("CELL_DL_PAD", 64, 0, 1400)); /* How long before its slot a DL frame takes its PDUs and is built. */ const int64_t dl_pick_lead_us = envl("CELL_DL_PICK_LEAD_US", 3000, 500, 100000); + /* A channel move's retune budget (the 8852CE's FastRetune: 23.5-23.7 ms). */ + const int64_t cs_retune_us = envl("CELL_CS_RETUNE_US", 26000, 1000, 100000); + const int64_t grid_span_us = [&] { /* every slot's end: where a move's window starts */ + int64_t span = 0; + for (const auto &x : grid->slots()) + span = std::max(span, x.start_us + x.len_us); + return span; + }(); /* A frame that would start later than this into its slot is not sent (the * slot budget's guard, as du_budget_ok counts it). */ const int64_t dl_guard_us = envl("CELL_DL_GUARD_US", 500, 0, 100000); @@ -1009,6 +1021,10 @@ int run_du(IRadio *dev, const Cfg &c) { fprintf(stderr, "StartBeacon failed\n"); return 1; } + std::vector live_bcn = bcn; /* what airs now: restarted after a channel move */ + /* The channel this DU serves on: DEVOURER_CHANNEL at start, then where a + * channel move (ChannelSwitch.h, from the CU) took it. */ + std::atomic du_ch{c.ch}; uint64_t t0 = 0, f0 = 0; if (!dev->ReadTsfAndFreeRun(t0, f0)) { @@ -1357,6 +1373,18 @@ int run_du(IRadio *dev, const Cfg &c) { std::map ho_out; /* by (source) UE id */ bool have_ho_seq = false; uint64_t last_ho_seq = 0; + /* A staged channel move (ChannelSwitch.h; f1_mu): announced in the beacon + * until its instant, then this DU and its UEs retune together. */ + struct CsStaged { + uint32_t id = 0; + uint8_t channel = 0; + int64_t eff_sf = 0; /* this DU's count */ + int aired = 0; /* superframes a published beacon carried its IE */ + }; + std::optional cs_staged; + uint32_t cs_aired_id = 0; /* the move whose IE the live beacon carries (f1_mu) */ + bool have_cs_seq = false; + uint64_t last_cs_seq = 0; /* DAPS: the other identities to forward, until a local superframe (f1_mu). */ const bool daps_on = envl("CELL_DAPS", 1, 0, 1) != 0; std::map, int64_t> daps_ids; @@ -1769,7 +1797,7 @@ int run_du(IRadio *dev, const Cfg &c) { r.tier = static_cast(tier); r.sync_state = f1_view.sync_state; r.coordinated = f1_view.coordinated; - r.channel = c.ch; + r.channel = du_ch.load(); r.phase_us = static_cast(dc.phase_us); r.period_us = P; r.gap_suspend_sf = static_cast(std::min(gap_suspend_every, 65535)); @@ -1909,10 +1937,67 @@ int run_du(IRadio *dev, const Cfg &c) { } continue; } + if (f1::peek_type(buf, static_cast(n)) == f1::Type::ChannelSwitch) { + auto cs = f1::decode_channel_switch(buf, static_cast(n), f1_mac); + if (!cs || cs->du != c.cell) + continue; + f1::AckStatus st = f1::AckStatus::Staged; + int64_t net_now = -1; + const char *why = ""; + { + std::lock_guard lk(f1_mu); + net_now = f1_view.net_sf; + const bool same = cs_staged && cs_staged->id == cs->id; + const int64_t eff_new = static_cast(cs->effective_net_sf) - net_off.load(); + const bool reused = same && (cs_staged->channel != cs->channel || cs_staged->eff_sf != eff_new); + if (have_cs_seq && cs->seq <= last_cs_seq) { + st = f1::AckStatus::Stale; /* a replay or reordering */ + } else { + have_cs_seq = true; + last_cs_seq = cs->seq; + const int64_t eff = static_cast(cs->effective_net_sf) - net_off.load(); + if (reused) { + st = f1::AckStatus::Invalid, why = "id_reused"; /* another move under a staged id */ + } else if (same) { + st = f1::AckStatus::Staged; /* a resend of the one staged */ + } else if (net_now < 0) { + st = f1::AckStatus::Late, why = "no_clock"; + } else if (static_cast(cs->effective_net_sf) < net_now + 4) { + st = f1::AckStatus::Late, why = "lead"; /* announced too few superframes ahead */ + } else if (cs->channel == du_ch.load() || !ho_in.empty() || !ho_out.empty() || + (cs_staged && cs_staged->id > cs->id)) { + st = f1::AckStatus::Invalid, why = "busy_or_same"; + } else if (!devourer::cell::cs_retune_at_us(grid_span_us, cs_retune_us, 1000, P)) { + st = f1::AckStatus::Invalid, why = "no_window"; /* the retune would not fit: never announced */ + } else { + cs_staged = CsStaged{cs->id, cs->channel, eff}; + } + } + } + send(f1::encode_ack({c.cell, f1::AckKind::ChannelSwitch, cs->id, st, + net_now < 0 ? 0 : static_cast(net_now)}, + f1_mac)); + char l[200]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.cs.cmd\",\"id\":%u,\"channel\":%u,\"effective_net_sf\":%llu," + "\"status\":\"%s\",\"why\":\"%s\"}", + cs->id, cs->channel, (unsigned long long)cs->effective_net_sf, f1::ack_status_name(st), why); + emit(l); + continue; + } if (f1::peek_type(buf, static_cast(n)) == f1::Type::Handover) { auto h = f1::decode_handover(buf, static_cast(n), f1_mac); if (!h || h->first != c.cell) continue; + bool moving = false; + { + std::lock_guard lk(f1_mu); + moving = cs_staged.has_value(); + } + if (moving) { /* a channel move staged: no handover across it */ + send(f1::encode_ack({c.cell, f1::AckKind::Handover, h->second.id, f1::AckStatus::Invalid, 0}, f1_mac)); + continue; + } const devourer::cell::HoCommand &cmd = h->second; f1::AckStatus st = f1::AckStatus::Invalid; const char *role = cmd.target == c.cell ? "target" : cmd.source == c.cell ? "source" : "none"; @@ -2008,7 +2093,7 @@ int run_du(IRadio *dev, const Cfg &c) { * every uplink of the UE, and an answer there would land in * the source cell's schedule. */ for (const auto &nb : nb_list) - if (nb.cell == cmd.source && nb.channel != 0 && nb.channel != c.ch) + if (nb.cell == cmd.source && nb.channel != 0 && nb.channel != du_ch.load()) visit_src[{cmd.source, cmd.ue}] = {cmd.target_ue, static_cast(eff)}; ue_home[cmd.target_ue] = cmd.home; /* reported on, for its next handover */ } else { @@ -2035,7 +2120,7 @@ int run_du(IRadio *dev, const Cfg &c) { ie.ul_slot = static_cast(*ul); ie.effective_sf = static_cast(static_cast(cmd.effective_net_sf) - off); ie.tsf_delta_us = cmd.tsf_delta_us; - ie.channel = cmd.target_channel != c.ch ? cmd.target_channel : 0; + ie.channel = cmd.target_channel != du_ch.load() ? cmd.target_channel : 0; ie.ctx = cmd.ctx; /* the target's key for this UE: relayed, opaque */ auto bytes = devourer::cell::encode_ho_ie(ie, auth); if (!bytes.empty()) { @@ -2157,7 +2242,7 @@ int run_du(IRadio *dev, const Cfg &c) { const std::vector busy = { {0, c.bcn_us}, {sl[sg->dl_slot].start_us, sl[sg->dl_slot].len_us}, {sl[sg->ul_slot].start_us, sl[sg->ul_slot].len_us}}; - if (devourer::cell::needs_suspend(nl_dl, c.ch, gap_floor_us, gap_guard_du_us, P, busy)) { + if (devourer::cell::needs_suspend(nl_dl, du_ch.load(), gap_floor_us, gap_guard_du_us, P, busy)) { char l[120]; snprintf(l, sizeof l, "{\"ev\":\"cell.dl.suspend\",\"ue\":%u,\"sf\":%lld}", ue, (long long)sf); emit(l); @@ -2441,6 +2526,7 @@ int run_du(IRadio *dev, const Cfg &c) { std::vector rar_sel; std::vector to_release; std::vector ho_ies; /* handover IEs to air (source side) */ + std::optional csn; /* a staged channel move, announced in ho_ies */ std::vector ho_done; /* source-side handovers concluded */ std::optional ho_eff; /* a handover arrival's instant (target side) */ uint16_t ho_eff_ue = 0; @@ -2725,6 +2811,16 @@ int run_du(IRadio *dev, const Cfg &c) { if (synced && f1_on) { const auto nl = devourer::cell::encode_nl_ie(c.cell, nl_gen_snap, nb_snap, area); ho_ies.insert(ho_ies.end(), nl.begin(), nl.end()); + /* A staged channel move, announced until its instant. */ + { + std::lock_guard lk(f1_mu); + csn = cs_staged; + } + if (csn && sf < csn->eff_sf) { + const auto cie = devourer::cell::encode_cs_ie( + {csn->id, c.cell, csn->channel, static_cast(csn->eff_sf)}, auth); + ho_ies.insert(ho_ies.end(), cie.begin(), cie.end()); + } /* Signed once per list and network window (its freshness: the * network superframe / kNlWindowSf, on the shared timebase). */ const int64_t net_sf_now = net_ok.load() ? sf + net_off.load() : -1; @@ -2770,6 +2866,12 @@ int run_du(IRadio *dev, const Cfg &c) { const auto nb = build_du_beacon(next, auth, rar_now, ho_ies); const bool ok = !nb.empty() && dev->UpdateBeaconPayload(nb.data(), nb.size()); beacon_fail_run = ok ? 0 : beacon_fail_run + 1; + if (ok) + live_bcn = nb; + if (ok) { /* what the live beacon now announces (nothing, if the IE went) */ + std::lock_guard lk(f1_mu); + cs_aired_id = csn && sf < csn->eff_sf && cs_staged && cs_staged->id == csn->id ? csn->id : 0; + } if (ok) { rar_on_air = rar_now; rar_on_air_set = rar_sel; @@ -2872,6 +2974,87 @@ int run_du(IRadio *dev, const Cfg &c) { std::lock_guard lk(mu); srv.published(rar_on_air_set); } + /* A staged channel move: in the idle part of the superframe before its + * instant (after every slot), the beacon stops, the radio retunes and the + * beacon restarts — the TSF does not move, so neither does the grid. Only + * a move its UEs were told of (its IE aired in >= 3 superframes) is made; + * a beacon that will not restart on the new channel takes the DU back to + * the old one. The CU gets the outcome (Applied, or Invalid). */ + { + std::optional cs; + { + std::lock_guard lk(f1_mu); + if (cs_staged && cs_aired_id == cs_staged->id && sf < cs_staged->eff_sf) + ++cs_staged->aired; + cs = cs_staged; + } + if (cs && sf + 1 >= cs->eff_sf) { + const auto at = devourer::cell::cs_retune_at_us(grid_span_us, cs_retune_us, 1000, P); + const uint8_t from = du_ch.load(); + bool moved = false; + int64_t late_us = 0, retune_us = 0; + const char *why = ""; + if (!at || sf + 1 != cs->eff_sf) { + why = "missed"; + } else if (cs->aired < 3) { + why = "unannounced"; /* the UEs were not told: never moved without them */ + } else { + late_us = sleep_until_tsf(dev, grid->slot_start_tsf(sf, 0) + *at) - (grid->slot_start_tsf(sf, 0) + *at); + bool stopped = false; + for (int k = 0; k < 3 && !stopped; ++k) + stopped = dev->StopBeacon(); + if (!stopped) { + why = "stop_beacon"; + } else { + auto tune = [&](uint8_t ch) { + try { + dev->FastRetune(ch); + return true; + } catch (const std::exception &) { + return false; + } + }; + auto restart = [&] { + for (int k = 0; k < 3; ++k) + if (dev->StartBeacon(live_bcn.data(), live_bcn.size(), c.tu)) + return true; + return false; + }; + const auto r0 = std::chrono::steady_clock::now(); + const bool tuned = tune(cs->channel); + retune_us = std::chrono::duration_cast(std::chrono::steady_clock::now() - r0) + .count(); + if (tuned && restart()) { + moved = true; + du_ch.store(cs->channel); + } else { + why = tuned ? "restart_beacon" : "retune"; + /* back where its UEs can still find it */ + const bool back = !tuned || tune(from); + if (!back || !restart()) + emit("{\"ev\":\"cell.cs.stranded\"}"); /* no beacon: the CU sees the DU go quiet */ + } + } + } + char l[240]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.cs.switch\",\"id\":%u,\"from\":%u,\"to\":%u,\"sf\":%lld,\"ok\":%d," + "\"retune_us\":%lld,\"late_us\":%lld,\"aired\":%d,\"why\":\"%s\"}", + cs->id, from, cs->channel, (long long)sf, moved ? 1 : 0, (long long)retune_us, (long long)late_us, + cs->aired, why); + emit(l); + if (f1_on) { /* the outcome, from this thread (a UDP send is thread-safe) */ + const auto ack = devourer::cell::f1::encode_ack({c.cell, devourer::cell::f1::AckKind::ChannelSwitch, cs->id, + moved ? devourer::cell::f1::AckStatus::Applied : devourer::cell::f1::AckStatus::Invalid, + static_cast(std::max(0, sf + net_off.load()))}, + f1_mac); + sendto(f1_fd.fd, ack.data(), ack.size(), 0, reinterpret_cast(&f1_addr), f1_addr_len); + } + std::lock_guard lk(f1_mu); + cs_staged.reset(); + cs_aired_id = 0; + } + } ++sf; } dev->StopBeacon(); @@ -3060,6 +3243,19 @@ int run_ue(IRadio *dev, const Cfg &c) { * serving DU's beacon addressed to us, the one accepted (fresh, instant * ahead), and the target's grant maps heard while waiting for it. */ std::optional ho_seen, ho_pending; + /* M5: the serving cell's channel move (ChannelSwitch.h), as seen in its + * beacon, accepted (CsReceiver), and after it the way back if the cell is + * not heard on the new channel. */ + std::optional cs_seen, cs_pending; + devourer::cell::CsReceiver cs_rx; + struct CsBack { + uint8_t to = 0; /* the channel it came from */ + int64_t deadline_sf = 0; + uint64_t bcn_at = 0; /* serving beacons counted at the move */ + }; + std::optional cs_back; + std::atomic serv_bcn_n{0}; /* serving-cell beacons heard */ + const int64_t ue_cs_retune_us = envl("CELL_CS_RETUNE_US", 26000, 1000, 100000); std::string ho_target_key; /* the accepted handover's target key (hex) */ CellMac target_auth; /* ... as a Mac: the target's buffered maps (mu) */ uint32_t ho_no_ctx_id = 0; /* the last command refused for no context (logged once) */ @@ -3300,6 +3496,9 @@ int run_ue(IRadio *dev, const Cfg &c) { if (auto h = devourer::cell::find_ho_ie(p.Data.data(), p.Data.size(), cell_a.load(), static_cast(me_a.load()), auth)) ho_seen = h; + if (auto cs = devourer::cell::find_cs_ie(p.Data.data(), p.Data.size(), cell_a.load(), auth)) + cs_seen = cs; + ++serv_bcn_n; wrap.feed(p.RxAtrib.tsfl); if (reattach_sf > 0 && sync.Steps() != reattach_armed_steps) { reattach_armed_steps = sync.Steps(); @@ -3578,6 +3777,8 @@ int run_ue(IRadio *dev, const Cfg &c) { no_step_until_sf = -1; ho_seen.reset(); /* commands aired by the old instance */ ho_pending.reset(); + cs_seen.reset(); + cs_pending.reset(); /* A target that restarted mid-handover lost the staged UE: the way * back stays, its deadline re-based on the new count. */ if (ho_back) @@ -3598,6 +3799,64 @@ int run_ue(IRadio *dev, const Cfg &c) { * SHIFTED to the target's (TsfSync::ShiftRemote, the CU's delta), the * identity becomes the target's, and the target's buffered grant * maps take over. No scan, no re-acquisition, no association. */ + /* M5: the serving cell moves to another channel (ChannelSwitch.h): + * accepted once (newer id, instant ahead); in the idle part of the + * superframe before its instant the UE stops its beacon and retunes + * with the cell (the retune below), restarting the beacon at once — no + * TSF change, so its uplink keeps its slot. Not heard there within 10 + * superframes: back to the channel it came from. */ + if (cs_seen) { + /* Not while a handover is pending: then not taken (nor marked seen), + * so the cell's next beacons offer it again. */ + if (!ho_pending && cs_rx.accept(*cs_seen, static_cast(sfn))) { + cs_pending = cs_seen; + char l[160]; + snprintf(l, sizeof l, "{\"ev\":\"cell.ue.cs_staged\",\"ue\":%d,\"id\":%u,\"channel\":%u,\"eff_sf\":%llu}", + me_a.load(), cs_seen->id, cs_seen->channel, (unsigned long long)cs_seen->effective_sf); + emit(l); + } + cs_seen.reset(); + } + if (cs_pending && du_now >= 0 && !retune_to) { + int64_t span = 0; + for (const auto &x : grid->slots()) + span = std::max(span, x.start_us + x.len_us); + const auto at = devourer::cell::cs_retune_at_us(span, ue_cs_retune_us, 1000, P); + /* no window of its own (its budget): still right after every slot */ + const int64_t win = (static_cast(cs_pending->effective_sf) - 1) * P + at.value_or(span + 1000); + if (du_now >= win) { + if (cs_pending->channel != cur_ch) { + retune_to = cs_pending->channel; + cs_back = CsBack{cur_ch, static_cast(cs_pending->effective_sf) + 10, serv_bcn_n.load()}; + /* the beacon restarts on the new channel before its next uplink slot */ + next_pass_at = std::chrono::steady_clock::now() + std::chrono::microseconds(ue_cs_retune_us + 2000); + } + char l[200]; + snprintf(l, sizeof l, + "{\"ev\":\"cell.ue.cs_switch\",\"ue\":%d,\"id\":%u,\"from\":%u,\"to\":%u,\"late_us\":%lld}", + me_a.load(), cs_pending->id, cur_ch, cs_pending->channel, (long long)(du_now - win)); + emit(l); + cs_pending.reset(); + } else { + const auto due = std::chrono::steady_clock::now() + std::chrono::microseconds(win - du_now); + if (!next_pass_at || due < *next_pass_at) + next_pass_at = due; + } + } + if (cs_back && !retune_to) { + if (cur_ch == cs_back->to) { + cs_back.reset(); /* its own retune failed: still on the old channel */ + } else if (serv_bcn_n.load() > cs_back->bcn_at && sfn >= cs_back->deadline_sf - 10) { + cs_back.reset(); /* the cell is heard on the new channel */ + } else if (sfn > cs_back->deadline_sf) { + retune_to = cs_back->to; + char l[160]; + snprintf(l, sizeof l, "{\"ev\":\"cell.ue.cs_fallback\",\"ue\":%d,\"to\":%u,\"sf\":%lld}", + me_a.load(), cs_back->to, (long long)sfn); + emit(l); + cs_back.reset(); + } + } if (ho_seen) { /* Per-cell keys: the target's key, unwrapped from the context the IE * carries for this UE alone. Without one (or a context not for this diff --git a/src/cell/ChannelSwitch.h b/src/cell/ChannelSwitch.h new file mode 100644 index 00000000..7f350fbe --- /dev/null +++ b/src/cell/ChannelSwitch.h @@ -0,0 +1,171 @@ +/* ChannelSwitch.h — M5 (#259) a running cell moves to another channel, with + * its UEs: the CU's coordination planner gives a DU a spare channel + * (Coord.h coord_plan, frequency separation); the DU announces it in its + * beacon for some superframes and, at the instant, it and every UE it serves + * retune together. An 802.11 channel-switch announcement in spirit, on the + * cell's synced superframe count. + * + * The IE (221, OUI kGrantOui), body little-endian: + * 'C' 'S' ver(1) id(4) cell(2) channel(1) effective_sf(8, the DU's count) + * crc16(2) tag(8) + * The tag (label 'S') is the cell key (as the grant map's): only the serving + * DU announces a move its UEs act on. + * + * Timing: the move happens in the idle part of superframe effective_sf - 1, + * after every slot (the active span) and before the next beacon; the DU and + * the UE each stop their beacon, retune and restart it there — the TSF does + * not move, so the beacon grid and every slot keep their phase. The first + * superframe on the new channel is effective_sf. + * + * Headless; cell_m5 ctest. */ +#ifndef DEVOURER_CELL_CHANNELSWITCH_H +#define DEVOURER_CELL_CHANNELSWITCH_H + +#include +#include +#include +#include +#include + +#include "cell/GrantMap.h" + +namespace devourer::cell { + +constexpr uint8_t kCsIeVersion = 1; +constexpr size_t kCsTagLen = 8; + +struct CsIe { + uint32_t id = 0; /* CU-wide, increasing: a later move supersedes */ + uint16_t cell = 0; /* the cell that moves (whose beacon carries it) */ + uint8_t channel = 0; /* where to */ + uint64_t effective_sf = 0; /* the first superframe on the new channel, the DU's count */ + bool operator==(const CsIe &) const = default; +}; + +namespace cs_detail { +constexpr size_t kBodyLen = 2 + 1 + 4 + 2 + 1 + 8; /* 'C' 'S' .. effective_sf */ +inline void put(std::vector &o, uint64_t v, int n) { + for (int i = 0; i < n; ++i) + o.push_back(static_cast(v >> (8 * i))); +} +inline uint64_t get(const uint8_t *p, int n) { + uint64_t v = 0; + for (int i = 0; i < n; ++i) + v |= static_cast(p[i]) << (8 * i); + return v; +} +inline bool tag(const Mac &mac, const uint8_t *body, size_t n, uint8_t out[kCsTagLen]) { + if (!mac) { + std::memset(out, 0, kCsTagLen); + return true; + } + std::vector m = {'S'}; + m.insert(m.end(), body, body + n); + uint8_t full[20]; + if (!mac(m.data(), m.size(), full)) + return false; + std::memcpy(out, full, kCsTagLen); + return true; +} +} /* namespace cs_detail */ + +/* The full IE; empty if the Mac fails. */ +inline std::vector encode_cs_ie(const CsIe &s, const Mac &mac) { + using namespace cs_detail; + std::vector b = {'C', 'S', kCsIeVersion}; + put(b, s.id, 4); + put(b, s.cell, 2); + b.push_back(s.channel); + put(b, s.effective_sf, 8); + put(b, detail::crc16_ccitt(b.data(), b.size()), 2); + uint8_t t[kCsTagLen]; + if (!tag(mac, b.data(), b.size(), t)) + return {}; + b.insert(b.end(), t, t + kCsTagLen); + std::vector ie = {221, static_cast(3 + b.size()), kGrantOui[0], kGrantOui[1], + kGrantOui[2]}; + ie.insert(ie.end(), b.begin(), b.end()); + return ie; +} + +/* One IE's payload (after the OUI): nullopt for another IE, a bad CRC or + * (keyed) a tag that does not verify. */ +inline std::optional decode_cs_body(const uint8_t *b, size_t n, const Mac &mac) { + using namespace cs_detail; + if (b == nullptr || n != kBodyLen + 2 + kCsTagLen || b[0] != 'C' || b[1] != 'S' || b[2] != kCsIeVersion) + return std::nullopt; + if (get(b + kBodyLen, 2) != detail::crc16_ccitt(b, kBodyLen)) + return std::nullopt; + if (mac) { + uint8_t want[kCsTagLen]; + if (!tag(mac, b, kBodyLen + 2, want)) + return std::nullopt; + uint8_t d = 0; + for (size_t i = 0; i < kCsTagLen; ++i) + d |= static_cast(want[i] ^ b[kBodyLen + 2 + i]); + if (d != 0) + return std::nullopt; + } + CsIe s; + s.id = static_cast(get(b + 3, 4)); + s.cell = static_cast(get(b + 7, 2)); + s.channel = b[9]; + s.effective_sf = get(b + 10, 8); + return s; +} + +/* The channel-switch IE in one BEACON (radiotap stripped) of cell `cell`. */ +inline std::optional find_cs_ie(const uint8_t *mpdu, size_t len, uint16_t cell, const Mac &mac) { + if (mpdu == nullptr || len < 36 || mpdu[0] != 0x80) + return std::nullopt; + std::optional best; + for (size_t off = 36; off + 2 <= len;) { + const size_t l = mpdu[off + 1]; + if (off + 2 + l > len) + break; + const uint8_t *v = mpdu + off + 2; + if (mpdu[off] == 221 && l > 3 && v[0] == kGrantOui[0] && v[1] == kGrantOui[1] && v[2] == kGrantOui[2]) + if (auto s = decode_cs_body(v + 3, l - 3, mac)) + if (s->cell == cell && (!best || s->id > best->id)) + best = s; + off += 2 + l; + } + return best; +} + +/* The UE's side: a captured, validly tagged beacon replayed later must not + * move it — an IE is taken only when its id is above every one taken so far + * AND its instant is still ahead of the UE's view of the cell's superframe. + * The same move again (its id) is not a new one. */ +class CsReceiver { +public: + bool accept(const CsIe &s, uint64_t now_sf) { + if (have_ && s.id <= last_id_) + return false; + if (s.effective_sf <= now_sf) + return false; + have_ = true; + last_id_ = s.id; + return true; + } + +private: + bool have_ = false; + uint32_t last_id_ = 0; +}; + +/* When in superframe effective_sf - 1 a node retunes (us into it): after + * the active span (`span_us`, every slot) and `guard_us`; nullopt if the + * retune (`retune_us`) and the guard would not end before the next beacon + * (`period_us`): then the grid leaves no room for a move. */ +inline std::optional cs_retune_at_us(int64_t span_us, int64_t retune_us, int64_t guard_us, + int64_t period_us) { + const int64_t at = span_us + guard_us; + if (at + retune_us + guard_us > period_us) + return std::nullopt; + return at; +} + +} /* namespace devourer::cell */ + +#endif /* DEVOURER_CELL_CHANNELSWITCH_H */ diff --git a/src/cell/F1.h b/src/cell/F1.h index 2699d8fa..0eb03dd0 100644 --- a/src/cell/F1.h +++ b/src/cell/F1.h @@ -80,7 +80,7 @@ namespace devourer::cell::f1 { /* 12: Downlink (CU -> DU, the CU's DL PDUs; DAPS duplicates them). 13: * Report carries the DU's suspend-gap period. */ /* 14: Uplink carries its reception instant (net_us). */ -constexpr uint8_t kVersion = 14; /* v14: Uplink.net_us */ +constexpr uint8_t kVersion = 15; /* v14: Uplink.net_us; v15: ChannelSwitch */ constexpr size_t kTagLen = 8; constexpr size_t kMaxFixed = 8; constexpr size_t kMaxPositions = kMaxUesPerCell; @@ -93,7 +93,7 @@ constexpr size_t kMaxUeMeas = kMaxUesPerCell * (kMaxNeighbours + 1); /* 216 < 25 constexpr size_t kMaxDatagram = 8192; enum class Type : uint8_t { Report = 1, Schedule = 2, Ack = 3, Handover = 4, Uplink = 5, Neighbours = 6, - Phase = 7, AreaKeys = 8, Downlink = 9 }; + Phase = 7, AreaKeys = 8, Downlink = 9, ChannelSwitch = 10 }; enum class AckStatus : uint8_t { Staged = 0, Applied = 1, Stale = 2, Late = 3, Invalid = 4 }; inline const char *ack_status_name(AckStatus s) { @@ -188,7 +188,7 @@ struct Schedule { }; /* What an Ack acknowledges. */ -enum class AckKind : uint8_t { Schedule = 0, Handover = 1 }; +enum class AckKind : uint8_t { Schedule = 0, Handover = 1, ChannelSwitch = 2 }; struct Ack { uint16_t du = 0; @@ -351,7 +351,7 @@ inline void fill_report_measurements(Report &r, uint16_t own, const std::vector< inline std::optional peek_type(const uint8_t *p, size_t n) { if (p == nullptr || n < 4 + kTagLen || p[0] != 'F' || p[1] != '1' || p[2] != kVersion) return std::nullopt; - if (p[3] < 1 || p[3] > 9) + if (p[3] < 1 || p[3] > static_cast(Type::ChannelSwitch)) return std::nullopt; return static_cast(p[3]); } @@ -557,7 +557,7 @@ inline std::optional decode_ack(const uint8_t *p, size_t n, const Mac &mac) Ack a; a.du = static_cast(r.get(2)); const uint8_t kind = static_cast(r.get(1)); - if (kind > static_cast(AckKind::Handover)) + if (kind > static_cast(AckKind::ChannelSwitch)) return std::nullopt; a.kind = static_cast(kind); a.version = static_cast(r.get(4)); @@ -618,6 +618,49 @@ inline std::optional> decode_handover(const uint8 return std::make_pair(du, h); } +/* A running cell's move to another channel (ChannelSwitch.h), CU -> that + * DU: from the network superframe `effective_net_sf` it serves on `channel`. + * `seq` orders the copies (a replayed or reordered one is Stale). The DU + * acks each copy Staged (and announces the move), or Late/Invalid; at the + * instant it acks the outcome: Applied once it moved, Invalid if it did not + * (not announced long enough, missed, or the radio failed). */ +struct ChannelSwitchCmd { + uint16_t du = 0; + uint32_t id = 0; + uint8_t channel = 0; + uint64_t effective_net_sf = 0; + uint64_t seq = 0; + bool operator==(const ChannelSwitchCmd &) const = default; +}; + +inline std::vector encode_channel_switch(const ChannelSwitchCmd &c, const Mac &mac) { + using namespace detail; + auto m = head(Type::ChannelSwitch); + put(m, c.du, 2); + put(m, c.id, 4); + m.push_back(c.channel); + put(m, c.effective_net_sf, 8); + put(m, c.seq, 8); + return seal(std::move(m), mac); +} + +inline std::optional decode_channel_switch(const uint8_t *p, size_t n, const Mac &mac) { + using namespace detail; + auto o = open(p, n, Type::ChannelSwitch, mac); + if (!o) + return std::nullopt; + Rd &r = *o; + ChannelSwitchCmd c; + c.du = static_cast(r.get(2)); + c.id = static_cast(r.get(4)); + c.channel = static_cast(r.get(1)); + c.effective_net_sf = r.get(8); + c.seq = r.get(8); + if (!r.ok || r.i != r.n || c.channel == 0) + return std::nullopt; + return c; +} + /* DAPS (M6): one UE uplink a DU heard, forwarded to the CU. `cell`/`ue` * are the identity it was heard under (the UE beacon's SA); `seq` its * common sequence (Handover.h ul_seq_from_egress). */ diff --git a/tests/cell_m5_selftest.cpp b/tests/cell_m5_selftest.cpp index fc88f300..8139f7a9 100644 --- a/tests/cell_m5_selftest.cpp +++ b/tests/cell_m5_selftest.cpp @@ -8,6 +8,7 @@ #include #include +#include "cell/ChannelSwitch.h" #include "cell/Coord.h" #include "cell/F1.h" #include "cell/SlotGrid.h" @@ -294,6 +295,42 @@ int main() { CHECK(st.cu_alive(4100)); } + /* A running cell's move to another channel (ChannelSwitch.h): the beacon + * IE, the UE's acceptance, the window, and the F1 command. */ + { + const Mac k1 = fake_mac(11), k2 = fake_mac(22); + const CsIe cs{77, 2, 157, 5000}; + auto ie = encode_cs_ie(cs, k1); + std::vector bcn(36, 0); + bcn[0] = 0x80; + bcn.insert(bcn.end(), ie.begin(), ie.end()); + CHECK(find_cs_ie(bcn.data(), bcn.size(), 2, k1) == cs); + CHECK(!find_cs_ie(bcn.data(), bcn.size(), 2, k2)); /* another cell's key */ + CHECK(!find_cs_ie(bcn.data(), bcn.size(), 3, k1)); /* another cell */ + auto t = bcn; + t[36 + 5 + 9] ^= 1; /* the channel byte: CRC and tag */ + CHECK(!find_cs_ie(t.data(), t.size(), 2, k1)); + CHECK(find_cs_ie(bcn.data(), bcn.size(), 2, {}) == cs); /* unkeyed cell */ + CsReceiver rx; + CHECK(!rx.accept(cs, 5000)); /* its instant has come: spent */ + CHECK(rx.accept(cs, 4990)); + CHECK(!rx.accept(cs, 4991)); /* the same move again */ + CHECK(!rx.accept(CsIe{76, 2, 36, 6000}, 4991)); /* an older id, replayed */ + CHECK(rx.accept(CsIe{78, 2, 36, 6000}, 4992)); /* a later move */ + /* The window: after the 20.5 ms span + 1 ms guard; 26 ms retune fits a + * 102.4 ms superframe; a span reaching 80 ms leaves no room. */ + CHECK(cs_retune_at_us(20500, 26000, 1000, 102400) == 21500); + CHECK(!cs_retune_at_us(80000, 26000, 1000, 102400)); + const f1::ChannelSwitchCmd cmd{2, 77, 157, 17523900000ull, 9}; + auto m = f1::encode_channel_switch(cmd, k1); + CHECK(f1::peek_type(m.data(), m.size()) == f1::Type::ChannelSwitch); + CHECK(f1::decode_channel_switch(m.data(), m.size(), k1) == cmd); + CHECK(!f1::decode_channel_switch(m.data(), m.size(), k2)); + auto ack = f1::encode_ack({2, f1::AckKind::ChannelSwitch, 77, f1::AckStatus::Staged, 17523899990ull}, k1); + auto da = f1::decode_ack(ack.data(), ack.size(), k1); + CHECK(da && da->kind == f1::AckKind::ChannelSwitch && da->version == 77); + } + if (failures == 0) std::printf("cell_m5_selftest: all checks passed\n"); return failures == 0 ? 0 : 1; diff --git a/tools/ran-controller/main.cpp b/tools/ran-controller/main.cpp index ff466779..4c754814 100644 --- a/tools/ran-controller/main.cpp +++ b/tools/ran-controller/main.cpp @@ -13,8 +13,11 @@ // changed DU takes effect on ONE network superframe (now + RAN_LEAD_SF); // unchanged ones get their current schedule again (the keepalive). // A DU that answers Stale / Late / Invalid gets a fresh version next round. -// Channel separation the planner proposes is logged (ran.retune), not -// applied: the DUs retune only at start (M5 scope). +// Channel separation the planner proposes is logged (ran.retune); with +// RAN_RETUNE_APPLY=1 it is applied: the DU is told to move (F1 +// ChannelSwitch, src/cell/ChannelSwitch.h) RAN_CS_LEAD_SF superframes ahead, +// announces it in its beacon, and it and its UEs retune together. One move +// at a time, never across a handover (ran.cs_cmd / ran.cs_ack / ran.cs_done). // // Env (defaults in brackets): // RAN_PORT [47254] UDP port the DUs report to @@ -29,6 +32,11 @@ // RAN_LEAD_SF [12] schedule lead (> the DUs' CELL_F1_MIN_LEAD_SF) // RAN_PLAN_MS [1000] plan / keepalive period // RAN_SPARE_CHANNELS channels the planner may propose (advisory) +// RAN_RETUNE_APPLY [0] 1: apply a proposal — move that running DU and +// its UEs (F1 ChannelSwitch, ChannelSwitch.h) +// RAN_CS_LEAD_SF [20] a move's instant, this many superframes ahead +// RAN_CS_BACKOFF_S [30] a DU whose move was refused or timed out is not +// asked again before this // Handover evaluation (M6, #260) — OBSERVE ONLY: for every UE the CU runs the // A3 (CHO) condition (src/cell/Handover.h) on the DUs' filtered reports — // the serving DU's own measurement against every other DU's heard one — and @@ -225,6 +233,9 @@ int main() { const bool plan_on = envl("RAN_PLAN", 1, 0, 1) != 0; const int edge_dbm = static_cast(envl("RAN_EDGE_DBM", -90, -127, 0)); const uint64_t lead_sf = static_cast(envl("RAN_LEAD_SF", 12, 2, 100000)); + const bool retune_apply = envl("RAN_RETUNE_APPLY", 0, 0, 1) != 0; + const uint64_t cs_lead_sf = static_cast(envl("RAN_CS_LEAD_SF", 20, 4, 100000)); + const int cs_backoff_s = static_cast(envl("RAN_CS_BACKOFF_S", 30, 1, 3600)); const int plan_ms = static_cast(envl("RAN_PLAN_MS", 1000, 50, 60000)); const int secs = static_cast(envl("RAN_SECS", 120, 1, 86400)); const bool ho_on = envl("RAN_HO", 1, 0, 1) != 0; @@ -358,6 +369,17 @@ int main() { }; std::map cancels; uint32_t ho_last_id = 0; + /* A channel move in flight (RAN_RETUNE_APPLY): resent until staged, done + * when the DU reports the new channel at/after the instant. */ + struct CsFlight { + f1::ChannelSwitchCmd cmd; + uint8_t from = 0; + bool staged = false; + }; + std::optional cs_flight; + uint32_t cs_last_id = 0; + /* A DU whose move was refused or timed out: not asked again before this. */ + std::map cs_hold; devourer::cell::UlStreams ul; /* Per stream: the newest seq delivered, its latest identity (labels only), * when it was last fed, and the seqs missing so far with the DU whose @@ -681,6 +703,19 @@ int main() { } } } else if (ty == f1::Type::Ack) { + if (auto ca = f1::decode_ack(buf, static_cast(n), mac); + ca && ca->kind == f1::AckKind::ChannelSwitch && cs_flight && ca->version == cs_flight->cmd.id && + ca->du == cs_flight->cmd.du && ca->status != f1::AckStatus::Stale) { + const bool ok = ca->status == f1::AckStatus::Staged || ca->status == f1::AckStatus::Applied; + emit("{\"ev\":\"ran.cs_ack\",\"id\":" + std::to_string(ca->version) + ",\"du\":" + + std::to_string(ca->du) + ",\"status\":\"" + f1::ack_status_name(ca->status) + "\"}"); + if (ok) { + cs_flight->staged = true; /* Applied: done once its report shows the channel */ + } else { /* refused, or not made: not asked again for RAN_CS_BACKOFF_S */ + cs_hold[cs_flight->cmd.du] = std::chrono::steady_clock::now() + std::chrono::seconds(cs_backoff_s); + cs_flight.reset(); + } + } if (auto a = f1::decode_ack(buf, static_cast(n), mac); a && a->kind == f1::AckKind::Handover) { if (auto c = cancels.find(a->version); @@ -1030,7 +1065,8 @@ int main() { forcing = true; /* done only once a command is in flight */ } if (ho_exec && act) { - bool busy = flights.count(key) > 0; + bool busy = flights.count(key) > 0 || + (cs_flight && (cs_flight->cmd.du == *act || cs_flight->cmd.du == cell)); for (const auto &fl : flights) busy = busy || fl.second.cmd.target == *act || fl.second.cmd.source == *act || fl.second.cmd.target == cell || fl.second.cmd.source == cell; @@ -1359,6 +1395,26 @@ int main() { const uint64_t est = d.rep.net_sf + static_cast(age_us / d.rep.period_us); net_now = std::max(net_now, est); } + /* A channel move in flight: resent until staged; done once the DU + * reports the new channel at or after the instant. */ + if (cs_flight) { + const DuState &d = dus.at(cs_flight->cmd.du); + if (d.rep.channel == cs_flight->cmd.channel && d.rep.net_sf >= cs_flight->cmd.effective_net_sf) { + emit("{\"ev\":\"ran.cs_done\",\"id\":" + std::to_string(cs_flight->cmd.id) + ",\"du\":" + + std::to_string(cs_flight->cmd.du) + ",\"from\":" + std::to_string(cs_flight->from) + ",\"to\":" + + std::to_string(cs_flight->cmd.channel) + ",\"net_sf\":" + std::to_string(d.rep.net_sf) + "}"); + cs_flight.reset(); + } else if (net_now > cs_flight->cmd.effective_net_sf + 50) { + emit("{\"ev\":\"ran.cs_timeout\",\"id\":" + std::to_string(cs_flight->cmd.id) + "}"); + cs_hold[cs_flight->cmd.du] = now + std::chrono::seconds(cs_backoff_s); + cs_flight.reset(); + } else if (net_now + 2 < cs_flight->cmd.effective_net_sf) { + /* Resent until the instant, staged or not: a DU that restarted after + * staging lost the move, and re-stages it from this. */ + cs_flight->cmd.seq = next_seq(); + send(d, f1::encode_channel_switch(cs_flight->cmd, mac)); + } + } const size_t before = devourer::cell::coord_conflicts(view); std::map> target; devourer::cell::CoordPlan p; @@ -1380,6 +1436,22 @@ int main() { fputs(txt.c_str(), stdout); fflush(stdout); } + /* Applied (RAN_RETUNE_APPLY): one DU at a time, none in a handover. */ + const bool held = !adv.retuned.empty() && cs_hold.count(adv.retuned.front()) && + now < cs_hold.at(adv.retuned.front()); + if (retune_apply && !cs_flight && flights.empty() && !adv.retuned.empty() && !held) { + const uint16_t du = adv.retuned.front(); + const DuState &d = dus.at(du); + CsFlight f; + cs_last_id = std::max(cs_last_id + 1, version_base); + f.cmd = {du, cs_last_id, adv.channel.at(du), net_now + cs_lead_sf, next_seq()}; + f.from = d.rep.channel; + cs_flight = f; + send(d, f1::encode_channel_switch(f.cmd, mac)); + emit("{\"ev\":\"ran.cs_cmd\",\"id\":" + std::to_string(f.cmd.id) + ",\"du\":" + std::to_string(du) + + ",\"from\":" + std::to_string(f.from) + ",\"to\":" + std::to_string(f.cmd.channel) + + ",\"effective_net_sf\":" + std::to_string(f.cmd.effective_net_sf) + "}"); + } } } else { for (const CoordDu &c : view)