firmware_wio_tracker_l1/
main.rs

1// ULCP device firmware shared by the LilyGO T-Echo and Seeed
2// SenseCAP T-1000E targets. Cargo features select the board-specific glue.
3//
4// Exposes the board's LoRa radio as a host-controlled PHY speaking the minimal
5// ULCP protocol plus advertised full-profile extensions
6// over USB-CDC/HDLC-Lite and encrypted+bonded BLE GATT/SAR. The host owns its
7// own MAC and drives this device through `umsh::ulcp::UlcpDevice`;
8// alongside that session, the **device node** (device_node.rs) runs a full
9// on-board MAC/node stack for the device identity, sharing the radio through
10// the mux.
11//
12// Protocol behavior lives in `umsh-ulcp-device::Session` (host-tested,
13// no I/O); this binary is only glue:
14//
15// Task layout (steady state):
16//   - main():            initializes MPSL/SDC and joins BLE, USB, heartbeat
17//   - radio_task:        owns lora_phy::LoRa via umsh_radio_loraphy::device_runner;
18//                        modulation/frequency/power are pushed at runtime
19//                        through DEVICE_CTL as the host sets properties
20//   - radio_mux_task:    multiplexes the physical radio across its clients
21//                        (per-client TX completion routing, RX fan-out);
22//                        the session is client A, the device node client B
23//   - node_pump_task /   the device node: MAC pump + beacon requests for
24//     node_beacon_task   the device identity (spawned only when a persisted
25//                        identity exists; dormant otherwise)
26//   - usb_in_task:       owns CdcAcmRescue + HDLC decoder; forwards frames and
27//                        attach/detach edges into INPUT_CH (keeps
28//                        read_packet out of any select, so cancel safety
29//                        never depends on the USB driver)
30//   - device_task:          hosts the shared ULCP driver
31//                        (umsh_ulcp_runtime::driver): the Session
32//                        select loop over INPUT_CH, radio RX, and TX
33//                        completions, with board couplings via BoardDeviceEnv
34//   - output_task:       owns the USB Sender + HDLC encoder, drains
35//                        OUT_CH.wired
36//   - ble_app:           advertising + encrypted/bond-gated GATT/SAR edges,
37//                        pairing policy, generation-tagged OUT_CH.ble, and
38//                        MPSL-coordinated PIN/bond persistence
39//   - button_task:       resolves the side button into display-menu gestures
40//                        on a board where it is the only control
41//   - back_button_task:  the same button beside a pad, where it means Back
42//                        and the power-off hold and nothing else
43//   - display_task:      owns the e-paper BLE menu and its attention policy
44//   - touch_task:        publishes the touch button's backlight demand
45//   - backlight_task:    arbitrates backlight demand (locate alert wins)
46//   - shutdown_task:     tri-states peripheral pins, drops the rail,
47//                        enters System OFF
48//
49// CMD_RST is a protocol-level reset: all protocol state returns to
50// post-reset values and the radio is re-applied (disabled), but the MCU
51// and the USB link stay up. Host attach resets only session state
52// (full-protocol semantics): the device domain — PHY configuration and
53// enable state, device name, duty accounting — is untouched, and
54// nothing is emitted; the reset notice is only sent for CMD_RST, so the
55// host never sees an unsolicited reset it didn't ask for mid-handshake.
56//
57// Safety primitives inherited from the BSP (see umsh-bsp-nrf52840):
58//   * Panic capture into reserved RAM (reported as STATUS_RESET_CRASH).
59//   * 1200-baud touchless reset and Ctrl-C × 3 + "dfu" escape to
60//     bootloader (baked into CdcAcmRescue).
61//   * Watchdog.
62
63#![cfg_attr(target_os = "none", no_std)]
64#![cfg_attr(target_os = "none", no_main)]
65// The no-ble diagnostic image compiles the full BLE support source
66// (pairing policy, bond store, GATT plumbing) with the call sites
67// cfg'd out. Silence the resulting dead-code noise for that image
68// only, so production builds keep full warning strength.
69#![cfg_attr(feature = "no-ble", allow(dead_code, unused_imports))]
70
71#[cfg(not(target_os = "none"))]
72fn main() {
73    // Host placeholder. This binary only runs on the embedded target.
74}
75
76// The device node's advertisement payloads use umsh-node's alloc-backed
77// types (they draw from the same 8 KiB heap the node stack already
78// uses).
79extern crate alloc;
80
81// Global heap allocator. The device node (umsh-sync's AsyncRefCell plus
82// umsh-node's Rc-based plumbing) allocates a small bounded amount at
83// bring-up; the ULCP session remains allocation-free. Initialized
84// with an 8 KiB region at the top of main() — the same budget the CLI
85// firmware's full stack runs in on identical hardware.
86#[cfg(target_os = "none")]
87#[global_allocator]
88static ALLOCATOR: embedded_alloc::Heap = embedded_alloc::Heap::empty();
89
90// Board-agnostic leaf modules now live in `umsh-ulcp-runtime` (Phase 5
91// extraction, increment A). Re-import them under their original names so the
92// `super::<module>` paths inside `mod firmware` resolve unchanged. Gated to the
93// firmware target because the host build compiles `mod firmware` out entirely.
94#[cfg(target_os = "none")]
95use umsh_ulcp_runtime::{ble_security, radio_mux, transport_policy};
96#[cfg_attr(not(target_os = "none"), allow(dead_code))]
97mod ble_store;
98#[cfg(target_os = "none")]
99mod device_node;
100mod proto_store;
101
102// The #[panic_handler] must live in the binary crate.
103#[cfg(target_os = "none")]
104mod panic;
105
106// lora-phy 3.x unconditionally depends on defmt. Provide a zero-overhead
107// no-op global logger so this binary links without any debug transport.
108#[cfg(target_os = "none")]
109mod defmt_logger {
110    #[defmt::global_logger]
111    struct Logger;
112    unsafe impl defmt::Logger for Logger {
113        fn acquire() {}
114        unsafe fn flush() {}
115        unsafe fn release() {}
116        unsafe fn write(_: &[u8]) {}
117    }
118    defmt::timestamp!("{=u32}", 0u32);
119}
120
121#[cfg(target_os = "none")]
122mod firmware {
123    use super::ble_security::{PairingFailureClass, PairingRuntime, pairing_enabled};
124    use super::ble_store::{self, Snapshot, StoredBond};
125    use super::proto_store;
126    use super::transport_policy::{Transport, generation_checked};
127    #[cfg(feature = "ble-debug")]
128    use core::fmt::Write as _;
129    use core::sync::atomic::{AtomicBool, AtomicU8, AtomicU16, AtomicU32, Ordering};
130    use embassy_executor::Spawner;
131    use embassy_futures::join::join;
132    #[cfg(feature = "dpad-nav")]
133    use embassy_futures::select::select_array;
134    use embassy_futures::select::{Either, Either3, Either4, select, select3, select4};
135    use embassy_nrf::bind_interrupts;
136    #[cfg(feature = "cap-gnss")]
137    use embassy_nrf::buffered_uarte::BufferedUarte;
138    use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
139    use embassy_nrf::mode::Async;
140    use embassy_nrf::pac;
141    use embassy_nrf::peripherals::{self, RNG};
142    #[cfg(feature = "t1000e")]
143    use embassy_nrf::pwm::DutyCycle;
144    #[cfg(any(feature = "t1000e", feature = "cap-buzzer"))]
145    use embassy_nrf::pwm::{Prescaler, SimpleConfig, SimplePwm};
146    use embassy_nrf::rng;
147    // The T-1000E is the one board whose converter is built in the BSP,
148    // per measurement, because its battery and light channels want
149    // different resolutions and oversampling. Every other board hands the
150    // BSP a ready-made single-channel `Saadc` from here.
151    #[cfg(feature = "t1000e")]
152    use embassy_nrf::Peri;
153    #[cfg(all(feature = "cap-battery-saadc", not(feature = "t1000e")))]
154    use embassy_nrf::saadc::{ChannelConfig, Config as SaadcConfig, Saadc};
155    use embassy_nrf::spim::{Config as SpimConfig, Frequency, Spim};
156    #[cfg(feature = "cap-gnss")]
157    use embassy_nrf::uarte::{Baudrate as UarteBaudrate, Config as UarteConfig};
158    use embassy_nrf::usb::Driver;
159    use embassy_nrf::usb::vbus_detect::SoftwareVbusDetect;
160    use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
161    use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
162    use embassy_sync::channel::Channel;
163    use embassy_sync::mutex::Mutex;
164    use embassy_sync::signal::Signal;
165    use embassy_time::{Delay, Duration, Instant, Timer};
166    use embassy_usb::class::cdc_acm::{CdcAcmClass, State};
167    use embassy_usb::{Builder, Config};
168    use embedded_hal_bus::spi::ExclusiveDevice;
169    use lora_phy::LoRa;
170    #[cfg(feature = "t1000e")]
171    use lora_phy::iv::GenericLr1110InterfaceVariant;
172    #[cfg(not(feature = "t1000e"))]
173    use lora_phy::iv::GenericSx126xInterfaceVariant;
174    #[cfg(feature = "t1000e")]
175    use lora_phy::lr1110::{
176        Config as LoraConfig, Lr1110, TcxoCtrlVoltage, radio_kind_params::PaSelection,
177        variant::Lr1110 as Lr1110Chip,
178    };
179    #[cfg(not(feature = "t1000e"))]
180    use lora_phy::sx126x::{Config as LoraConfig, Sx126x, Sx1262, TcxoCtrlVoltage};
181    use nrf_sdc::mpsl::{self, MultiprotocolServiceLayer};
182    use nrf_sdc::{self as sdc};
183    use static_cell::StaticCell;
184    use trouble_host::gap;
185    use trouble_host::prelude::*;
186    use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
187    use umsh_bsp_nrf52840::panic_persist::PanicSlot;
188    #[cfg(any(
189        feature = "system-off-techo",
190        feature = "system-off-wio",
191        feature = "t1000e"
192    ))]
193    use umsh_bsp_nrf52840::system_off::Port;
194    #[cfg(any(feature = "system-off-wio", feature = "power-button"))]
195    use umsh_bsp_nrf52840::system_off::ShutdownReason;
196    #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
197    use umsh_bsp_nrf52840::system_off::drive_pin_high;
198    #[cfg(any(
199        feature = "t1000e",
200        feature = "system-off-wio",
201        feature = "system-off-techo"
202    ))]
203    use umsh_bsp_nrf52840::system_off::drive_pin_low;
204    #[cfg(feature = "system-off-wio")]
205    use umsh_bsp_nrf52840::system_off::{LpcompInput, LpcompReference, arm_lpcomp_wake_up};
206    #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
207    use umsh_bsp_nrf52840::system_off::{WakePin, WakeSense, power_off, tristate_pin};
208    #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
209    use umsh_bsp_nrf52840::system_off::{WakePull, connect_input, read_pin};
210    #[cfg(feature = "t1000e")]
211    use umsh_bsp_t1000e::RF_SWITCH;
212    #[cfg(feature = "display-epd")]
213    use umsh_bsp_techo::display;
214    #[cfg(feature = "display-oled")]
215    use umsh_bsp_wio_tracker_l1::display;
216    // Board-selected battery BSP module, used only by the shared
217    // `cap-battery-saadc` snapshot/load-hint code below.
218    #[cfg(all(feature = "cap-battery-saadc", feature = "board-sensecap-solar"))]
219    use umsh_bsp_sensecap_solar::power as board_power;
220    #[cfg(all(feature = "cap-battery-saadc", feature = "t1000e"))]
221    use umsh_bsp_t1000e::power as board_power;
222    #[cfg(all(feature = "cap-battery-saadc", feature = "board-techo"))]
223    use umsh_bsp_techo::power as board_power;
224    #[cfg(all(feature = "cap-battery-saadc", feature = "board-wio-tracker-l1"))]
225    use umsh_bsp_wio_tracker_l1::power as board_power;
226    #[cfg(all(feature = "cap-battery-saadc", feature = "board-xiao-nrf52"))]
227    use umsh_bsp_xiao_nrf52::power as board_power;
228    // Board-selected GNSS power control. One board feature is active per
229    // image, so this alias resolves to exactly one type and the pump's
230    // task shim stays concrete — which is what `#[embassy_executor::task]`
231    // requires, since a task function cannot be generic.
232    #[cfg(all(feature = "cap-gnss", feature = "board-techo"))]
233    type BoardGnss = umsh_bsp_techo::gnss::Gnss<'static>;
234    #[cfg(all(feature = "cap-gnss", feature = "t1000e"))]
235    type BoardGnss = umsh_bsp_t1000e::gnss::Gnss<'static>;
236    #[cfg(all(feature = "cap-gnss", feature = "board-wio-tracker-l1"))]
237    type BoardGnss = umsh_bsp_wio_tracker_l1::gnss::Gnss<'static>;
238    #[cfg(all(feature = "cap-gnss", feature = "board-sensecap-solar"))]
239    type BoardGnss = umsh_bsp_sensecap_solar::gnss::Gnss<'static>;
240    /// The byte stream the pump reads.
241    #[cfg(feature = "cap-gnss")]
242    type GnssUart = BufferedUarte<'static>;
243    use umsh_crypto::CryptoEngine;
244    use umsh_crypto::software::{SoftwareAes, SoftwareSha256};
245    use umsh_ulcp::ble::BleLinkState;
246    #[cfg(feature = "has-display")]
247    use umsh_ulcp::stats::Counter;
248    use umsh_ulcp::stats::StatsLedger;
249    use umsh_ulcp::{Status, gatt, hdlc};
250    #[cfg(feature = "cap-gnss")]
251    use umsh_ulcp_device::GnssConfig;
252    use umsh_ulcp_device::{
253        AlertConfig, BatteryFields, MAX_DEVICE_NAME_LEN, RadioSettings, SessionConfig, TimeConfig,
254    };
255
256    /// The ULCP session instantiated with this firmware's crypto
257    /// providers (software AES/SHA; Ed25519 comes in only through the
258    /// device-identity provisioning path).
259    // The physical radio remains single-flight, but the protocol session can
260    // retain several host frames. This target-specific const generic avoids a
261    // LoRa completion round trip between fragments without imposing the RAM
262    // cost on smaller/default Session users.
263    const ULCP_TX_QUEUE_CAPACITY: usize = 8;
264    type Session = umsh_ulcp_device::Session<SoftwareAes, SoftwareSha256, ULCP_TX_QUEUE_CAPACITY>;
265
266    /// Deterministic CSPRNG for device-identity generation, seeded from
267    /// the hardware TRNG at boot: the RNG peripheral itself is owned by
268    /// the SoftDevice Controller for the lifetime of the BLE stack.
269    type IdentityRng = rand_chacha::ChaCha20Rng;
270    use umsh_radio_loraphy::{DeviceControl, MAX_PAYLOAD};
271    use umsh_ulcp_runtime::driver::{
272        self, DeviceEnv, DeviceRuntime, InEvent, InputChannel, OutFrame, TransportChannels,
273    };
274    #[cfg(feature = "has-display")]
275    use umsh_ux_display_tracker::attention::{
276        Attention, AttentionConfig, DisplayKind, HoldReason, Transition,
277    };
278    #[cfg(feature = "button-nav")]
279    use umsh_ux_display_tracker::gate::{Disposition, Gate, GateReason};
280    use umsh_ux_display_tracker::menu::UiNotice;
281    #[cfg(feature = "has-display")]
282    use umsh_ux_display_tracker::menu::{MenuItems, ToggleId, UiEffect, UiInput, UiModel};
283    #[cfg(feature = "has-display")]
284    use umsh_ux_display_tracker::screen;
285    // `ButtonEvent` is the vocabulary [`Gate`] judges, so every board with
286    // a control needs it. The recognizer behind it is only for a button
287    // carrying more than one meaning — which the Wio's Back button, beside
288    // a pad, does not.
289    #[cfg(any(feature = "button-nav", feature = "t1000e"))]
290    use umsh_ux_tracker::button::ButtonEvent;
291    #[cfg(any(
292        all(feature = "button-nav", not(feature = "dpad-nav")),
293        feature = "t1000e"
294    ))]
295    use umsh_ux_tracker::button::{ButtonEdge, ButtonFsm};
296    // The display trackers take their timings from the shared class
297    // policy; only the headless T-1000E still names its own.
298    #[cfg(feature = "t1000e")]
299    use umsh_ux_tracker::button::ButtonTimings;
300    #[cfg(feature = "t1000e")]
301    use umsh_ux_tracker::buzzer::melodies as buzzer_melodies;
302    #[cfg(feature = "t1000e")]
303    use umsh_ux_tracker::led::T1000eLedEngine;
304    #[cfg(not(feature = "t1000e"))]
305    use umsh_ux_tracker::led::{LedEngine, LedTimings};
306    // The Solar P1's attention LED plays the same sequences from the
307    // generic engine.
308    #[cfg(any(feature = "t1000e", feature = "power-button"))]
309    use umsh_ux_tracker::led::LedSequence;
310
311    bind_interrupts!(struct Irqs {
312        USBD        => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
313        RNG         => rng::InterruptHandler<RNG>;
314        EGU0_SWI0   => nrf_sdc::mpsl::LowPrioInterruptHandler;
315        CLOCK_POWER => nrf_sdc::mpsl::ClockInterruptHandler;
316        RADIO       => nrf_sdc::mpsl::HighPrioInterruptHandler;
317        TIMER0      => nrf_sdc::mpsl::HighPrioInterruptHandler;
318        RTC0        => nrf_sdc::mpsl::HighPrioInterruptHandler;
319        // TWIM0/SPIM0 is the one peripheral this family uses two ways:
320        // SPIM0 → LR1110 on the T-1000E, TWIM0 → SH1106 OLED on the Wio
321        // Tracker L1. Both handlers cannot be bound at once — they claim
322        // the same peripheral — so the board picks.
323        TWISPI0     =>
324            #[cfg(not(feature = "display-oled"))]
325            embassy_nrf::spim::InterruptHandler<peripherals::TWISPI0>,
326            #[cfg(feature = "display-oled")]
327            embassy_nrf::twim::InterruptHandler<peripherals::TWISPI0>;
328        // SPIM1 → SX1262 LoRa SPI bus. embassy-nrf names this peripheral
329        // TWISPI1 (it's the shared TWIM1/SPIM1 block on nRF52840).
330        TWISPI1     => embassy_nrf::spim::InterruptHandler<peripherals::TWISPI1>;
331        // SPIM2 → SSD1681 e-paper SPI bus. embassy-nrf names this interrupt SPI2.
332        SPI2        => embassy_nrf::spim::InterruptHandler<peripherals::SPI2>;
333        SAADC       => embassy_nrf::saadc::InterruptHandler;
334        // UARTE0 → the GNSS receiver, on every board that has one. Bound
335        // unconditionally rather than per-board: an unused handler for a
336        // peripheral nothing instantiates costs a vector-table entry and
337        // saves a `cfg` fork in the one block that must stay readable.
338        UARTE0      => embassy_nrf::buffered_uarte::InterruptHandler<peripherals::UARTE0>;
339    });
340
341    // ─── Configuration ───────────────────────────────────────────────────────
342
343    /// SX1262 PA limits on this module.
344    const MIN_TX_POWER_DBM: i8 = -9;
345    const MAX_TX_POWER_DBM: i8 = 22;
346
347    const BLE_CONNECTIONS_MAX: usize = 1;
348    const BLE_L2CAP_CHANNELS_MAX: usize = 2;
349    const BLE_L2CAP_TXQ: u8 = 3;
350    const BLE_L2CAP_RXQ: u8 = 3;
351    /// Nordic's SDC buffer configuration accepts 27..=251 octets.
352    const SDC_PACKET_SIZE: u16 = 251;
353    /// Largest value the ULCP characteristics accept.
354    ///
355    /// A client may write up to ATT_MTU-3 octets in one request, and the
356    /// packet pool is configured for a 255-octet MTU, so anything smaller
357    /// than 252 here is a size the peer is entitled to send and this device
358    /// would refuse with an invalid-length error.
359    const BLE_VALUE_MAX: usize = 252;
360    #[cfg(feature = "board-techo")]
361    const DEFAULT_DEVICE_NAME: &str = "UMSH T-Echo";
362    #[cfg(feature = "t1000e")]
363    const DEFAULT_DEVICE_NAME: &str = "UMSH T-1000E";
364    // Board default + " XXXX" suffix must stay within trouble's 22-byte
365    // GAP device-name limit; a longer name fails GATT-server construction
366    // (the cause of the Solar P1 first-bringup boot loop).
367    #[cfg(feature = "board-sensecap-solar")]
368    const DEFAULT_DEVICE_NAME: &str = "UMSH Solar";
369    #[cfg(feature = "board-wio-tracker-l1")]
370    const DEFAULT_DEVICE_NAME: &str = "UMSH Wio L1";
371    #[cfg(feature = "board-xiao-nrf52")]
372    const DEFAULT_DEVICE_NAME: &str = "UMSH XIAO";
373
374    /// The board default name plus a stable per-die suffix — the low 16
375    /// bits of FICR DEVICEADDR, the same die-unique value the BLE
376    /// identity address is built from — so factory-fresh radios are
377    /// tellable apart in scan lists and on multi-board benches.
378    fn default_device_name() -> &'static str {
379        use core::fmt::Write as _;
380        static NAME: embassy_sync::once_lock::OnceLock<heapless09::String<24>> =
381            embassy_sync::once_lock::OnceLock::new();
382        NAME.get_or_init(|| {
383            let suffix = embassy_nrf::pac::FICR.deviceaddr(0).read() & 0xFFFF;
384            let mut name = heapless09::String::new();
385            let _ = write!(name, "{DEFAULT_DEVICE_NAME} {suffix:04X}");
386            name
387        })
388        .as_str()
389    }
390
391    #[gatt_server]
392    struct UlcpServer {
393        ulcp: UlcpService,
394    }
395
396    #[gatt_service(uuid = "21eb6b15-0001-4ccf-92e4-a079171bec97")]
397    struct UlcpService {
398        #[characteristic(
399            uuid = "21eb6b15-0002-4ccf-92e4-a079171bec97",
400            write,
401            write_without_response,
402            permissions(write = encrypted)
403        )]
404        frame_in: heapless09::Vec<u8, BLE_VALUE_MAX>,
405        #[characteristic(
406            uuid = "21eb6b15-0003-4ccf-92e4-a079171bec97",
407            notify,
408            permissions(cccd = encrypted)
409        )]
410        frame_out: heapless09::Vec<u8, BLE_VALUE_MAX>,
411    }
412
413    /// `PROP_DEV_VERSION`: the stack name and the release version from the
414    /// build script, in the `STACK-NAME/STACK-VERSION` form the spec
415    /// recommends. It names the firmware and nothing else — which board it
416    /// is running on is `PROP_DEV_MODEL`'s job, and boot diagnostics stay
417    /// on the debug console.
418    const DEV_VERSION: &str = concat!("umsh/", env!("GIT_DESCRIBE"));
419
420    /// `PROP_DEV_MODEL`: the hardware this image was built for. These
421    /// strings are the `description` fields of the board presets in
422    /// `scripts/firmware_image.py`, so a device can be matched against a
423    /// release manifest entry and against `site/data/hardware.toml`.
424    #[cfg(feature = "board-techo")]
425    const DEV_MODEL: &str = "LilyGO T-Echo";
426    #[cfg(feature = "t1000e")]
427    const DEV_MODEL: &str = "Seeed SenseCAP T1000-E";
428    #[cfg(feature = "board-sensecap-solar")]
429    const DEV_MODEL: &str = "SenseCAP Solar Node P1 / P1-Pro";
430    #[cfg(feature = "board-wio-tracker-l1")]
431    const DEV_MODEL: &str = "Seeed Wio Tracker L1 / L1 Pro";
432    #[cfg(feature = "board-xiao-nrf52")]
433    const DEV_MODEL: &str = "Seeed XIAO nRF52840 + Wio-SX1262 Kit";
434
435    fn session_config() -> SessionConfig {
436        SessionConfig {
437            dev_version: DEV_VERSION,
438            dev_model: Some(DEV_MODEL),
439            default_device_name: default_device_name(),
440            mtu: MAX_PAYLOAD as u16,
441            // Fixed at build time: LoRa::new(.., false, ..) below sets the
442            // private-network word 0x12 → SX126x registers 0x1424.
443            sync_word: umsh_ulcp::profiles::DEFAULT.sync_word,
444            min_tx_power_dbm: MIN_TX_POWER_DBM,
445            max_tx_power_dbm: MAX_TX_POWER_DBM,
446            // SX1262 tunable range.
447            freq_khz_min: 150_000,
448            freq_khz_max: 960_000,
449            // Post-reset defaults: the vetted default profile, with the
450            // PHY disabled until the host enables it.
451            defaults: RadioSettings {
452                enabled: false,
453                freq_khz: umsh_ulcp::profiles::DEFAULT.freq_khz,
454                bw_hz: umsh_ulcp::profiles::DEFAULT.bw_hz,
455                sf: umsh_ulcp::profiles::DEFAULT.sf,
456                cr_denom: umsh_ulcp::profiles::DEFAULT.cr_denom,
457                tx_power_dbm: umsh_ulcp::profiles::DEFAULT_TX_POWER_DBM,
458            },
459            default_duty_limit: umsh_ulcp::profiles::DEFAULT.duty_limit,
460            duty: &DUTY_LEDGER,
461            // Every board here is battery powered, and every one now has
462            // a SAADC monitor reporting voltage, charge state, and the
463            // rest-gated OCV level estimate.
464            #[cfg(feature = "cap-battery-saadc")]
465            battery: Some(BatteryFields {
466                voltage: true,
467                level: true,
468                charge_state: true,
469            }),
470            #[cfg(not(feature = "cap-battery-saadc"))]
471            battery: Some(BatteryFields::NONE),
472            // Every board here can make itself conspicuous: the T-1000E
473            // with its buzzer, the T-Echo and Solar P1 with their
474            // indicator LEDs. `CAP_ALERT` says only that *something*
475            // happens, so the difference stays a board matter.
476            alert: Some(AlertConfig::DEFAULT),
477            // Every board here keeps a wall clock. What it does *not*
478            // claim is that the clock is set: a board with no receiver
479            // and no battery-backed RTC simply reports that it does not
480            // know what time it is until a host tells it.
481            time: Some(TimeConfig),
482            // The Solar P1 is the one board here that runs its receiver
483            // by default. It is a fixed outdoor node with a panel rather
484            // than a pocket tracker on a cell: the load it is worried
485            // about is the one it can see coming, and a node that has to
486            // be told to find itself after every reset is the worse
487            // failure. Everywhere else the receiver waits to be asked.
488            #[cfg(all(feature = "cap-gnss", feature = "board-sensecap-solar"))]
489            gnss: Some(GnssConfig::ALWAYS_ON),
490            #[cfg(all(feature = "cap-gnss", not(feature = "board-sensecap-solar")))]
491            gnss: Some(GnssConfig::DEFAULT),
492            #[cfg(not(feature = "cap-gnss"))]
493            gnss: None,
494            // The T-1000E is the one board here with an ambient light
495            // sensor fitted.
496            illuminance: cfg!(feature = "cap-illuminance"),
497            // Every board here is an nRF52840 running the SoftDevice
498            // controller, and every one can be made unfindable: see
499            // `advertising_permitted`.
500            ble: true,
501            // The bond journal and the pairing window are the same on
502            // every board here; both commands reach the machinery the
503            // front-panel menu already drives.
504            ble_pairing: true,
505            // Every nRF52 board can reset itself through the bootloader
506            // register; see the `reboot` hook below.
507            reboot: true,
508            // A real MAC runs behind every session here.
509            mac_node: true,
510            stats: Some(&STATS),
511        }
512    }
513
514    /// The one duty ledger shared by every radio client (device-node
515    /// plan increment 4): the session prices and records its own
516    /// transmissions here, and the device node's radio path admits
517    /// each transmit against the same combined budget (`duty_gate`),
518    /// so `PROP_PHY_DUTY_LIMIT` bounds session + node airtime together
519    /// and `PROP_PHY_DUTY_NOW` reports the combined figure.
520    pub(crate) static DUTY_LEDGER: umsh_ulcp_device::DutyLedger =
521        umsh_ulcp_device::DutyLedger::new();
522
523    // ─── Concrete types ──────────────────────────────────────────────────────
524
525    type RadioSpiBus = ExclusiveDevice<Spim<'static>, Output<'static>, Delay>;
526    #[cfg(not(feature = "t1000e"))]
527    type RadioIv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
528    #[cfg(feature = "t1000e")]
529    type RadioIv = GenericLr1110InterfaceVariant<Output<'static>, Input<'static>>;
530    #[cfg(not(feature = "t1000e"))]
531    type RadioKind = Sx126x<RadioSpiBus, RadioIv, Sx1262>;
532    #[cfg(feature = "t1000e")]
533    type RadioKind = Lr1110<RadioSpiBus, RadioIv, Lr1110Chip>;
534    type LoraRadio = LoRa<RadioKind, Delay>;
535
536    type DeviceUsbDriver = Driver<'static, &'static SoftwareVbusDetect>;
537    type DeviceSender = embassy_usb::class::cdc_acm::Sender<'static, DeviceUsbDriver>;
538    type DeviceRescue = CdcAcmRescue<'static, DeviceUsbDriver>;
539    type BleStoreMutex = Mutex<ThreadModeRawMutex, BleStore>;
540    /// The one MPSL-coordinated flash driver, shared between the BLE
541    /// bond/PIN journal and the protocol snapshot journal.
542    pub type SharedFlash = Mutex<ThreadModeRawMutex, JournalFlash>;
543
544    /// Local wrapper carrying the `umsh-journal-store` trait impls for
545    /// the MPSL-coordinated flash (both trait and driver are foreign
546    /// since the journal extraction, so the impls need a local type).
547    /// Derefs to the driver for the blocking read paths.
548    pub struct JournalFlash(nrf_mpsl::Flash<'static>);
549
550    impl core::ops::Deref for JournalFlash {
551        type Target = nrf_mpsl::Flash<'static>;
552
553        fn deref(&self) -> &Self::Target {
554            &self.0
555        }
556    }
557
558    impl core::ops::DerefMut for JournalFlash {
559        fn deref_mut(&mut self) -> &mut Self::Target {
560            &mut self.0
561        }
562    }
563
564    struct BleStore {
565        flash: &'static SharedFlash,
566        snapshot: Snapshot,
567        slot: Option<u32>,
568    }
569
570    impl ble_store::RecordWriter for JournalFlash {
571        type Error = ();
572
573        async fn write_record(&mut self, address: u32, bytes: &[u8]) -> Result<(), Self::Error> {
574            #[cfg(feature = "ble-store-fault-inject")]
575            if BLE_STORE_FAULT_ARMED.load(Ordering::Acquire) {
576                debug_log(format_args!(
577                    "store fault-inject write address=0x{address:06x} len={}",
578                    bytes.len(),
579                ));
580                return Err(());
581            }
582            self.write(address, bytes).await.map_err(|_| ())
583        }
584    }
585
586    impl ble_store::PageEraser for JournalFlash {
587        type Error = ();
588
589        async fn erase_page(&mut self, start: u32, end: u32) -> Result<(), Self::Error> {
590            #[cfg(feature = "ble-store-fault-inject")]
591            if BLE_STORE_FAULT_ARMED.load(Ordering::Acquire) {
592                debug_log(format_args!(
593                    "store fault-inject erase start=0x{start:06x} end=0x{end:06x}",
594                ));
595                return Err(());
596            }
597            self.erase(start, end).await.map_err(|_| ())
598        }
599    }
600
601    impl ble_store::RecordReader for JournalFlash {
602        type Error = ();
603
604        fn read_record(&mut self, address: u32, bytes: &mut [u8]) -> Result<(), Self::Error> {
605            self.read(address, bytes).map_err(|_| ())
606        }
607    }
608
609    impl BleStore {
610        async fn mount(shared: &'static SharedFlash) -> Self {
611            let mut flash = shared.lock().await;
612            let mut latest: Option<(u32, Snapshot)> = None;
613            let mut read_failures = 0u8;
614            let mut valid_records = 0u8;
615            for page in [ble_store::PAGE0, ble_store::PAGE1] {
616                let mut address = page;
617                while address < page + ble_store::PAGE_SIZE {
618                    let mut bytes = [0u8; ble_store::SLOT_SIZE];
619                    if flash.read(address, &mut bytes).is_ok() {
620                        if Snapshot::decode(&bytes).is_some() {
621                            valid_records = valid_records.saturating_add(1);
622                        }
623                        latest = ble_store::consider_snapshot(latest, address, &bytes);
624                    } else {
625                        read_failures = read_failures.saturating_add(1);
626                    }
627                    address += ble_store::SLOT_SIZE as u32;
628                }
629            }
630            let (slot, snapshot) = latest
631                .map(|(slot, snapshot)| (Some(slot), snapshot))
632                .unwrap_or((None, Snapshot::empty()));
633            debug_log(format_args!(
634                "store mount valid-records={} read-failures={} selected-slot={:?} generation={} bonds={} pin={} local-irk={}",
635                valid_records,
636                read_failures,
637                slot,
638                snapshot.generation,
639                snapshot.bonds.len(),
640                snapshot.pin.is_some(),
641                snapshot.local_irk.is_some(),
642            ));
643            drop(flash);
644            Self {
645                flash: shared,
646                snapshot,
647                slot,
648            }
649        }
650
651        fn snapshot(&self) -> &Snapshot {
652            &self.snapshot
653        }
654
655        async fn persist(&mut self, mut snapshot: Snapshot) -> Result<(), ()> {
656            snapshot.generation = self.snapshot.generation.wrapping_add(1);
657            let mut flash = self.flash.lock().await;
658            let target = umsh_ulcp_runtime::journal::journal_write_target(
659                &mut *flash,
660                self.slot,
661                ble_store::PAGE0,
662                ble_store::SLOT_SIZE,
663            )
664            .await?;
665
666            let bytes = snapshot.encode();
667            debug_log(format_args!(
668                "store body-write begin generation={} target=0x{target:06x}",
669                snapshot.generation
670            ));
671            match ble_store::write_committed_record(&mut *flash, target, &bytes).await {
672                Ok(()) => debug_log(format_args!(
673                    "store body-write=ok commit-write=ok target=0x{target:06x}"
674                )),
675                Err(ble_store::CommitError::Body(())) => {
676                    debug_log(format_args!(
677                        "store body-write=FAILED target=0x{target:06x}"
678                    ));
679                    return Err(());
680                }
681                Err(ble_store::CommitError::Commit(())) => {
682                    debug_log(format_args!(
683                        "store body-write=ok commit-write=FAILED target=0x{target:06x}"
684                    ));
685                    return Err(());
686                }
687            }
688            self.snapshot = snapshot;
689            self.slot = Some(target);
690            debug_log(format_args!(
691                "store commit generation={} slot=0x{:06x} bonds={} pin={} local_irk={}",
692                self.snapshot.generation,
693                target,
694                self.snapshot.bonds.len(),
695                self.snapshot.pin.is_some(),
696                self.snapshot.local_irk.is_some(),
697            ));
698            Ok(())
699        }
700
701        async fn set_pin(&mut self, pin: Option<u32>) -> Result<(), ()> {
702            let mut next = self.snapshot.clone();
703            next.pin = pin;
704            self.persist(next).await
705        }
706
707        async fn set_local_irk(&mut self, local_irk: [u8; 16]) -> Result<(), ()> {
708            if self.snapshot.local_irk == Some(local_irk) {
709                return Ok(());
710            }
711            let mut next = self.snapshot.clone();
712            next.local_irk = Some(local_irk);
713            self.persist(next).await
714        }
715
716        /// Persists `bond`, keeping the bond list LRU-ordered. Returns the
717        /// evicted bond, if inserting a new one at `MAX_BONDS` capacity
718        /// pushed out the least-recently-used entry.
719        async fn add_bond(&mut self, bond: &BondInformation) -> Result<Option<StoredBond>, ()> {
720            let stored = stored_bond(bond);
721            let mut next = self.snapshot.clone();
722            let outcome = ble_store::upsert_bond(&mut next.bonds, stored);
723            let evicted = match outcome {
724                ble_store::BondUpsert::Unchanged => return Ok(None),
725                ble_store::BondUpsert::Updated => None,
726                ble_store::BondUpsert::Inserted { evicted } => evicted,
727            };
728            self.persist(next).await?;
729            Ok(evicted)
730        }
731
732        /// Moves the bond matching `address_kind`/`address` to the MRU end
733        /// and persists it, if it isn't already there. Called on reconnect
734        /// via an existing bond, so the LRU order reflects actual use
735        /// rather than only pairing/re-pairing events.
736        async fn touch_bond(&mut self, address_kind: u8, address: [u8; 6]) -> Result<bool, ()> {
737            let mut next = self.snapshot.clone();
738            if !ble_store::touch_bond(&mut next.bonds, address_kind, address) {
739                return Ok(false);
740            }
741            self.persist(next).await?;
742            Ok(true)
743        }
744
745        async fn clear_security(&mut self) -> Result<(), ()> {
746            let mut next = Snapshot::empty();
747            next.generation = self.snapshot.generation;
748            next.local_irk = self.snapshot.local_irk;
749            self.persist(next).await
750        }
751    }
752
753    /// The stored protocol snapshot payload as read at boot.
754    type BootSnapshot = umsh_ulcp_runtime::journal::BootPayload;
755
756    /// This board's journal handle: the shared two-page rotating store
757    /// bound to the MPSL-coordinated flash.
758    type ProtoStore = umsh_ulcp_runtime::journal::ProtoStore<ThreadModeRawMutex, JournalFlash>;
759
760    #[cfg(feature = "t1000e")]
761    fn mapped_ux_preferences() -> Option<umsh_ux_tracker::state::UserPreferences> {
762        let mut newest: Option<(u32, u32)> = None;
763        let mut bytes = [0u8; proto_store::SLOT_SIZE];
764        // Internal flash is memory mapped. These early reads happen
765        // before MPSL takes NVMC and never mutate flash.
766        let read_slot = |address: u32, bytes: &mut [u8; proto_store::SLOT_SIZE]| unsafe {
767            core::ptr::copy_nonoverlapping(address as *const u8, bytes.as_mut_ptr(), bytes.len());
768        };
769        for page in [
770            proto_store::UX_PAGE0,
771            proto_store::UX_PAGE0 + proto_store::PAGE_SIZE,
772        ] {
773            let mut address = page;
774            while address < page + proto_store::PAGE_SIZE {
775                read_slot(address, &mut bytes);
776                proto_store::consider_slot(&mut newest, address, &bytes);
777                address += proto_store::SLOT_SIZE as u32;
778            }
779        }
780        let (slot, _) = newest?;
781        read_slot(slot, &mut bytes);
782        let stored = proto_store::Stored::decode(&bytes)?;
783        let proto_store::Record::Snapshot(payload) = stored.record else {
784            return None;
785        };
786        let mut preferences =
787            umsh_ux_tracker::state::UserPreferences::try_decode(*payload.first()?)?;
788        // Critical shutdown is a live protective condition mirrored in the
789        // retained register, not a durable user preference.
790        preferences.battery_critical = false;
791        Some(preferences)
792    }
793
794    /// Callers deliberately ignore the result: the preference is already
795    /// applied in RAM and mirrored in GPREGRET2, so a failed journal write
796    /// costs only durability across the next reset. There is no user-facing
797    /// fault channel, and confirmation feedback reflects the applied state,
798    /// not the flash commit.
799    #[cfg(feature = "t1000e")]
800    async fn persist_ux_preferences(
801        store: &mut ProtoStore,
802        mut preferences: umsh_ux_tracker::state::UserPreferences,
803    ) -> Result<(), ()> {
804        preferences.battery_critical = false;
805        store.persist(&[preferences.encode()]).await
806    }
807
808    // ─── Device-node counter persistence (plan increment 4) ─────────────────
809
810    // ─── Device-node counter persistence (plan increment 4) ─────────────────
811
812    // ─── Device-node counter persistence ────────────────────────────────
813
814    /// The device node's persisted frame counters, bound to this board's
815    /// flash. The map, the journal handle, and the `CounterStore` impl
816    /// are shared (`umsh_ulcp_runtime::node_counters`); only the flash
817    /// type and the journal's page are this board's.
818    pub type NodeCounters =
819        umsh_ulcp_runtime::node_counters::NodeCounters<ThreadModeRawMutex, JournalFlash>;
820    pub type NodeCountersMutex = umsh_ulcp_runtime::node_counters::NodeCountersMutex<
821        ThreadModeRawMutex,
822        ThreadModeRawMutex,
823        JournalFlash,
824    >;
825    pub type NodeCounterStore = umsh_ulcp_runtime::node_counters::NodeCounterStore<
826        ThreadModeRawMutex,
827        ThreadModeRawMutex,
828        JournalFlash,
829    >;
830
831    static NODE_COUNTERS_CELL: StaticCell<NodeCountersMutex> = StaticCell::new();
832
833    /// Initialize the (still journal-less) counter state. Call exactly
834    /// once, early in boot; the BLE image attaches the journal with
835    /// [`mount_node_counters`] before the device node comes up.
836    fn init_node_counters() -> &'static NodeCountersMutex {
837        NODE_COUNTERS_CELL.init(Mutex::new(NodeCounters::new()))
838    }
839
840    /// Mount the counter journal and load the persisted map.
841    async fn mount_node_counters(
842        counters: &'static NodeCountersMutex,
843        flash: &'static SharedFlash,
844    ) {
845        umsh_ulcp_runtime::node_counters::mount(counters, flash, proto_store::COUNTER_PAGE0).await
846    }
847
848    async fn prune_stale_tx_counters(counters: &'static NodeCountersMutex, public_key: &[u8; 32]) {
849        umsh_ulcp_runtime::node_counters::prune_stale_tx(counters, public_key).await
850    }
851
852    async fn clear_node_counters(counters: &'static NodeCountersMutex) {
853        umsh_ulcp_runtime::node_counters::clear(counters).await
854    }
855
856    fn stored_bond(bond: &BondInformation) -> StoredBond {
857        let address = bond.identity.addr.to_bytes();
858        StoredBond {
859            address_kind: address[0],
860            address: address[1..].try_into().unwrap(),
861            irk: bond.identity.irk.map(IdentityResolvingKey::to_le_bytes),
862            ltk: bond.ltk.to_le_bytes(),
863            security_level: match bond.security_level {
864                SecurityLevel::NoEncryption => 0,
865                SecurityLevel::Encrypted => 1,
866                SecurityLevel::EncryptedAuthenticated => 2,
867            },
868            is_bonded: bond.is_bonded,
869        }
870    }
871
872    fn bond_identity_is_persistable(bond: &BondInformation) -> bool {
873        let address = bond.identity.addr.to_bytes();
874        let public = address[0] & 1 == 0;
875        let random_static = address[1] & 0xc0 == 0xc0;
876        public || random_static || bond.identity.irk.is_some()
877    }
878
879    fn trouble_bond(bond: &StoredBond) -> Option<BondInformation> {
880        let mut raw = bond.address;
881        raw.reverse();
882        let identity = Identity {
883            addr: Address::new(AddrKind::new(bond.address_kind), BdAddr::new(raw)),
884            irk: bond.irk.and_then(IdentityResolvingKey::from_le_bytes),
885        };
886        let security_level = match bond.security_level {
887            0 => SecurityLevel::NoEncryption,
888            1 => SecurityLevel::Encrypted,
889            2 => SecurityLevel::EncryptedAuthenticated,
890            _ => return None,
891        };
892        Some(BondInformation::new(
893            identity,
894            LongTermKey::from_le_bytes(bond.ltk),
895            security_level,
896            bond.is_bonded,
897        ))
898    }
899
900    fn ble_identity_address() -> Address {
901        let low = embassy_nrf::pac::FICR.deviceaddr(0).read().to_le_bytes();
902        let high = embassy_nrf::pac::FICR.deviceaddr(1).read().to_le_bytes();
903        let mut address = [low[0], low[1], low[2], low[3], high[0], high[1]];
904        address[5] |= 0xc0;
905        Address::random(address)
906    }
907
908    // ─── Static shared state ─────────────────────────────────────────────────
909
910    /// Channels shared between the radio runner and the radio mux, which
911    /// is the runner's only client.
912    type RadioCh = umsh_radio_loraphy::Channels<ThreadModeRawMutex, 4, 2>;
913    static RADIO_CH: RadioCh = RadioCh::new();
914
915    /// The session's virtual radio endpoint (mux client A). The device
916    /// node's endpoint (client B) lives in `device_node::NODE_CH`.
917    static SESSION_CH: RadioCh = RadioCh::new();
918    static MUX_CLIENTS: [&RadioCh; 2] = [&SESSION_CH, &super::device_node::NODE_CH];
919
920    /// Runtime radio settings pushed by the session to the runner.
921    static DEVICE_CTL: DeviceControl<ThreadModeRawMutex> = DeviceControl::new();
922
923    /// The one traffic ledger for the whole device.
924    ///
925    /// The mux is where every real transmit and every off-air reception
926    /// passes exactly once, so that is where the air counters are kept —
927    /// counting at the MAC would miss everything the session sends, which
928    /// on a phone-attached tracker is most of it. The runner adds the CRC
929    /// failures it alone can see, and the node's pump mirrors the four
930    /// figures only the MAC knows.
931    pub(crate) static STATS: StatsLedger = StatsLedger::new();
932
933    /// Framing-free receive path and connection edges into the shared
934    /// ULCP driver (`InEvent`/`FrameBuf` and the queue types live there).
935    static INPUT_CH: InputChannel<ThreadModeRawMutex> = InputChannel::new();
936    type FrameBuf = driver::FrameBuf;
937    const FRAME_IN_MAX: usize = driver::FRAME_IN_MAX;
938
939    /// Outbound frame queues: `wired` drained by output_task (USB-CDC),
940    /// `ble` by the GATT connection writer.
941    static OUT_CH: TransportChannels<ThreadModeRawMutex> = TransportChannels::new();
942
943    type DeviceName = heapless::Vec<u8, { MAX_DEVICE_NAME_LEN }>;
944    static DEVICE_NAME: Mutex<ThreadModeRawMutex, DeviceName> = Mutex::new(DeviceName::new());
945
946    /// Snapshot the live device name for the device node's
947    /// advertisements. Falls back to the (FICR-suffixed) default until
948    /// the session publishes a name at boot.
949    pub(crate) async fn device_name_snapshot() -> DeviceName {
950        let current = DEVICE_NAME.lock().await;
951        if current.is_empty() {
952            let mut name = DeviceName::new();
953            let _ = name.extend_from_slice(default_device_name().as_bytes());
954            name
955        } else {
956            current.clone()
957        }
958    }
959    static DEVICE_NAME_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
960
961    /// The GAP Device Name value that clients may hold a stale copy of.
962    ///
963    /// Set when the device is renamed and cleared once a Service Changed
964    /// indication has gone out. A rename that happens with no one connected
965    /// is therefore announced to the next client instead of being lost.
966    /// Known gap: a second bonded peer that is absent for the rename *and*
967    /// for the connection that consumes this flag keeps its cached name
968    /// until it reads the characteristic again. Closing that needs the
969    /// pending-indication state to live per bond, in the bond store.
970    static GATT_NAME_STALE: AtomicBool = AtomicBool::new(false);
971
972    /// Whether a device name has been published since boot.
973    ///
974    /// The first publication is the saved name being restored as the radio
975    /// configuration is applied, not a rename. Treating it as one would mark
976    /// the database stale on every power cycle and make every bonded peer
977    /// re-discover on its next connection.
978    static DEVICE_NAME_PUBLISHED: AtomicBool = AtomicBool::new(false);
979
980    /// GAP's own bound on the Device Name value, which is shorter than the
981    /// ULCP device-name limit.
982    type GapDeviceName = heapless09::Vec<u8, { gap::DEVICE_NAME_MAX_LENGTH }>;
983
984    /// The platform battery source behind `Effect::SampleBattery`: one
985    /// async sample operation per board profile, returning the fields
986    /// that board's `SessionConfig::battery` advertises.
987    ///
988    /// T-1000E: a request/reply round trip into the BSP battery monitor
989    /// — the sole SAADC and sensor-rail owner — which runs its normal
990    /// gated sample/classify/publish iteration early and replies with
991    /// the millivolt reading and UX classification. The timeout covers
992    /// the monitor having exited for critical-battery shutdown.
993    #[cfg(feature = "cap-battery-saadc")]
994    async fn sample_battery_snapshot() -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
995        let sample =
996            embassy_time::with_timeout(Duration::from_secs(2), board_power::sample_battery())
997                .await
998                .map_err(|_| ())?;
999        battery_snapshot(sample)
1000    }
1001
1002    /// Reduce one BSP battery sample to the protocol snapshot this board's
1003    /// `SessionConfig::battery` advertises.
1004    ///
1005    /// Shared by the on-demand read (`Effect::SampleBattery`) and the
1006    /// asynchronous publication (`DeviceEnv::battery_event`) so the two can
1007    /// never report the same measurement differently.
1008    #[cfg(feature = "cap-battery-saadc")]
1009    fn battery_snapshot(
1010        sample: board_power::BatterySample,
1011    ) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
1012        use umsh_ulcp::battery::{BatteryChargeState, BatteryStatus};
1013        use umsh_ux_tracker::battery::ChargeClass;
1014        // Low and critical are UX presentation policy, not charge states;
1015        // `charge_class` collapses all three unpowered classifications.
1016        let charge_state = match umsh_ux_tracker::battery::charge_class(sample.state) {
1017            ChargeClass::Charging => BatteryChargeState::Charging,
1018            ChargeClass::Charged => BatteryChargeState::Charged,
1019            ChargeClass::Discharging => BatteryChargeState::Discharging,
1020        };
1021        // The level is reported by its absence when the estimator has
1022        // none to give — before its first quiet sample, and for as long as
1023        // the pack is charging on a board whose charger reports no
1024        // completion. Voltage and charge state still mean something in
1025        // both cases, so the snapshot goes out carrying what it can.
1026        Ok(BatteryStatus {
1027            voltage_mv: Some(sample.battery_mv),
1028            level_percent: sample.level_percent,
1029            charge_state: Some(charge_state),
1030        })
1031    }
1032
1033    /// T-Echo: `BatteryFields::NONE` means the session answers the empty
1034    /// value without emitting the effect, so this is unreachable; a
1035    /// failure keeps any future misrouting honest.
1036    #[cfg(not(feature = "cap-battery-saadc"))]
1037    async fn sample_battery_snapshot() -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
1038        Err(())
1039    }
1040
1041    /// The platform light source behind `Effect::SampleIlluminance`: a
1042    /// request/reply round trip into the same BSP monitor that owns the
1043    /// SAADC, which raises both sensor enables, settles, averages, and
1044    /// replies in millilux. The timeout covers the monitor having exited
1045    /// for critical-battery shutdown — a device on its way down reports
1046    /// no reading rather than hanging the transaction.
1047    #[cfg(feature = "cap-illuminance")]
1048    async fn sample_illuminance_millilux() -> Option<u32> {
1049        embassy_time::with_timeout(
1050            Duration::from_secs(2),
1051            umsh_bsp_t1000e::light::sample_illuminance(),
1052        )
1053        .await
1054        .ok()
1055    }
1056
1057    /// Published session epoch, checked by each transport at framing edges.
1058    static SESSION_GEN: AtomicU32 = AtomicU32::new(0);
1059
1060    /// Previous boot's last breadcrumb stage (temporary freeze
1061    /// diagnostics; surfaced in the version string as `crumb=N`).
1062    static PREV_BOOT_CRUMB: AtomicU8 = AtomicU8::new(0);
1063    /// Previous boot's heartbeat-iteration count (~50 ms each;
1064    /// surfaced as `beats=N`).
1065    static PREV_BOOT_BEATS: AtomicU16 = AtomicU16::new(0);
1066    /// Raw `RESETREAS` bits captured at this boot (surfaced as `rr=0x…`
1067    /// so a simultaneous LOCKUP+DOG cannot hide behind the DOG-first
1068    /// reset-reason mapping).
1069    static BOOT_RESETREAS: AtomicU32 = AtomicU32::new(0);
1070    /// Previous boot's watchdog-timeout capture (stacked PC/LR/xPSR of
1071    /// the code executing ~61 µs before the watchdog reset).
1072    static PREV_WDT_PC: AtomicU32 = AtomicU32::new(0);
1073    static PREV_WDT_LR: AtomicU32 = AtomicU32::new(0);
1074    static PREV_WDT_PSR: AtomicU32 = AtomicU32::new(0);
1075    /// Previous boot's PC-ring sample count (proves the 1 kHz sampler
1076    /// ran; surfaced as `rc=N`).
1077    static PREV_RING_COUNT: AtomicU16 = AtomicU16::new(0);
1078
1079    /// RAM-only until BLE persistence lands. `u32::MAX` means unset.
1080    static PAIRING_PIN: AtomicU32 = AtomicU32::new(u32::MAX);
1081    static BLE_BONDS_AT_BOOT: AtomicU8 = AtomicU8::new(0);
1082    static BLE_BOND_COUNT: AtomicU8 = AtomicU8::new(0);
1083
1084    /// How far the BLE link has got, for the status page and for
1085    /// `PROP_BLE_LINK`. Nothing is connected until a GATT connection is
1086    /// accepted; "attached" means the client has subscribed to the ULCP
1087    /// notification characteristic and is therefore actually talking to
1088    /// us, not merely nearby.
1089    ///
1090    /// Holds a [`BleLinkState`] code rather than a private encoding of
1091    /// its own: the panel and the property are two readings of one fact,
1092    /// and the wire enumeration is the one place that fact is defined.
1093    /// Same encoding as the Heltec V3's, so both families read the state
1094    /// out of their `ui_status` the same way.
1095    static BLE_LINK: AtomicU8 = AtomicU8::new(BleLinkState::None.code());
1096
1097    static PAIRING_MODE: AtomicBool = AtomicBool::new(true);
1098    static PAIRING_LOCKED_OUT: AtomicBool = AtomicBool::new(false);
1099    static PAIRING_FAILURES: AtomicU8 = AtomicU8::new(0);
1100    /// Set when the T-1000E user button is held through power-on. This is
1101    /// deliberately independent of bond count: physical presence opens one
1102    /// pairing window even when existing bonds are present.
1103    static FORCE_PAIRING_AT_BOOT: AtomicBool = AtomicBool::new(false);
1104    #[cfg(feature = "ble-store-fault-inject")]
1105    static BLE_STORE_FAULT_ARMED: AtomicBool = AtomicBool::new(false);
1106    static PAIRING_CONFIG_CH: Channel<ThreadModeRawMutex, Option<u32>, 1> = Channel::new();
1107    static PAIRING_CONFIG_ACK: Signal<ThreadModeRawMutex, bool> = Signal::new();
1108    static PAIRING_MODE_REQUEST: Signal<ThreadModeRawMutex, bool> = Signal::new();
1109    static PAIRING_TIMER_RESET: Signal<ThreadModeRawMutex, ()> = Signal::new();
1110    static BLE_WIPE_REQUEST: Signal<ThreadModeRawMutex, ()> = Signal::new();
1111    /// Outcomes for the two requests above, for a caller that has someone
1112    /// to answer. The menu fires and forgets; a ULCP command waits, and
1113    /// resets the signal first so it cannot read the menu's stale result.
1114    static BLE_WIPE_ACK: Signal<ThreadModeRawMutex, bool> = Signal::new();
1115    static PAIRING_MODE_ACK: Signal<ThreadModeRawMutex, bool> = Signal::new();
1116    /// The bond count moved, carrying the new value to `publish_event` so
1117    /// `PROP_BLE_BOND_COUNT` follows enrollment without polling.
1118    static BLE_BOND_COUNT_CHANGED: Signal<ThreadModeRawMutex, u8> = Signal::new();
1119    /// The BLE link moved, carrying the new state to `publish_event` so
1120    /// `PROP_BLE_LINK` follows a host arriving or walking away without
1121    /// polling.
1122    static BLE_LINK_CHANGED: Signal<ThreadModeRawMutex, BleLinkState> = Signal::new();
1123    /// The pairing window moved, carrying the new state to
1124    /// `publish_event` so `PROP_BLE_PAIRING` follows the window without
1125    /// polling — including the transitions nobody commanded (a timeout,
1126    /// the boot-time window) and the boot seeding of the session's
1127    /// mirror.
1128    static BLE_PAIRING_CHANGED: Signal<ThreadModeRawMutex, bool> = Signal::new();
1129    #[cfg(feature = "has-display")]
1130    static UI_INPUT_CH: Channel<ThreadModeRawMutex, UiInput, 8> = Channel::new();
1131    static UI_REFRESH: Signal<ThreadModeRawMutex, ()> = Signal::new();
1132    static UI_NOTICE: Signal<ThreadModeRawMutex, UiNotice> = Signal::new();
1133    #[cfg(feature = "has-display")]
1134    static DISPLAY_SHUTDOWN: Signal<ThreadModeRawMutex, ()> = Signal::new();
1135    #[cfg(feature = "has-display")]
1136    static DISPLAY_SHUTDOWN_DONE: Signal<ThreadModeRawMutex, ()> = Signal::new();
1137    /// 0 = normal heartbeat, 1 = pairing mode, 2 = BLE state wiped.
1138    static BLE_LED_MODE: AtomicU8 = AtomicU8::new(0);
1139
1140    /// Whether a locate alert (`PROP_ALERT`) is running. The session is
1141    /// authoritative; this is its board-side mirror, read by the LED
1142    /// task to drive the blink and by the button task, which swallows
1143    /// the press that cancels an alert.
1144    static ALERT_ACTIVE: AtomicBool = AtomicBool::new(false);
1145    /// Wakes the LED task on a locate-alert edge.
1146    static ALERT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1147    /// The same edge, for the display task. A `Signal` has one useful
1148    /// consumer, so each task that must react promptly gets its own.
1149    #[cfg(feature = "has-display")]
1150    static UI_ALERT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1151
1152    /// Apply a `PROP_ALERT` transition to the board's indicators.
1153    ///
1154    /// Idempotent — the session emits the effect on every transition,
1155    /// including ones that change nothing.
1156    fn set_alert_indication(active: bool) {
1157        if ALERT_ACTIVE.swap(active, Ordering::AcqRel) == active {
1158            return;
1159        }
1160        ALERT_CHANGED.signal(());
1161        // Boards with a sounder make the noise too; on the others the
1162        // LED blink (plus whatever the panel shows) is the whole alert.
1163        #[cfg(feature = "t1000e")]
1164        umsh_bsp_t1000e::indicator::BUZZER_ALERT_SET.signal(active);
1165        #[cfg(feature = "cap-buzzer")]
1166        umsh_bsp_wio_tracker_l1::buzzer::BUZZER_ALERT_SET.signal(active);
1167        #[cfg(feature = "has-display")]
1168        UI_ALERT_CHANGED.signal(());
1169        #[cfg(feature = "display-epd")]
1170        BACKLIGHT_CHANGED.signal(());
1171    }
1172
1173    /// Whether the capacitive touch button is currently held.
1174    ///
1175    /// The touch button and the locate alert both want the backlight, so
1176    /// neither drives the pin directly; they publish their demand here
1177    /// and [`backlight_task`] arbitrates.
1178    #[cfg(feature = "display-epd")]
1179    static BACKLIGHT_TOUCH: AtomicBool = AtomicBool::new(false);
1180    /// Wakes [`backlight_task`] when either demand changes.
1181    #[cfg(feature = "display-epd")]
1182    static BACKLIGHT_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1183
1184    /// Whether the emissive panel has lapsed dark.
1185    ///
1186    /// Published by the display task and read by the button task, which
1187    /// latches it on the press edge so the press that lights the panel is
1188    /// not also treated as navigation. Boards with a bistable panel never
1189    /// set it: they have nothing to wake.
1190    #[cfg(feature = "display-oled")]
1191    static SCREEN_OFF: AtomicBool = AtomicBool::new(false);
1192    /// Asks the emissive panel to come back on. Distinct from
1193    /// [`UI_REFRESH`], which changes what is drawn but must never light a
1194    /// panel the user did not touch.
1195    #[cfg(feature = "display-oled")]
1196    static UI_WAKE: Signal<ThreadModeRawMutex, ()> = Signal::new();
1197
1198    /// Whether a locate alert is running.
1199    fn alert_active() -> bool {
1200        ALERT_ACTIVE.load(Ordering::Acquire)
1201    }
1202
1203    /// USB protocol attachment suppresses BLE advertising. The signal wakes a
1204    /// pending advertiser/connection so it can apply the atomic policy.
1205    static ADV_ALLOWED: AtomicBool = AtomicBool::new(true);
1206    static ADV_POLICY_CHANGED: Signal<ThreadModeRawMutex, ()> = Signal::new();
1207
1208    /// `PROP_BLE_ENABLED`, mirrored from the session.
1209    ///
1210    /// The second gate on advertising, and the one the user owns: where
1211    /// [`ADV_ALLOWED`] says "a wired host is talking to me right now",
1212    /// this says "do not be findable at all". Both have to agree before
1213    /// the advertiser runs.
1214    ///
1215    /// True at boot, before any restore has happened, so a device that
1216    /// never gets as far as its saved state is still reachable by the
1217    /// host that could fix it.
1218    static BLE_ENABLED: AtomicBool = AtomicBool::new(true);
1219
1220    #[cfg(feature = "ble-debug")]
1221    type DebugLine = heapless::String<192>;
1222    #[cfg(feature = "ble-debug")]
1223    // 128 deep so the whole boot window (including trouble-host's resolving-list
1224    // diagnostics) survives in the buffer until a serial reader attaches and
1225    // starts draining; the output task only drains after DTR. ble-debug only.
1226    static DEBUG_CH: Channel<ThreadModeRawMutex, DebugLine, 128> = Channel::new();
1227    #[cfg(feature = "ble-debug")]
1228    static DEBUG_DROPPED: AtomicU32 = AtomicU32::new(0);
1229
1230    pub(crate) fn debug_log(args: core::fmt::Arguments<'_>) {
1231        #[cfg(feature = "ble-debug")]
1232        {
1233            let mut line = DebugLine::new();
1234            let dropped = DEBUG_DROPPED.swap(0, Ordering::AcqRel);
1235            if write!(line, "[{:>8} ms] ", Instant::now().as_millis()).is_err()
1236                || (dropped != 0 && write!(line, "[debug-dropped={dropped}] ").is_err())
1237                || line.write_fmt(args).is_err()
1238                || line.push_str("\r\n").is_err()
1239            {
1240                DEBUG_DROPPED.fetch_add(dropped.saturating_add(1), Ordering::AcqRel);
1241                return;
1242            }
1243            if DEBUG_CH.try_send(line).is_err() {
1244                DEBUG_DROPPED.fetch_add(dropped.saturating_add(1), Ordering::AcqRel);
1245            }
1246        }
1247        #[cfg(not(feature = "ble-debug"))]
1248        let _ = args;
1249    }
1250
1251    /// Bridges the `log` facade used by foreign crates (notably
1252    /// trouble-host's resolving-list and SMP diagnostics) into the same
1253    /// USB-serial debug channel as [`debug_log`], inheriting its
1254    /// timestamping and drop accounting.
1255    #[cfg(feature = "ble-debug")]
1256    struct DebugChannelLogger;
1257
1258    #[cfg(feature = "ble-debug")]
1259    impl log::Log for DebugChannelLogger {
1260        fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
1261            true
1262        }
1263
1264        fn log(&self, record: &log::Record<'_>) {
1265            debug_log(format_args!(
1266                "{} {}: {}",
1267                record.level(),
1268                record.target(),
1269                record.args(),
1270            ));
1271        }
1272
1273        fn flush(&self) {}
1274    }
1275
1276    #[cfg(feature = "ble-debug")]
1277    static DEBUG_LOGGER: DebugChannelLogger = DebugChannelLogger;
1278
1279    /// Installs [`DebugChannelLogger`] as the global `log` sink. Idempotent:
1280    /// `set_logger` may only succeed once, so a repeat call is ignored. Level
1281    /// is capped at Debug to capture trouble-host's `[host] …` diagnostics
1282    /// and warnings without the per-packet Trace flood.
1283    #[cfg(feature = "ble-debug")]
1284    fn init_foreign_crate_logging() {
1285        let _ = log::set_logger(&DEBUG_LOGGER);
1286        log::set_max_level(log::LevelFilter::Debug);
1287    }
1288
1289    #[cfg(feature = "ble-debug")]
1290    fn trouble_security_trace(event: SecurityTrace) {
1291        debug_log(format_args!(
1292            "smp {:?} opcode=0x{:02x} detail={:02x?}",
1293            event.direction,
1294            event.command,
1295            &event.detail[..usize::from(event.detail_len)],
1296        ));
1297    }
1298
1299    #[cfg(feature = "ble-debug")]
1300    fn trouble_connection_trace(event: ConnectionTrace) {
1301        debug_log(format_args!("trouble connection {event:?}"));
1302    }
1303
1304    #[cfg(feature = "ble-debug")]
1305    fn trouble_security_diagnostic_trace(event: SecurityDiagnosticTrace) {
1306        debug_log(format_args!("trouble security {event:?}"));
1307    }
1308
1309    /// Fired by button_task on the power-off hold; consumed by the
1310    /// board's shutdown task, which also watches the BSP's own signal
1311    /// (the low-battery cutoff).
1312    #[cfg(any(feature = "system-off-techo", feature = "system-off-wio"))]
1313    static SHUTDOWN_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
1314
1315    // ─── Outgoing frame limits ───────────────────────────────────────────────
1316
1317    const WIRE_MAX: usize = hdlc::max_encoded_len(driver::FRAME_OUT_MAX);
1318
1319    /// Whether the peripheral may be findable right now.
1320    ///
1321    /// Two independent gates, and both have to agree: transport
1322    /// arbitration (a wired host is attached) and the user's own
1323    /// `PROP_BLE_ENABLED`. One predicate rather than two loads at each
1324    /// site, so a new gate is added in one place and the advertiser,
1325    /// the live-connection teardown and the status line cannot disagree
1326    /// about what it means to be reachable.
1327    fn advertising_permitted() -> bool {
1328        ADV_ALLOWED.load(Ordering::Acquire) && BLE_ENABLED.load(Ordering::Acquire)
1329    }
1330
1331    /// Whether a pairing window is genuinely open, for everything that
1332    /// shows one: the status page and the attention hold that keeps the
1333    /// panel awake while a PIN might need reading.
1334    ///
1335    /// Gated on `PROP_BLE_ENABLED` because pairing mode is a property of
1336    /// the running stack: nothing can pair through a transport that is
1337    /// off, and a panel held awake for a window nobody can walk through
1338    /// is a battery drain announcing a falsehood.
1339    fn pairing_window_open() -> bool {
1340        BLE_ENABLED.load(Ordering::Acquire) && PAIRING_MODE.load(Ordering::Acquire)
1341    }
1342
1343    /// Apply `PROP_BLE_ENABLED`.
1344    ///
1345    /// Reuses the advertising-policy path, which already stops the
1346    /// advertiser and drops a live connection — which is exactly what
1347    /// the property requires and nothing more: bonds are untouched, so
1348    /// the host reconnects without pairing again when it comes back on.
1349    fn set_ble_enabled(enabled: bool) {
1350        let window_was = pairing_window_open();
1351        if BLE_ENABLED.swap(enabled, Ordering::AcqRel) != enabled {
1352            debug_log(format_args!(
1353                "ble reachability {}",
1354                if enabled { "ON" } else { "off" }
1355            ));
1356            ADV_POLICY_CHANGED.signal(());
1357            UI_REFRESH.signal(());
1358            // `PROP_BLE_PAIRING` reports the window through this gate, so
1359            // flipping the transport can move the property too.
1360            let window_now = pairing_window_open();
1361            if window_was != window_now {
1362                BLE_PAIRING_CHANGED.signal(window_now);
1363            }
1364        }
1365    }
1366
1367    /// Record how many bonds the store now holds, waking anything that
1368    /// reports it. Every write to the count goes through here so the
1369    /// property, the status page and the atomic cannot drift apart.
1370    fn set_bond_count(count: u8) {
1371        if BLE_BOND_COUNT.swap(count, Ordering::AcqRel) != count {
1372            BLE_BOND_COUNT_CHANGED.signal(count);
1373            UI_REFRESH.signal(());
1374        }
1375    }
1376
1377    /// Move the pairing window, waking anything that reports it. What is
1378    /// published is `pairing_window_open()` — the window as the property
1379    /// defines it, gated on `PROP_BLE_ENABLED` — so the session's mirror
1380    /// and the panel read the same fact.
1381    fn set_pairing_mode(open: bool) {
1382        let was = pairing_window_open();
1383        PAIRING_MODE.store(open, Ordering::Release);
1384        let now = pairing_window_open();
1385        if was != now {
1386            BLE_PAIRING_CHANGED.signal(now);
1387            UI_REFRESH.signal(());
1388        }
1389    }
1390
1391    /// Record how far the BLE link has got, waking anything that reports
1392    /// it. Every write to the state goes through here for the same reason
1393    /// the bond count does — the panel and `PROP_BLE_LINK` are two
1394    /// readings of one fact and must not disagree.
1395    fn set_ble_link(state: BleLinkState) {
1396        if BLE_LINK.swap(state.code(), Ordering::AcqRel) != state.code() {
1397            BLE_LINK_CHANGED.signal(state);
1398            UI_REFRESH.signal(());
1399        }
1400    }
1401
1402    fn set_advertising_allowed(allowed: bool) {
1403        let previous = ADV_ALLOWED.swap(allowed, Ordering::AcqRel);
1404        debug_log(format_args!(
1405            "advertising policy set previous={} allowed={} changed={}",
1406            previous,
1407            allowed,
1408            previous != allowed,
1409        ));
1410        ADV_POLICY_CHANGED.signal(());
1411    }
1412
1413    fn apply_pairing_gate<C: Controller, P: PacketPool>(stack: &Stack<'_, C, P>) {
1414        let pin_configured = PAIRING_PIN.load(Ordering::Acquire) != u32::MAX;
1415        let bonds = usize::from(BLE_BOND_COUNT.load(Ordering::Acquire));
1416        let enabled = pairing_enabled(
1417            PAIRING_MODE.load(Ordering::Acquire),
1418            pin_configured,
1419            PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1420        );
1421        stack.set_pairing_enabled(enabled);
1422        debug_log(format_args!(
1423            "pairing gate enabled={} mode={} pin={} locked={} failures={} bonds={}/{}",
1424            enabled,
1425            PAIRING_MODE.load(Ordering::Acquire),
1426            pin_configured,
1427            PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1428            PAIRING_FAILURES.load(Ordering::Acquire),
1429            bonds,
1430            ble_store::MAX_BONDS,
1431        ));
1432    }
1433
1434    fn pairing_runtime() -> PairingRuntime {
1435        PairingRuntime {
1436            pairing_mode: PAIRING_MODE.load(Ordering::Acquire),
1437            failures: PAIRING_FAILURES.load(Ordering::Acquire),
1438            locked_out: PAIRING_LOCKED_OUT.load(Ordering::Acquire),
1439        }
1440    }
1441
1442    fn publish_pairing_runtime(state: PairingRuntime) {
1443        let window_was = pairing_window_open();
1444        PAIRING_MODE.store(state.pairing_mode, Ordering::Release);
1445        PAIRING_FAILURES.store(state.failures, Ordering::Release);
1446        PAIRING_LOCKED_OUT.store(state.locked_out, Ordering::Release);
1447        // A pairing success or a bonded reconnect closes the window with
1448        // no host asking, which is exactly what `PROP_BLE_PAIRING`
1449        // promises to announce.
1450        let window_now = pairing_window_open();
1451        if window_was != window_now {
1452            BLE_PAIRING_CHANGED.signal(window_now);
1453        }
1454        UI_REFRESH.signal(());
1455    }
1456
1457    async fn persist_bond(
1458        store: &BleStoreMutex,
1459        bond: &BondInformation,
1460    ) -> Result<(usize, Option<StoredBond>), ()> {
1461        let mut store = store.lock().await;
1462        let evicted = store.add_bond(bond).await?;
1463        Ok((store.snapshot().bonds.len(), evicted))
1464    }
1465
1466    /// Drops an LRU-evicted bond from the live trouble bond table so the
1467    /// evicted peer can't keep reconnecting as "bonded" this power cycle
1468    /// using stale in-RAM keys after being pushed out of durable storage.
1469    fn forget_evicted_bond<C: Controller, P: PacketPool>(
1470        stack: &Stack<'_, C, P>,
1471        evicted: Option<StoredBond>,
1472    ) {
1473        let Some(evicted) = evicted else {
1474            return;
1475        };
1476        let Some(evicted_info) = trouble_bond(&evicted) else {
1477            return;
1478        };
1479        match stack.remove_bond_information(evicted_info.identity) {
1480            Ok(()) => debug_log(format_args!("lru bond evict remove=ok")),
1481            Err(error) => debug_log(format_args!("lru bond evict remove=FAILED error={error:?}")),
1482        }
1483    }
1484
1485    fn build_sdc<'d, const N: usize>(
1486        p: sdc::Peripherals<'d>,
1487        rng: &'d mut rng::Rng<Async>,
1488        mpsl: &'d MultiprotocolServiceLayer,
1489        mem: &'d mut sdc::Mem<N>,
1490    ) -> Result<sdc::SoftdeviceController<'d>, sdc::Error> {
1491        sdc::Builder::new()?
1492            .support_adv()
1493            .support_peripheral()
1494            // LE Privacy enables the controller resolving list and address
1495            // resolution. Without it, trouble-host's boot-time
1496            // LeSetAddrResolutionEnable / LeAddDeviceToResolvingList commands
1497            // fail as unsupported, the resolving list stays empty, and a
1498            // bonded central that reconnects with a rotated resolvable private
1499            // address (iOS rotates its RPA ~every 15 min) never resolves to
1500            // its bond — so the link never re-encrypts and wedges the single
1501            // peripheral slot as an unusable NoEncryption connection.
1502            .support_le_privacy()
1503            .peripheral_count(1)?
1504            .buffer_cfg(
1505                SDC_PACKET_SIZE,
1506                SDC_PACKET_SIZE,
1507                BLE_L2CAP_TXQ,
1508                BLE_L2CAP_RXQ,
1509            )?
1510            .build(p, rng, mpsl, mem)
1511    }
1512
1513    /// Board environment for the shared ULCP driver
1514    /// (`umsh_ulcp_runtime::driver`): persistence, entropy, pairing,
1515    /// and indicator couplings. The former `cfg(feature = "t1000e")` forks
1516    /// inside the session loop live here as trait-method overrides; the
1517    /// T-Echo build keeps the driver's no-op defaults for the indicator
1518    /// and load hooks.
1519    struct BoardDeviceEnv {
1520        proto_store: ProtoStore,
1521        identity_store: ProtoStore,
1522        identity_rng: IdentityRng,
1523        node_counters: &'static NodeCountersMutex,
1524        /// Announce-worthy battery measurements from the BSP monitor, for
1525        /// unsolicited `PROP_BATTERY` publication. The monitor owns the
1526        /// cadence and the change policy; this only forwards.
1527        #[cfg(feature = "cap-battery-saadc")]
1528        battery: embassy_sync::watch::DynReceiver<'static, board_power::BatterySample>,
1529        /// Positioning changes worth publishing unasked. The runtime's
1530        /// GNSS sink owns the policy — a stationary receiver produces a
1531        /// fix a second and almost none of them are news — so this only
1532        /// forwards what it decided to raise.
1533        #[cfg(feature = "cap-gnss")]
1534        gnss_announce: umsh_ulcp_runtime::gnss::Announcer,
1535    }
1536
1537    impl BoardDeviceEnv {
1538        /// The publishable sources that depend on what is fitted, as one
1539        /// future. `None` is a reading this board could not reduce to its
1540        /// advertised field set — skipped rather than published, the same
1541        /// fail-closed rule the on-demand read applies.
1542        ///
1543        /// Split out of [`DeviceEnv::publish_event`] so the feature
1544        /// combinations live in one place instead of multiplying against
1545        /// the sources that are always present. Cancellation-safe:
1546        /// `Watch::changed` remembers which value the receiver last
1547        /// observed, and the driver's select drops this future whenever
1548        /// another arm wins.
1549        async fn sensor_event(&mut self) -> Option<driver::PublishEvent> {
1550            #[cfg(all(feature = "cap-battery-saadc", feature = "cap-gnss"))]
1551            let event = select(self.battery.changed(), self.gnss_announce.changed()).await;
1552            #[cfg(all(feature = "cap-battery-saadc", not(feature = "cap-gnss")))]
1553            let event = Either::First(self.battery.changed().await) as Either<_, ()>;
1554            #[cfg(all(not(feature = "cap-battery-saadc"), feature = "cap-gnss"))]
1555            let event = Either::Second(self.gnss_announce.changed().await) as Either<(), _>;
1556            // A board with neither never publishes on its own; the bond
1557            // count arm beside this one is the whole of what it has.
1558            #[cfg(all(not(feature = "cap-battery-saadc"), not(feature = "cap-gnss")))]
1559            let event = core::future::pending::<Either<(), ()>>().await;
1560
1561            match event {
1562                #[cfg(feature = "cap-battery-saadc")]
1563                Either::First(sample) => battery_snapshot(sample)
1564                    .ok()
1565                    .map(driver::PublishEvent::Battery),
1566                #[cfg(not(feature = "cap-battery-saadc"))]
1567                Either::First(()) => unreachable!("no battery source on this board"),
1568                #[cfg(feature = "cap-gnss")]
1569                Either::Second(umsh_ulcp_runtime::gnss::Announce::Gnss(key, snapshot)) => {
1570                    Some(driver::PublishEvent::Gnss(key, snapshot))
1571                }
1572                #[cfg(feature = "cap-gnss")]
1573                Either::Second(umsh_ulcp_runtime::gnss::Announce::Time(epoch)) => {
1574                    Some(driver::PublishEvent::Time(epoch))
1575                }
1576                #[cfg(feature = "cap-gnss")]
1577                Either::Second(umsh_ulcp_runtime::gnss::Announce::IdentityFix(
1578                    location,
1579                    altitude_m,
1580                )) => Some(driver::PublishEvent::IdentityFix(
1581                    heapless::Vec::from_slice(location.as_bytes()).unwrap_or_default(),
1582                    altitude_m,
1583                )),
1584                #[cfg(not(feature = "cap-gnss"))]
1585                Either::Second(()) => unreachable!("no receiver on this board"),
1586            }
1587        }
1588    }
1589
1590    impl DeviceEnv for BoardDeviceEnv {
1591        async fn persist_snapshot(&mut self, bytes: &[u8]) -> Result<(), ()> {
1592            self.proto_store.persist(bytes).await
1593        }
1594
1595        async fn clear_snapshot(&mut self) -> Result<(), ()> {
1596            self.proto_store.clear().await
1597        }
1598
1599        async fn older_snapshot(&mut self, out: &mut [u8]) -> Option<usize> {
1600            self.proto_store.older_snapshot(out).await
1601        }
1602
1603        async fn sign_identity(&mut self, out: &mut [u8]) -> Option<usize> {
1604            super::device_node::sign_identity_blob(out).await
1605        }
1606
1607        /// A rejected snapshot on an unattended repeater reaches nobody
1608        /// over the protocol, so raise the board's local indication too.
1609        /// `fell_back` distinguishes "running on stale configuration"
1610        /// from "booted bare".
1611        fn report_snapshot_rejected(&mut self, fell_back: bool) {
1612            debug_log(format_args!(
1613                "proto-store snapshot rejected fell-back={fell_back}"
1614            ));
1615            #[cfg(feature = "t1000e")]
1616            umsh_bsp_t1000e::indicator::request_attention();
1617        }
1618
1619        async fn persist_identity(&mut self, bytes: &[u8]) -> Result<(), ()> {
1620            self.identity_store.persist(bytes).await
1621        }
1622
1623        async fn clear_identity(&mut self) -> Result<(), ()> {
1624            self.identity_store.clear().await
1625        }
1626
1627        async fn clear_counters(&mut self) {
1628            clear_node_counters(self.node_counters).await;
1629        }
1630
1631        fn fill_secret(&mut self, secret: &mut [u8; 32]) -> Result<(), ()> {
1632            // TRNG-seeded ChaCha20 CSPRNG (the RNG peripheral belongs to
1633            // the SDC at runtime); infallible once seeded.
1634            rand_core::RngCore::fill_bytes(&mut self.identity_rng, secret);
1635            Ok(())
1636        }
1637
1638        async fn sample_battery(&mut self) -> Result<umsh_ulcp::battery::BatteryStatus, ()> {
1639            sample_battery_snapshot().await
1640        }
1641
1642        #[cfg(feature = "cap-illuminance")]
1643        async fn sample_illuminance(&mut self) -> Option<u32> {
1644            sample_illuminance_millilux().await
1645        }
1646
1647        /// Forward the monitor's announce-worthy samples. A sample that
1648        /// cannot be reduced to this board's advertised field set is
1649        /// skipped rather than published — the same fail-closed rule the
1650        /// on-demand read applies.
1651        ///
1652        /// `Watch::changed` is cancellation-safe (the receiver remembers
1653        /// which value it last observed), which this hook requires: the
1654        /// driver's select drops the future whenever another arm wins.
1655        #[cfg(feature = "cap-battery-saadc")]
1656        async fn battery_event(&mut self) -> umsh_ulcp::battery::BatteryStatus {
1657            loop {
1658                let sample = self.battery.changed().await;
1659                if let Ok(snapshot) = battery_snapshot(sample) {
1660                    return snapshot;
1661                }
1662            }
1663        }
1664
1665        async fn read_time(&mut self) -> Option<u32> {
1666            umsh_hal::wall_clock::now()
1667        }
1668
1669        /// A host wrote `PROP_TIME`. An operator outranks every other
1670        /// source, including a receiver whose time is being distrusted —
1671        /// distrusting the sky is precisely why somebody would set the
1672        /// clock by hand.
1673        ///
1674        /// The empty write is not a failure to parse: it is the host
1675        /// saying the device should go back to not knowing, which is what
1676        /// takes the clock off a board's display.
1677        async fn apply_time(&mut self, epoch: Option<u32>) {
1678            match epoch {
1679                Some(seconds) => {
1680                    umsh_hal::wall_clock::set_manual(seconds);
1681                }
1682                None => umsh_hal::wall_clock::clear(),
1683            }
1684            // The clock appearing or vanishing is a visible change the
1685            // user asked for, so redraw now rather than at whatever the
1686            // next event happens to be.
1687            UI_REFRESH.signal(());
1688        }
1689
1690        /// The receiver's current view. Cached by the runtime rather than
1691        /// re-read from the receiver, because "what did it last say" is
1692        /// the only question a UART emitting one cycle a second can
1693        /// answer promptly.
1694        #[cfg(feature = "cap-gnss")]
1695        async fn sample_gnss(&mut self) -> Result<umsh_ulcp::gnss::GnssSnapshot, ()> {
1696            Ok(umsh_ulcp_runtime::gnss::snapshot())
1697        }
1698
1699        /// Everything this board publishes unasked, on one select arm.
1700        ///
1701        /// The driver has exactly one, because a hook per property would
1702        /// need one `&mut self` borrow apiece. The bond count and the
1703        /// link state are the two sources that need no board hardware —
1704        /// both come off statics — so they ride here on every board, GNSS
1705        /// or not, while [`sensor_event`](Self::sensor_event) keeps the
1706        /// sources that do vary by board behind their own features.
1707        async fn publish_event(&mut self) -> driver::PublishEvent {
1708            loop {
1709                let woke = select4(
1710                    self.sensor_event(),
1711                    BLE_BOND_COUNT_CHANGED.wait(),
1712                    BLE_LINK_CHANGED.wait(),
1713                    BLE_PAIRING_CHANGED.wait(),
1714                )
1715                .await;
1716                match woke {
1717                    Either4::First(Some(event)) => return event,
1718                    // A reading this board could not reduce to its
1719                    // advertised field set; wait for the next one.
1720                    Either4::First(None) => continue,
1721                    Either4::Second(count) => {
1722                        return driver::PublishEvent::BleBondCount(count);
1723                    }
1724                    Either4::Third(state) => {
1725                        return driver::PublishEvent::BleLink(state);
1726                    }
1727                    Either4::Fourth(open) => {
1728                        return driver::PublishEvent::BlePairing(open);
1729                    }
1730                }
1731            }
1732        }
1733
1734        async fn apply_pairing_pin(&mut self, pin: Option<u32>) -> bool {
1735            PAIRING_CONFIG_CH.send(pin).await;
1736            PAIRING_CONFIG_ACK.wait().await
1737        }
1738
1739        async fn clear_ble_bonds(&mut self) -> bool {
1740            // The menu fires this signal too and never waits, so an
1741            // outcome may already be sitting in the ack; clear it before
1742            // asking or we would answer with the menu's.
1743            BLE_WIPE_ACK.reset();
1744            BLE_WIPE_REQUEST.signal(());
1745            BLE_WIPE_ACK.wait().await
1746        }
1747
1748        async fn set_ble_pairing(&mut self, open: bool) -> bool {
1749            if !BLE_ENABLED.load(Ordering::Acquire) {
1750                // Nothing can pair through a transport that is off, so a
1751                // window cannot open — and closing one is trivially done
1752                // without the stack's help, which matters because the
1753                // task that would answer may be parked with the radio.
1754                if !open {
1755                    set_pairing_mode(false);
1756                }
1757                return !open;
1758            }
1759            PAIRING_MODE_ACK.reset();
1760            PAIRING_MODE_REQUEST.signal(open);
1761            PAIRING_MODE_ACK.wait().await
1762        }
1763
1764        async fn factory_reset(&mut self) -> ! {
1765            // Erase the entire non-volatile storage region in one sweep.
1766            // Every persistent journal lives in this contiguous window
1767            // (see `ble_store` / memory.x): BLE bonds + pairing PIN + local
1768            // IRK, the saved provisioning snapshot, the device identity,
1769            // UX state, and the frame-counter boundaries. Wiping the flash
1770            // and rebooting is a complete factory reset — every subsystem
1771            // remounts from erased flash on boot, so no live in-RAM table
1772            // (BLE bonds included) has to be touched here.
1773            //
1774            // The full reserved region, not just the pages currently in
1775            // use, so a future journal added inside it is covered too.
1776            const NV_REGION_START: u32 = ble_store::PAGE0; // 0x000E_4000
1777            const NV_REGION_END: u32 = 0x000F_4000;
1778            debug_log(format_args!("FACTORY RESET: erasing NV region + reboot"));
1779            // Commanded over the mesh, the MAC acknowledgment of the
1780            // request is still in the TX queue; let it out so the
1781            // administrator hears the command landed. The counter flush
1782            // that rides along is erased two lines down, which is fine —
1783            // a factory-fresh device has no admins left to replay at.
1784            super::device_node::quiesce_for_reboot().await;
1785            {
1786                let mut flash = self.proto_store.flash().lock().await;
1787                let mut page = NV_REGION_START;
1788                while page < NV_REGION_END {
1789                    // Best-effort: a page that fails to erase is superseded
1790                    // by the reboot's fresh mount anyway, and there is no
1791                    // host left to report to (the link drops on reset).
1792                    let _ = flash.erase(page, page + ble_store::PAGE_SIZE).await;
1793                    page += ble_store::PAGE_SIZE;
1794                }
1795            }
1796            // Discards all in-RAM state and remounts factory-fresh.
1797            cortex_m::peripheral::SCB::sys_reset()
1798        }
1799
1800        async fn reboot(&mut self) -> ! {
1801            // Settle the mesh first: air the MAC acknowledgment that
1802            // answers a mesh-commanded reboot, and force the frame
1803            // counters to flash. Without the flush, the boundary that
1804            // admitted the reboot command dies with the RAM it lives in,
1805            // and the administrator's retries are accepted again after
1806            // boot — one reboot per retry.
1807            super::device_node::quiesce_for_reboot().await;
1808            // Nothing is erased: every journal stays where it is and the
1809            // board remounts from it. Through `reset_to_app` rather than
1810            // a bare `sys_reset` so GPREGRET is cleared first — a stale
1811            // DFU value there would land the reboot in the bootloader
1812            // instead of the application.
1813            debug_log(format_args!("REBOOT: restarting"));
1814            umsh_bsp_nrf52840::gpregret::reset_to_app()
1815        }
1816
1817        fn set_advertising_allowed(&mut self, allowed: bool) {
1818            // ble-debug builds keep advertising open regardless of the
1819            // arbitration policy so the diagnostic console stays reachable.
1820            #[cfg(feature = "ble-debug")]
1821            let allowed = {
1822                let _ = allowed;
1823                true
1824            };
1825            set_advertising_allowed(allowed);
1826        }
1827
1828        async fn publish_device_name(&mut self, name: &str) {
1829            let bytes = name.as_bytes();
1830            let mut current = DEVICE_NAME.lock().await;
1831            if current.as_slice() == bytes {
1832                return;
1833            }
1834            current.clear();
1835            if current.extend_from_slice(bytes).is_ok() {
1836                if DEVICE_NAME_PUBLISHED.swap(true, Ordering::AcqRel) {
1837                    GATT_NAME_STALE.store(true, Ordering::Release);
1838                }
1839                DEVICE_NAME_CHANGED.signal(());
1840                super::device_node::set_device_name(bytes);
1841            }
1842        }
1843
1844        fn publish_dev_domain(&mut self, snapshot: driver::DevDomainSnapshot) {
1845            // The zone and the positioning policy ride the device-domain
1846            // mirror, so a host write, a boot restore, and a `CMD_RST`
1847            // all reach the clock and the receiver by the same path —
1848            // and neither needs anything to remember to push it.
1849            umsh_hal::wall_clock::set_tz(snapshot.tz_offset_min);
1850            #[cfg(feature = "cap-gnss")]
1851            umsh_ulcp_runtime::gnss::configure(
1852                snapshot.gnss_enabled,
1853                umsh_ulcp_runtime::gnss::Policy {
1854                    trust_time: snapshot.gnss_time_trust,
1855                    update_identity: snapshot.gnss_ident_update,
1856                    identity_precision: snapshot.gnss_ident_precision,
1857                },
1858            );
1859            super::device_node::publish_snapshot(snapshot);
1860            // Every switch the settings menu shows is read back out of the
1861            // mirrors this call has just finished writing, so this is the
1862            // one place that can honestly say they moved. A host write, a
1863            // boot restore, a `CMD_RST` and a press on the panel all
1864            // arrive here, which is why none of them has to remember to
1865            // raise it for itself. `UI_REFRESH` never lights a dark panel
1866            // — the press that caused this already did.
1867            #[cfg(feature = "has-display")]
1868            UI_REFRESH.signal(());
1869        }
1870
1871        #[cfg(feature = "t1000e")]
1872        fn request_attention(&mut self) {
1873            umsh_bsp_t1000e::indicator::request_attention();
1874        }
1875
1876        #[cfg(feature = "t1000e")]
1877        fn clear_attention(&mut self) {
1878            umsh_bsp_t1000e::indicator::clear_attention();
1879        }
1880
1881        #[cfg(feature = "cap-battery-saadc")]
1882        fn note_transmit_load(&mut self) {
1883            // Mark the load for the battery level estimator (the radio
1884            // runner transmits within milliseconds of this).
1885            board_power::note_external_load();
1886        }
1887
1888        fn set_alert(&mut self, state: umsh_ulcp::alert::AlertState) {
1889            set_alert_indication(state.is_active());
1890        }
1891
1892        fn set_ble_enabled(&mut self, enabled: bool) {
1893            set_ble_enabled(enabled);
1894        }
1895
1896        #[cfg(all(feature = "cap-gnss", feature = "t1000e"))]
1897        fn gnss_switched(&mut self, enabled: bool) {
1898            // Two indications rather than one confirmation blink: the
1899            // gesture is a toggle, so "it worked" tells the operator
1900            // nothing they did not already know. Which way it went is
1901            // the only thing worth reporting, and the buzzer carries it
1902            // for a device still in a pocket.
1903            umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL.signal(if enabled {
1904                LedSequence::GnssOn
1905            } else {
1906                LedSequence::GnssOff
1907            });
1908            umsh_bsp_t1000e::BUZZER_SIGNAL.signal(if enabled {
1909                &umsh_ux_tracker::buzzer::melodies::GNSS_ON
1910            } else {
1911                &umsh_ux_tracker::buzzer::melodies::GNSS_OFF
1912            });
1913        }
1914
1915        fn trace(&mut self, args: core::fmt::Arguments<'_>) {
1916            debug_log(args);
1917        }
1918    }
1919
1920    // ─── Tasks ───────────────────────────────────────────────────────────────
1921
1922    #[embassy_executor::task]
1923    async fn mpsl_task(mpsl: &'static MultiprotocolServiceLayer<'static>) -> ! {
1924        mpsl.run().await
1925    }
1926
1927    async fn ble_runner<C: Controller, P: PacketPool>(mut runner: Runner<'_, C, P>) -> ! {
1928        loop {
1929            match runner.run().await {
1930                Ok(()) => debug_log(format_args!("ble runner exited cleanly")),
1931                Err(error) => debug_log(format_args!("ble runner error={error:?}")),
1932            }
1933        }
1934    }
1935
1936    /// How long a pairing window stays open before it closes itself.
1937    ///
1938    /// Boards that can *ask* for a window — a menu entry, or a
1939    /// hold-through-power-on gesture — get 30 s, because reopening one is
1940    /// cheap. A `boot-pairing-window` board has neither, so its only
1941    /// window is the automatic one at boot and it is deliberately shorter:
1942    /// it is open on every single boot rather than on request, so the
1943    /// exposure is recurring and the length is the only thing limiting it.
1944    #[cfg(not(feature = "boot-pairing-window"))]
1945    const PAIRING_WINDOW_SECS: u64 = 30;
1946    #[cfg(feature = "boot-pairing-window")]
1947    const PAIRING_WINDOW_SECS: u64 = 20;
1948
1949    async fn pairing_timeout<C: Controller, P: PacketPool>(stack: &Stack<'_, C, P>) -> ! {
1950        loop {
1951            match select(
1952                Timer::after_secs(PAIRING_WINDOW_SECS),
1953                PAIRING_TIMER_RESET.wait(),
1954            )
1955            .await
1956            {
1957                Either::First(()) => {
1958                    debug_log(format_args!("pairing window expired"));
1959                    set_pairing_mode(false);
1960                    BLE_LED_MODE.store(0, Ordering::Release);
1961                    UI_REFRESH.signal(());
1962                    apply_pairing_gate(stack);
1963                }
1964                Either::Second(()) => debug_log(format_args!("pairing timer reset")),
1965            }
1966        }
1967    }
1968
1969    async fn pairing_config_task<C: Controller, P: PacketPool>(
1970        stack: &Stack<'_, C, P>,
1971        store: &BleStoreMutex,
1972    ) -> ! {
1973        loop {
1974            match select3(
1975                PAIRING_CONFIG_CH.receive(),
1976                PAIRING_MODE_REQUEST.wait(),
1977                BLE_WIPE_REQUEST.wait(),
1978            )
1979            .await
1980            {
1981                Either3::First(pin) => {
1982                    debug_log(format_args!(
1983                        "pin config begin configured={}",
1984                        pin.is_some()
1985                    ));
1986                    let persisted = match store.lock().await.set_pin(pin).await {
1987                        Ok(()) => {
1988                            debug_log(format_args!("pin config persist=ok"));
1989                            true
1990                        }
1991                        Err(()) => {
1992                            debug_log(format_args!("pin config persist=FAILED"));
1993                            false
1994                        }
1995                    };
1996                    let applied = if persisted {
1997                        match stack.set_fixed_passkey(pin) {
1998                            Ok(()) => {
1999                                debug_log(format_args!("pin config trouble-passkey=ok"));
2000                                true
2001                            }
2002                            Err(error) => {
2003                                debug_log(format_args!(
2004                                    "pin config trouble-passkey=FAILED error={error:?}"
2005                                ));
2006                                false
2007                            }
2008                        }
2009                    } else {
2010                        debug_log(format_args!("pin config trouble-passkey=skipped"));
2011                        false
2012                    };
2013                    let result = persisted && applied;
2014                    if result {
2015                        stack.set_io_capabilities(if pin.is_some() {
2016                            IoCapabilities::DisplayOnly
2017                        } else {
2018                            IoCapabilities::NoInputNoOutput
2019                        });
2020                        PAIRING_PIN.store(pin.unwrap_or(u32::MAX), Ordering::Release);
2021                        apply_pairing_gate(stack);
2022                    }
2023                    debug_log(format_args!(
2024                        "pin config requested={} persisted={} applied={}",
2025                        pin.is_some(),
2026                        persisted,
2027                        result,
2028                    ));
2029                    PAIRING_CONFIG_ACK.signal(result);
2030                }
2031                Either3::Second(false) => {
2032                    debug_log(format_args!("pairing window closed on request"));
2033                    set_pairing_mode(false);
2034                    BLE_LED_MODE.store(0, Ordering::Release);
2035                    UI_REFRESH.signal(());
2036                    apply_pairing_gate(stack);
2037                    // Closing always lands: there is no state the device
2038                    // can be in where a window refuses to shut.
2039                    PAIRING_MODE_ACK.signal(true);
2040                }
2041                Either3::Second(true) => {
2042                    debug_log(format_args!("pairing mode requested"));
2043                    let locked_out = PAIRING_LOCKED_OUT.load(Ordering::Acquire);
2044                    // A window that cannot be walked through is not a
2045                    // window: while locked out nothing is opened and the
2046                    // caller is told so rather than left waiting out a
2047                    // timeout — and `PROP_BLE_PAIRING` never reports a
2048                    // window nothing can use. A full store is not that
2049                    // case — enrollment at capacity evicts rather than
2050                    // refuses, so it warns the operator without failing
2051                    // the request.
2052                    if !locked_out {
2053                        set_pairing_mode(true);
2054                        BLE_LED_MODE.store(1, Ordering::Release);
2055                        PAIRING_TIMER_RESET.signal(());
2056                    }
2057                    let unavailable = locked_out
2058                        || usize::from(BLE_BOND_COUNT.load(Ordering::Acquire))
2059                            >= ble_store::MAX_BONDS;
2060                    UI_NOTICE.signal(if unavailable {
2061                        UiNotice::PairingUnavailable
2062                    } else {
2063                        UiNotice::PairingStarted
2064                    });
2065                    apply_pairing_gate(stack);
2066                    PAIRING_MODE_ACK.signal(!locked_out);
2067                }
2068                Either3::Third(()) => {
2069                    debug_log(format_args!("security wipe requested"));
2070                    if store.lock().await.clear_security().await.is_ok() {
2071                        debug_log(format_args!("security wipe flash=ok"));
2072                        set_bond_count(0);
2073                        let mut identities: heapless::Vec<Identity, { ble_store::MAX_BONDS }> =
2074                            heapless::Vec::new();
2075                        stack.with_bond_information(|bonds| {
2076                            for bond in bonds {
2077                                if identities.push(bond.identity).is_err() {
2078                                    debug_log(format_args!("security wipe identity-list=FULL"));
2079                                }
2080                            }
2081                        });
2082                        debug_log(format_args!(
2083                            "security wipe removing-bonds count={}",
2084                            identities.len()
2085                        ));
2086                        for identity in identities {
2087                            match stack.remove_bond_information(identity) {
2088                                Ok(()) => debug_log(format_args!("security wipe remove-bond=ok")),
2089                                Err(error) => debug_log(format_args!(
2090                                    "security wipe remove-bond=FAILED error={error:?}"
2091                                )),
2092                            }
2093                        }
2094                        match stack.set_fixed_passkey(None) {
2095                            Ok(()) => debug_log(format_args!("security wipe clear-passkey=ok")),
2096                            Err(error) => debug_log(format_args!(
2097                                "security wipe clear-passkey=FAILED error={error:?}"
2098                            )),
2099                        }
2100                        stack.set_io_capabilities(IoCapabilities::NoInputNoOutput);
2101                        PAIRING_PIN.store(u32::MAX, Ordering::Release);
2102                        PAIRING_FAILURES.store(0, Ordering::Release);
2103                        PAIRING_LOCKED_OUT.store(false, Ordering::Release);
2104                        set_pairing_mode(true);
2105                        BLE_LED_MODE.store(2, Ordering::Release);
2106                        PAIRING_TIMER_RESET.signal(());
2107                        apply_pairing_gate(stack);
2108                        debug_log(format_args!("security wipe complete"));
2109                        UI_NOTICE.signal(UiNotice::BondsCleared);
2110                        BLE_WIPE_ACK.signal(true);
2111                    } else {
2112                        debug_log(format_args!("security wipe flash=FAILED"));
2113                        UI_NOTICE.signal(UiNotice::ClearFailed);
2114                        BLE_WIPE_ACK.signal(false);
2115                    }
2116                }
2117            }
2118        }
2119    }
2120
2121    fn classify_pairing_failure(error: &trouble_host::Error) -> PairingFailureClass {
2122        match error {
2123            trouble_host::Error::Security(PairingFailedReason::ConfirmValueFailed) => {
2124                PairingFailureClass::ConfirmValue
2125            }
2126            trouble_host::Error::Security(PairingFailedReason::DHKeyCheckFailed) => {
2127                PairingFailureClass::DhKeyCheck
2128            }
2129            _ => PairingFailureClass::Other,
2130        }
2131    }
2132
2133    async fn advertise<'values, 'server, C: Controller>(
2134        peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
2135        server: &'server UlcpServer<'values>,
2136    ) -> Result<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
2137        const SERVICE_UUID_LE: [u8; 16] = gatt::SERVICE_UUID.to_le_bytes();
2138        // The advertisement and the GAP characteristic must agree, and this
2139        // is the one place both are known to be about to matter.
2140        sync_gap_device_name(server).await;
2141        let name = {
2142            let configured = DEVICE_NAME.lock().await;
2143            if configured.is_empty() {
2144                DeviceName::from_slice(default_device_name().as_bytes()).expect("default name fits")
2145            } else {
2146                configured.clone()
2147            }
2148        };
2149        let adv_name_len = utf8_prefix_len(name.as_slice(), 8);
2150        let mut adv_data = [0u8; 31];
2151        let adv_len = AdStructure::encode_slice(
2152            &[
2153                AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
2154                AdStructure::CompleteServiceUuids128(&[SERVICE_UUID_LE]),
2155                AdStructure::ShortenedLocalName(&name[..adv_name_len]),
2156            ],
2157            &mut adv_data,
2158        )?;
2159        let scan_name_len = utf8_prefix_len(name.as_slice(), 29);
2160        let scan_name = if scan_name_len == name.len() {
2161            AdStructure::CompleteLocalName(&name[..scan_name_len])
2162        } else {
2163            AdStructure::ShortenedLocalName(&name[..scan_name_len])
2164        };
2165        let mut scan_data = [0u8; 31];
2166        let scan_len = AdStructure::encode_slice(&[scan_name], &mut scan_data)?;
2167        debug_log(format_args!(
2168            "advertising start adv-bytes={} scan-bytes={}",
2169            adv_len, scan_len
2170        ));
2171        let advertiser = peripheral
2172            .advertise(
2173                &Default::default(),
2174                Advertisement::ConnectableScannableUndirected {
2175                    adv_data: &adv_data[..adv_len],
2176                    scan_data: &scan_data[..scan_len],
2177                },
2178            )
2179            .await?;
2180        debug_log(format_args!("advertising controller-active"));
2181        let raw_connection = advertiser.accept().await?;
2182        debug_log(format_args!("advertising raw-connection accepted"));
2183        let connection = raw_connection.with_attribute_server(server)?;
2184        debug_log(format_args!("advertising gatt-server attached"));
2185        Ok(connection)
2186    }
2187
2188    fn utf8_prefix_len(bytes: &[u8], maximum: usize) -> usize {
2189        let text = core::str::from_utf8(bytes).expect("validated device name");
2190        let mut len = bytes.len().min(maximum);
2191        while !text.is_char_boundary(len) {
2192            len -= 1;
2193        }
2194        len
2195    }
2196
2197    async fn send_ble_frame(
2198        server: &UlcpServer<'_>,
2199        conn: &GattConnection<'_, '_, DefaultPacketPool>,
2200        outbound: OutFrame,
2201    ) -> Result<(), trouble_host::Error> {
2202        if SESSION_GEN.load(Ordering::Acquire) != outbound.generation {
2203            debug_log(format_args!(
2204                "ble outbound dropped stale-generation frame-gen={} active-gen={}",
2205                outbound.generation,
2206                SESSION_GEN.load(Ordering::Acquire),
2207            ));
2208            return Ok(());
2209        }
2210        let segment_payload = usize::from(conn.raw().att_mtu())
2211            .saturating_sub(4)
2212            .clamp(1, BLE_VALUE_MAX - 1);
2213        let mut segments = generation_checked(
2214            gatt::segments(&outbound.frame, segment_payload),
2215            outbound.generation,
2216            || SESSION_GEN.load(Ordering::Acquire),
2217        );
2218        for segment in segments.by_ref() {
2219            let mut value: heapless09::Vec<u8, BLE_VALUE_MAX> = heapless09::Vec::new();
2220            value
2221                .push(segment.header())
2222                .map_err(|_| trouble_host::Error::InsufficientSpace)?;
2223            value
2224                .extend_from_slice(segment.payload())
2225                .map_err(|_| trouble_host::Error::InsufficientSpace)?;
2226            server.ulcp.frame_out.notify(conn, &value, false).await?;
2227        }
2228        if segments.stale() {
2229            debug_log(format_args!(
2230                "ble outbound segmentation stopped generation-changed"
2231            ));
2232        }
2233        Ok(())
2234    }
2235
2236    /// A link-level signal the connection loop reacts to, other than a GATT
2237    /// event or an outbound frame.
2238    enum LinkSignal {
2239        AdvertisingPolicy,
2240        DeviceName,
2241    }
2242
2243    /// The GAP Device Name value for `name`, truncated on a UTF-8 boundary.
2244    ///
2245    /// The characteristic is inline-stored and therefore shorter than the
2246    /// ULCP name limit; GAP gets a prefix rather than the full name, exactly
2247    /// as the advertisement does.
2248    fn gap_device_name(name: &[u8]) -> GapDeviceName {
2249        let len = utf8_prefix_len(name, gap::DEVICE_NAME_MAX_LENGTH);
2250        GapDeviceName::from_slice(&name[..len]).unwrap_or_default()
2251    }
2252
2253    /// Publish the configured name on the GAP Device Name characteristic.
2254    ///
2255    /// Called before advertising and after a rename, so the value a client
2256    /// reads is the current name rather than whatever the device booted
2257    /// with.
2258    async fn sync_gap_device_name(server: &UlcpServer<'_>) {
2259        let Some(gap) = server.gap.as_ref() else {
2260            return;
2261        };
2262        let name = device_name_snapshot().await;
2263        if server
2264            .set(&gap.device_name, &gap_device_name(name.as_slice()))
2265            .is_err()
2266        {
2267            debug_log(format_args!("gap device-name update FAILED"));
2268        }
2269    }
2270
2271    /// Tell a connected client that its cached attributes are stale.
2272    ///
2273    /// A bonded iOS client caches the GAP device name against the bond and
2274    /// will keep showing the old one — in Settings › Bluetooth and to every
2275    /// app on the phone — until a Service Changed indication makes it
2276    /// re-read. The indicated range covers the whole table, because the
2277    /// point is to invalidate a cache rather than to describe a structural
2278    /// change. Clients that never subscribed are skipped inside trouble.
2279    async fn announce_gatt_change(
2280        server: &UlcpServer<'_>,
2281        conn: &GattConnection<'_, '_, DefaultPacketPool>,
2282    ) {
2283        let Some(gap) = server.gap.as_ref() else {
2284            return;
2285        };
2286        // A client that has not subscribed cannot be told anything, and
2287        // `indicate` reports that case as success. Checking first keeps the
2288        // stale marker set so the next connection tries again.
2289        if !gap.service_changed.should_indicate(conn) {
2290            debug_log(format_args!("service-changed not subscribed; deferring"));
2291            return;
2292        }
2293        const WHOLE_TABLE: [u8; 4] = [0x01, 0x00, 0xFF, 0xFF];
2294        match gap
2295            .service_changed
2296            .indicate(conn, &WHOLE_TABLE, false)
2297            .await
2298        {
2299            Ok(()) => {
2300                GATT_NAME_STALE.store(false, Ordering::Release);
2301                debug_log(format_args!("service-changed indicated"));
2302            }
2303            Err(error) => debug_log(format_args!("service-changed indicate error={error:?}")),
2304        }
2305    }
2306
2307    async fn gatt_connection<C: Controller, P: PacketPool>(
2308        stack: &Stack<'_, C, P>,
2309        store: &BleStoreMutex,
2310        server: &UlcpServer<'_>,
2311        conn: &GattConnection<'_, '_, DefaultPacketPool>,
2312    ) -> Result<(), trouble_host::Error> {
2313        match conn.raw().set_bondable(true) {
2314            Ok(()) => debug_log(format_args!("connection set-bondable=ok")),
2315            Err(error) => {
2316                debug_log(format_args!(
2317                    "connection set-bondable=FAILED error={error:?}"
2318                ));
2319                return Err(error);
2320            }
2321        }
2322        let peer = conn.raw().peer_identity();
2323        debug_log(format_args!(
2324            "connected peer={} kind={} irk={} table_match={} level={:?} mtu={}",
2325            peer.addr,
2326            peer.addr.to_bytes()[0],
2327            peer.irk.is_some(),
2328            conn.raw().is_bonded_peer(),
2329            conn.raw().security_level(),
2330            conn.raw().att_mtu(),
2331        ));
2332        let mut attached = false;
2333        set_ble_link(BleLinkState::Connected);
2334        let mut reassembler: gatt::Reassembler<{ gatt::MAX_FRAME }> = gatt::Reassembler::new();
2335
2336        // Reap a connection that never reaches encryption, so an unbonded or
2337        // unresolvable central — e.g. an iOS OS-level background reconnect that
2338        // presents an RPA we can't resolve — cannot squat the single peripheral
2339        // slot at NoEncryption and lock out the real client. A bonded reconnect
2340        // encrypts in ~0.3 s (see connect→Encrypted in the trace); 5 s leaves
2341        // ~2x headroom for a slow negotiated connection interval. A deliberate
2342        // pairing (the user pressed pair, then enters the PIN on the phone) gets
2343        // the full pairing-window grace instead.
2344        let grace = if PAIRING_MODE.load(Ordering::Acquire) {
2345            Duration::from_secs(40)
2346        } else {
2347            Duration::from_secs(5)
2348        };
2349        let mut encrypted = false;
2350        let mut deadline = core::pin::pin!(Timer::after(grace));
2351
2352        loop {
2353            let event = {
2354                let grace_guard = async {
2355                    if encrypted {
2356                        core::future::pending::<()>().await
2357                    } else {
2358                        deadline.as_mut().await
2359                    }
2360                };
2361                // The two link-level signals share one arm so the GATT event
2362                // match below keeps its shape.
2363                let link_signal = async {
2364                    match select(ADV_POLICY_CHANGED.wait(), DEVICE_NAME_CHANGED.wait()).await {
2365                        Either::First(()) => LinkSignal::AdvertisingPolicy,
2366                        Either::Second(()) => LinkSignal::DeviceName,
2367                    }
2368                };
2369                match select(
2370                    select3(conn.next(), OUT_CH.ble.receive(), link_signal),
2371                    grace_guard,
2372                )
2373                .await
2374                {
2375                    Either::First(event) => event,
2376                    Either::Second(()) => {
2377                        debug_log(format_args!(
2378                            "unencrypted connection grace expired; disconnecting squatter"
2379                        ));
2380                        conn.raw().disconnect();
2381                        break;
2382                    }
2383                }
2384            };
2385            match event {
2386                Either3::First(GattConnectionEvent::Disconnected { reason }) => {
2387                    debug_log(format_args!("disconnected reason={reason:?}"));
2388                    break;
2389                }
2390                Either3::First(GattConnectionEvent::PairingComplete { bond, .. }) => {
2391                    debug_log(format_args!(
2392                        "pairing-complete bond={} table_match={}",
2393                        bond.is_some(),
2394                        conn.raw().is_bonded_peer(),
2395                    ));
2396                    if let Some(bond) = bond {
2397                        if !bond_identity_is_persistable(&bond) {
2398                            debug_log(format_args!("pairing bond identity=incomplete"));
2399                            match stack.remove_bond_information(bond.identity) {
2400                                Ok(()) => {
2401                                    debug_log(format_args!("pairing incomplete bond remove=ok"))
2402                                }
2403                                Err(error) => debug_log(format_args!(
2404                                    "pairing incomplete bond remove=FAILED error={error:?}"
2405                                )),
2406                            }
2407                            debug_log(format_args!(
2408                                "disconnect initiated by incomplete pairing identity"
2409                            ));
2410                            conn.raw().disconnect();
2411                            break;
2412                        }
2413                        let persisted_bonds = match persist_bond(store, &bond).await {
2414                            Ok((count, evicted)) => {
2415                                forget_evicted_bond(stack, evicted);
2416                                count
2417                            }
2418                            Err(()) => {
2419                                debug_log(format_args!("pairing bond persist=FAILED"));
2420                                match stack.remove_bond_information(bond.identity) {
2421                                    Ok(()) => debug_log(format_args!(
2422                                        "pairing unpersisted bond remove=ok"
2423                                    )),
2424                                    Err(error) => debug_log(format_args!(
2425                                        "pairing unpersisted bond remove=FAILED error={error:?}"
2426                                    )),
2427                                }
2428                                debug_log(format_args!(
2429                                    "disconnect initiated by pairing persistence failure"
2430                                ));
2431                                conn.raw().disconnect();
2432                                break;
2433                            }
2434                        };
2435                        set_bond_count(persisted_bonds as u8);
2436                        UI_REFRESH.signal(());
2437                        debug_log(format_args!(
2438                            "pairing bond persist=ok peer={} kind={} irk={} bonded={} level={:?}",
2439                            bond.identity.addr,
2440                            bond.identity.addr.to_bytes()[0],
2441                            bond.identity.irk.is_some(),
2442                            bond.is_bonded,
2443                            bond.security_level,
2444                        ));
2445                    }
2446                    // Trouble may report a successful peripheral pairing with
2447                    // bond=None and expose the completed bond at the first
2448                    // protected GATT edge. Pairing success still resets the
2449                    // failure counter and closes the window in that case.
2450                    publish_pairing_runtime(pairing_runtime().pairing_succeeded());
2451                    BLE_LED_MODE.store(0, Ordering::Release);
2452                    apply_pairing_gate(stack);
2453                }
2454                Either3::First(GattConnectionEvent::PassKeyDisplay(_)) => {
2455                    debug_log(format_args!("passkey display requested"));
2456                }
2457                Either3::First(GattConnectionEvent::PassKeyConfirm(_)) => {
2458                    debug_log(format_args!("passkey confirmation requested"));
2459                }
2460                Either3::First(GattConnectionEvent::PassKeyInput) => {
2461                    debug_log(format_args!("passkey input requested"));
2462                }
2463                Either3::First(GattConnectionEvent::BondLost) => {
2464                    debug_log(format_args!("bond lost event"));
2465                }
2466                Either3::First(GattConnectionEvent::OobRequest) => {
2467                    debug_log(format_args!("oob requested"));
2468                }
2469                Either3::First(GattConnectionEvent::Encrypted { bond, .. }) => {
2470                    debug_log(format_args!(
2471                        "encrypted event_bond={} table_match={} level={:?}",
2472                        bond.is_some(),
2473                        conn.raw().is_bonded_peer(),
2474                        conn.raw().security_level(),
2475                    ));
2476                    // Link is encrypted: this is not a squatter, so stop the
2477                    // unencrypted-connection reaper regardless of bond state.
2478                    encrypted = true;
2479                    if bond.is_some() || conn.raw().is_bonded_peer() {
2480                        let peer = conn.raw().peer_identity();
2481                        let raw = peer.addr.to_bytes();
2482                        let address: [u8; 6] = raw[1..].try_into().unwrap();
2483                        match store.lock().await.touch_bond(raw[0], address).await {
2484                            Ok(true) => debug_log(format_args!("bond lru touch=moved")),
2485                            Ok(false) => {}
2486                            Err(()) => debug_log(format_args!("bond lru touch=FAILED")),
2487                        }
2488                        publish_pairing_runtime(pairing_runtime().bonded_reconnect());
2489                        BLE_LED_MODE.store(0, Ordering::Release);
2490                        apply_pairing_gate(stack);
2491                    }
2492                    // A bonded client caches attributes across connections,
2493                    // so a rename it missed has to be announced now. Only a
2494                    // client that has subscribed to Service Changed — which
2495                    // it does after encrypting — can be told.
2496                    if GATT_NAME_STALE.load(Ordering::Acquire) {
2497                        announce_gatt_change(server, conn).await;
2498                    }
2499                }
2500                Either3::First(GattConnectionEvent::PairingFailed(error)) => {
2501                    debug_log(format_args!("pairing-failed error={error:?}"));
2502                    let failure = classify_pairing_failure(&error);
2503                    if failure.counts_toward_lockout() {
2504                        let before = pairing_runtime();
2505                        let after = before.record_failure(failure);
2506                        publish_pairing_runtime(after);
2507                        debug_log(format_args!(
2508                            "pairing authentication-failures={} locked={}",
2509                            after.failures, after.locked_out,
2510                        ));
2511                        if after.locked_out && !before.locked_out {
2512                            apply_pairing_gate(stack);
2513                        }
2514                    }
2515                }
2516                Either3::First(GattConnectionEvent::Gatt { event }) => {
2517                    match &event {
2518                        GattEvent::Read(read) => debug_log(format_args!(
2519                            "gatt read handle=0x{:04x} level={:?}",
2520                            read.handle(),
2521                            conn.raw().security_level(),
2522                        )),
2523                        GattEvent::Write(write) => debug_log(format_args!(
2524                            "gatt write handle=0x{:04x} level={:?}",
2525                            write.handle(),
2526                            conn.raw().security_level(),
2527                        )),
2528                        GattEvent::NotAllowed(event) => debug_log(format_args!(
2529                            "gatt not-allowed handle=0x{:04x} level={:?}",
2530                            event.handle(),
2531                            conn.raw().security_level(),
2532                        )),
2533                        GattEvent::Other(event) => debug_log(format_args!(
2534                            "gatt other handle={:?} level={:?}",
2535                            event.payload().handle(),
2536                            conn.raw().security_level(),
2537                        )),
2538                    }
2539                    let frame_in = matches!(&event, GattEvent::Write(write) if write.handle() == server.ulcp.frame_in.handle);
2540                    let cccd = matches!(&event, GattEvent::Write(write) if Some(write.handle()) == server.ulcp.frame_out.cccd_handle);
2541                    let protected = frame_in || cccd;
2542                    let bonded = conn.raw().is_bonded_peer();
2543                    let mut bond_persist_failed = false;
2544                    // PairingComplete is not guaranteed to carry the newly-created bond on
2545                    // every peripheral path.  The protected GATT edge is authoritative: if
2546                    // Trouble says this peer is bonded, find that exact live-table entry and
2547                    // make it durable before granting access.  add_bond is idempotent, so
2548                    // subsequent frames do not write flash.
2549                    let durable_bond = if protected && bonded {
2550                        let peer = conn.raw().peer_identity();
2551                        let bond = stack.with_bond_information(|bonds| {
2552                            bonds
2553                                .iter()
2554                                .find(|bond| bond.identity.match_identity(&peer))
2555                                .cloned()
2556                        });
2557                        match bond {
2558                            Some(bond) if !bond_identity_is_persistable(&bond) => {
2559                                debug_log(format_args!("protected bond identity=pending"));
2560                                false
2561                            }
2562                            Some(bond) => match persist_bond(store, &bond).await {
2563                                Ok((count, evicted)) => {
2564                                    debug_log(format_args!("protected bond persist=ok"));
2565                                    forget_evicted_bond(stack, evicted);
2566                                    set_bond_count(count as u8);
2567                                    UI_REFRESH.signal(());
2568                                    apply_pairing_gate(stack);
2569                                    true
2570                                }
2571                                Err(()) => {
2572                                    debug_log(format_args!("protected bond persist=FAILED"));
2573                                    bond_persist_failed = true;
2574                                    match stack.remove_bond_information(bond.identity) {
2575                                        Ok(()) => debug_log(format_args!(
2576                                            "protected unpersisted bond remove=ok"
2577                                        )),
2578                                        Err(error) => debug_log(format_args!(
2579                                            "protected unpersisted bond remove=FAILED error={error:?}"
2580                                        )),
2581                                    }
2582                                    false
2583                                }
2584                            },
2585                            None => {
2586                                debug_log(format_args!("protected bond lookup=missing"));
2587                                false
2588                            }
2589                        }
2590                    } else {
2591                        !protected
2592                    };
2593                    if protected {
2594                        let peer = conn.raw().peer_identity();
2595                        debug_log(format_args!(
2596                            "gatt protected={} bonded={} durable={} peer={} kind={} irk={} level={:?}",
2597                            if cccd { "cccd" } else { "frame-in" },
2598                            bonded,
2599                            durable_bond,
2600                            peer.addr,
2601                            peer.addr.to_bytes()[0],
2602                            peer.irk.is_some(),
2603                            conn.raw().security_level(),
2604                        ));
2605                    }
2606                    let mut inbound: heapless09::Vec<u8, BLE_VALUE_MAX> = heapless09::Vec::new();
2607                    if frame_in {
2608                        if let GattEvent::Write(write) = &event {
2609                            write.with_data(|_, data| {
2610                                if inbound.extend_from_slice(data).is_err() {
2611                                    debug_log(format_args!(
2612                                        "gatt frame-in staging=FAILED len={}",
2613                                        data.len()
2614                                    ));
2615                                }
2616                            });
2617                        }
2618                    }
2619
2620                    let server_permission_denied = matches!(&event, GattEvent::NotAllowed(_));
2621                    let reply = if protected && !(bonded && durable_bond) {
2622                        debug_log(format_args!(
2623                            "gatt decision=reject insufficient-authentication"
2624                        ));
2625                        event.reject(AttErrorCode::INSUFFICIENT_AUTHENTICATION)
2626                    } else if server_permission_denied {
2627                        // `NotAllowedEvent::accept()` preserves and returns the
2628                        // attribute server's permission error; it does not grant
2629                        // the operation. Make that non-obvious Trouble API
2630                        // behavior explicit in the hardware trace.
2631                        debug_log(format_args!("gatt decision=return-server-permission-error"));
2632                        event.accept()
2633                    } else {
2634                        debug_log(format_args!("gatt decision=accept"));
2635                        event.accept()
2636                    }?;
2637                    reply.send().await;
2638
2639                    if protected && bonded && !durable_bond && bond_persist_failed {
2640                        debug_log(format_args!(
2641                            "disconnect initiated by protected bond persistence failure"
2642                        ));
2643                        conn.raw().disconnect();
2644                        break;
2645                    }
2646
2647                    if frame_in && bonded {
2648                        match reassembler.push(&inbound) {
2649                            Some(Ok(frame)) => {
2650                                let mut value: FrameBuf = heapless::Vec::new();
2651                                match value.extend_from_slice(frame) {
2652                                    Ok(()) => {
2653                                        debug_log(format_args!(
2654                                            "gatt frame-in complete len={}",
2655                                            frame.len()
2656                                        ));
2657                                        INPUT_CH.send(InEvent::Frame(Transport::Ble, value)).await;
2658                                    }
2659                                    Err(()) => debug_log(format_args!(
2660                                        "gatt frame-in complete staging=FAILED len={}",
2661                                        frame.len()
2662                                    )),
2663                                }
2664                            }
2665                            Some(Err(error)) => debug_log(format_args!(
2666                                "gatt frame-in decode=FAILED error={error:?} segment-len={}",
2667                                inbound.len()
2668                            )),
2669                            None => debug_log(format_args!(
2670                                "gatt frame-in segment accepted segment-len={} complete=false",
2671                                inbound.len()
2672                            )),
2673                        }
2674                    }
2675                    if cccd && bonded {
2676                        let subscribed = server.ulcp.frame_out.should_notify(conn);
2677                        match (attached, subscribed) {
2678                            (false, true) => {
2679                                debug_log(format_args!("cccd subscribed=true"));
2680                                attached = true;
2681                                set_ble_link(BleLinkState::Attached);
2682                                INPUT_CH.send(InEvent::Attached(Transport::Ble)).await;
2683                            }
2684                            (true, false) => {
2685                                debug_log(format_args!("cccd subscribed=false"));
2686                                attached = false;
2687                                set_ble_link(BleLinkState::Connected);
2688                                reassembler.reset();
2689                                INPUT_CH.send(InEvent::Detached(Transport::Ble)).await;
2690                            }
2691                            (false, false) => debug_log(format_args!(
2692                                "cccd state unchanged attached=false subscribed=false"
2693                            )),
2694                            (true, true) => debug_log(format_args!(
2695                                "cccd state unchanged attached=true subscribed=true"
2696                            )),
2697                        }
2698                    }
2699                }
2700                Either3::First(GattConnectionEvent::PhyUpdated { tx_phy, rx_phy }) => {
2701                    debug_log(format_args!(
2702                        "connection phy-updated tx={tx_phy:?} rx={rx_phy:?}"
2703                    ));
2704                }
2705                Either3::First(GattConnectionEvent::ConnectionParamsUpdated {
2706                    conn_interval,
2707                    peripheral_latency,
2708                    supervision_timeout,
2709                }) => {
2710                    debug_log(format_args!(
2711                        "connection params-updated interval-us={} latency={} timeout-us={}",
2712                        conn_interval.as_micros(),
2713                        peripheral_latency,
2714                        supervision_timeout.as_micros(),
2715                    ));
2716                }
2717                Either3::First(GattConnectionEvent::RequestConnectionParams(request)) => {
2718                    debug_log(format_args!(
2719                        "connection params-requested params={:?}",
2720                        request.params()
2721                    ));
2722                    match request.accept(None, stack).await {
2723                        Ok(()) => debug_log(format_args!("connection params-response=accepted")),
2724                        Err(error) => debug_log(format_args!(
2725                            "connection params-response=FAILED error={error:?}"
2726                        )),
2727                    }
2728                }
2729                Either3::First(GattConnectionEvent::DataLengthUpdated {
2730                    max_tx_octets,
2731                    max_tx_time,
2732                    max_rx_octets,
2733                    max_rx_time,
2734                }) => debug_log(format_args!(
2735                    "connection data-length tx-octets={} tx-time={} rx-octets={} rx-time={}",
2736                    max_tx_octets, max_tx_time, max_rx_octets, max_rx_time,
2737                )),
2738                Either3::First(GattConnectionEvent::FrameSpaceUpdated {
2739                    frame_space,
2740                    initiator,
2741                    phys,
2742                    spacing_types,
2743                }) => debug_log(format_args!(
2744                    "connection frame-space us={} initiator={initiator:?} phys={phys:?} spacing={spacing_types:?}",
2745                    frame_space.as_micros(),
2746                )),
2747                Either3::First(GattConnectionEvent::ConnectionRateChanged {
2748                    conn_interval,
2749                    subrate_factor,
2750                    peripheral_latency,
2751                    continuation_number,
2752                    supervision_timeout,
2753                }) => debug_log(format_args!(
2754                    "connection rate-changed interval-us={} subrate={} latency={} continuation={} timeout-us={}",
2755                    conn_interval.as_micros(),
2756                    subrate_factor,
2757                    peripheral_latency,
2758                    continuation_number,
2759                    supervision_timeout.as_micros(),
2760                )),
2761                Either3::Second(outbound) => {
2762                    if attached && conn.raw().is_bonded_peer() {
2763                        send_ble_frame(server, conn, outbound).await?;
2764                    } else {
2765                        debug_log(format_args!(
2766                            "ble outbound dropped attached={} bonded={} level={:?}",
2767                            attached,
2768                            conn.raw().is_bonded_peer(),
2769                            conn.raw().security_level(),
2770                        ));
2771                    }
2772                }
2773                Either3::Third(LinkSignal::AdvertisingPolicy) => {
2774                    if !advertising_permitted() {
2775                        debug_log(format_args!(
2776                            "disconnect initiated by transport arbitration"
2777                        ));
2778                        conn.raw().disconnect();
2779                        break;
2780                    }
2781                }
2782                Either3::Third(LinkSignal::DeviceName) => {
2783                    sync_gap_device_name(server).await;
2784                    announce_gatt_change(server, conn).await;
2785                }
2786            }
2787        }
2788        set_ble_link(BleLinkState::None);
2789        if attached {
2790            INPUT_CH.send(InEvent::Detached(Transport::Ble)).await;
2791        }
2792        Ok(())
2793    }
2794
2795    async fn ble_peripheral<'values, C: Controller>(
2796        stack: &Stack<'_, C, DefaultPacketPool>,
2797        store: &BleStoreMutex,
2798        peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
2799        server: &UlcpServer<'values>,
2800    ) -> ! {
2801        loop {
2802            if !advertising_permitted() {
2803                ADV_POLICY_CHANGED.wait().await;
2804                continue;
2805            }
2806            super::panic::breadcrumb_mark(12);
2807            match select3(
2808                advertise(peripheral, server),
2809                ADV_POLICY_CHANGED.wait(),
2810                DEVICE_NAME_CHANGED.wait(),
2811            )
2812            .await
2813            {
2814                Either3::First(Ok(connection)) => {
2815                    match gatt_connection(stack, store, server, &connection).await {
2816                        Ok(()) => debug_log(format_args!("gatt connection task ended ok")),
2817                        Err(error) => {
2818                            debug_log(format_args!("gatt connection task error={error:?}"))
2819                        }
2820                    }
2821                }
2822                Either3::First(Err(error)) => {
2823                    debug_log(format_args!("advertising error={error:?}"))
2824                }
2825                Either3::Second(()) => debug_log(format_args!("advertising policy changed")),
2826                Either3::Third(()) => debug_log(format_args!("advertising device name changed")),
2827            }
2828        }
2829    }
2830
2831    /// Startup-failure containment: a misconfigured or failed BLE bring-up
2832    /// must degrade to a USB-only device, never a panic/reboot loop — a
2833    /// display-less field node that boot-loops is unrecoverable in place.
2834    /// Parks the BLE app forever; USB keeps running via the outer join.
2835    async fn ble_disabled_park(reason: &'static str) -> ! {
2836        loop {
2837            debug_log(format_args!("BLE DISABLED: {reason}"));
2838            Timer::after_secs(600).await;
2839        }
2840    }
2841
2842    async fn ble_app<C: Controller>(controller: C, store: BleStore) -> ! {
2843        // Install the log→serial bridge before the host stack starts so
2844        // trouble-host's boot-time resolving-list diagnostics are captured.
2845        #[cfg(feature = "ble-debug")]
2846        init_foreign_crate_logging();
2847        super::panic::breadcrumb_mark(10);
2848        let mut resources: HostResources<
2849            _,
2850            DefaultPacketPool,
2851            BLE_CONNECTIONS_MAX,
2852            BLE_L2CAP_CHANNELS_MAX,
2853        > = HostResources::new();
2854        let initial = store.snapshot().clone();
2855        debug_log(format_args!(
2856            "ble boot identity={} bonds={} pin={} local_irk={} privacy=false",
2857            ble_identity_address(),
2858            initial.bonds.len(),
2859            initial.pin.is_some(),
2860            initial.local_irk.is_some(),
2861        ));
2862        for bond in &initial.bonds {
2863            debug_log(format_args!(
2864                "restored bond peer-kind={} peer={:02x?} irk={} bonded={} level={}",
2865                bond.address_kind,
2866                bond.address,
2867                bond.irk.is_some(),
2868                bond.is_bonded,
2869                bond.security_level,
2870            ));
2871        }
2872        PAIRING_PIN.store(initial.pin.unwrap_or(u32::MAX), Ordering::Release);
2873        set_bond_count(initial.bonds.len() as u8);
2874        // `boot-pairing-window` boards open a window on *every* boot,
2875        // bonded or not. They have no button and no menu, so this is the
2876        // only way to ever pair a second host — without it the first
2877        // bond would lock everyone else out permanently. Pressing RESET
2878        // is the physical-presence ceremony on those boards, standing in
2879        // for the button hold the others use; a configured PIN still
2880        // gates the pairing itself, and the failure lockout still applies.
2881        let initial_pairing_mode = initial.bonds.is_empty()
2882            || FORCE_PAIRING_AT_BOOT.load(Ordering::Acquire)
2883            || cfg!(feature = "boot-pairing-window");
2884        PAIRING_MODE.store(initial_pairing_mode, Ordering::Release);
2885        BLE_LED_MODE.store(u8::from(initial_pairing_mode), Ordering::Release);
2886        // Seed the session's `PROP_BLE_PAIRING` mirror unconditionally:
2887        // the change-triggered signal in `set_pairing_mode` cannot fire
2888        // for a boot that lands on the static's initial value.
2889        BLE_PAIRING_CHANGED.signal(pairing_window_open());
2890        // The hold-through-power-on ceremony must end with a reachable
2891        // radio even when the operator had turned Bluetooth off. Routed
2892        // through the session so the property, the panel, the saved
2893        // snapshot, and this transport all move together.
2894        if FORCE_PAIRING_AT_BOOT.load(Ordering::Acquire) {
2895            INPUT_CH.send(InEvent::ForceBluetoothOn).await;
2896        }
2897        UI_REFRESH.signal(());
2898        let io_capabilities = if initial.pin.is_some() {
2899            IoCapabilities::DisplayOnly
2900        } else {
2901            IoCapabilities::NoInputNoOutput
2902        };
2903        let initial_pairing_enabled =
2904            pairing_enabled(initial_pairing_mode, initial.pin.is_some(), false);
2905        debug_log(format_args!(
2906            "ble stack configure io={io_capabilities:?} pairing-enabled={} fixed-passkey={}",
2907            initial_pairing_enabled,
2908            initial.pin.is_some(),
2909        ));
2910        let stack_builder = trouble_host::new(controller, &mut resources)
2911            .set_random_address(ble_identity_address())
2912            .set_io_capabilities(io_capabilities)
2913            .set_pairing_enabled(initial_pairing_enabled)
2914            .set_fixed_passkey(initial.pin);
2915        let stack = match stack_builder {
2916            Ok(builder) => {
2917                debug_log(format_args!("ble stack fixed-passkey configure=ok"));
2918                builder.build()
2919            }
2920            Err(error) => {
2921                debug_log(format_args!(
2922                    "ble stack fixed-passkey configure=FAILED error={error:?}"
2923                ));
2924                ble_disabled_park("invalid fixed passkey").await
2925            }
2926        };
2927        for (index, bond) in initial.bonds.iter().enumerate() {
2928            if let Some(bond) = trouble_bond(bond) {
2929                match stack.add_bond_information(bond) {
2930                    Ok(()) => debug_log(format_args!("restored bond index={index} add=ok")),
2931                    Err(error) => debug_log(format_args!(
2932                        "restored bond index={index} add=FAILED error={error:?}"
2933                    )),
2934                }
2935            } else {
2936                debug_log(format_args!("restored bond index={index} decode=FAILED"));
2937            }
2938        }
2939        let store = BleStoreMutex::new(store);
2940        let runner = stack.runner();
2941        let mut peripheral = stack.peripheral();
2942        let server_result = UlcpServer::new_with_config(GapConfig::Peripheral(PeripheralConfig {
2943            name: default_device_name(),
2944            appearance: &appearance::computer::GENERIC_COMPUTER,
2945        }));
2946        let server = match server_result {
2947            Ok(server) => {
2948                debug_log(format_args!("gatt server construction=ok"));
2949                server
2950            }
2951            Err(error) => {
2952                debug_log(format_args!(
2953                    "gatt server construction=FAILED error={error:?}"
2954                ));
2955                ble_disabled_park("gatt server construction failed").await
2956            }
2957        };
2958
2959        super::panic::breadcrumb_mark(11);
2960        join(
2961            ble_runner(runner),
2962            join(
2963                pairing_timeout(&stack),
2964                join(
2965                    pairing_config_task(&stack, &store),
2966                    ble_peripheral(&stack, &store, &mut peripheral, &server),
2967                ),
2968            ),
2969        )
2970        .await;
2971        unreachable!()
2972    }
2973
2974    /// Owns the `lora_phy::LoRa` instance via the reconfigurable device
2975    /// runner. TX uses MeshCore's 32-symbol SF7 preamble; existing per-radio
2976    /// RX acquisition settings remain unchanged.
2977    #[embassy_executor::task]
2978    async fn radio_task(lora: LoraRadio) {
2979        #[cfg(not(feature = "t1000e"))]
2980        const RX_PREAMBLE: u16 = 8;
2981        // Hardware bring-up established that the LR1110 misses MeshCore-US
2982        // traffic with an 8-symbol RX setting even though the SX1262 does not.
2983        #[cfg(feature = "t1000e")]
2984        const RX_PREAMBLE: u16 = 16;
2985        // Continuous RX for now: the nRF boards are hardware-proven in
2986        // this mode, and preamble duty cycle (what the ESP32 SX1262
2987        // boards run) should land here only with its own RF validation
2988        // pass. The LR1110's 16-symbol acquisition would fall back to
2989        // continuous against the 32-symbol preamble anyway.
2990        umsh_radio_loraphy::device_runner(
2991            lora,
2992            &RADIO_CH,
2993            &DEVICE_CTL,
2994            RX_PREAMBLE,
2995            32,
2996            umsh_radio_loraphy::RxStrategy::Continuous,
2997            Some(&STATS),
2998        )
2999        .await;
3000    }
3001
3002    /// Owns the real `RADIO_CH` bundle and multiplexes it across the
3003    /// virtual per-client bundles (see `radio_mux`): per-client TX
3004    /// completion routing plus RX fan-out to every client.
3005    #[embassy_executor::task]
3006    async fn radio_mux_task() {
3007        super::radio_mux::radio_mux(
3008            &RADIO_CH,
3009            &MUX_CLIENTS,
3010            &super::radio_mux::MUX_MODE,
3011            Some(&STATS),
3012        )
3013        .await
3014    }
3015
3016    /// Owns the USB `Sender`, HDLC-encodes frames, and writes USB packets.
3017    #[embassy_executor::task]
3018    async fn output_task(
3019        mut tx: DeviceSender,
3020        wdt_report: Option<&'static str>,
3021        panic_report: Option<&'static str>,
3022    ) {
3023        // Emit the previous boot's diagnostics (watchdog capture and/or
3024        // panic message) as ASCII to the first USB reader. HDLC hosts
3025        // resynchronize past it; humans read it with a serial terminal.
3026        // Wait for DTR — the OS CDC driver drains the IN endpoint even
3027        // with no process attached, so writing before a real opener
3028        // exists would discard the report into the void.
3029        if wdt_report.is_some() || panic_report.is_some() {
3030            while !tx.dtr() {
3031                Timer::after_millis(50).await;
3032            }
3033            Timer::after_millis(300).await;
3034            for report in [wdt_report, panic_report].into_iter().flatten() {
3035                for chunk in report.as_bytes().chunks(64) {
3036                    let _ = tx.write_packet(chunk).await;
3037                }
3038            }
3039        }
3040        loop {
3041            #[cfg(feature = "ble-debug")]
3042            let outbound = match select(OUT_CH.wired.receive(), DEBUG_CH.receive()).await {
3043                Either::First(outbound) => outbound,
3044                Either::Second(line) => {
3045                    for chunk in line.as_bytes().chunks(64) {
3046                        let _ = tx.write_packet(chunk).await;
3047                    }
3048                    continue;
3049                }
3050            };
3051            #[cfg(not(feature = "ble-debug"))]
3052            let outbound = OUT_CH.wired.receive().await;
3053            if SESSION_GEN.load(Ordering::Acquire) != outbound.generation {
3054                continue;
3055            }
3056            let mut wire = [0u8; WIRE_MAX];
3057            let Ok(len) = hdlc::encode_frame(&outbound.frame, &mut wire) else {
3058                continue;
3059            };
3060            for chunk in generation_checked(wire[..len].chunks(64), outbound.generation, || {
3061                SESSION_GEN.load(Ordering::Acquire)
3062            }) {
3063                let _ = tx.write_packet(chunk).await;
3064            }
3065        }
3066    }
3067
3068    /// Owns the CDC receive half and HDLC decoder. Forwards frames and edges
3069    /// into INPUT_CH; `wait_connection` always precedes the read loop so
3070    /// a disconnected port never busy-loops.
3071    #[embassy_executor::task]
3072    async fn usb_in_task(mut rx: DeviceRescue) {
3073        loop {
3074            rx.wait_connection().await;
3075            debug_log(format_args!("usb debug attached"));
3076            let mut decoder: hdlc::Decoder<FRAME_IN_MAX> = hdlc::Decoder::new();
3077            let mut local_generation = SESSION_GEN.load(Ordering::Acquire);
3078            INPUT_CH.send(InEvent::Attached(Transport::Usb)).await;
3079            loop {
3080                let generation = SESSION_GEN.load(Ordering::Acquire);
3081                if generation != local_generation {
3082                    decoder.reset();
3083                    local_generation = generation;
3084                }
3085                let mut packet = [0u8; 64];
3086                match rx.read_packet(&mut packet).await {
3087                    Ok(0) | Err(_) => break,
3088                    Ok(len) => {
3089                        for &byte in &packet[..len] {
3090                            let Some(Ok(bytes)) = decoder.push(byte) else {
3091                                continue;
3092                            };
3093                            let mut frame = heapless::Vec::new();
3094                            let _ = frame.extend_from_slice(bytes);
3095                            INPUT_CH.send(InEvent::Frame(Transport::Usb, frame)).await;
3096                        }
3097                    }
3098                }
3099            }
3100            INPUT_CH.send(InEvent::Detached(Transport::Usb)).await;
3101        }
3102    }
3103
3104    /// Owns the framing-free protocol session: hosts the shared ULCP
3105    /// driver (`umsh_ulcp_runtime::driver::run`) — host frames,
3106    /// radio receptions, transmit completions, and every session effect
3107    /// — over this board's channel wiring and [`BoardDeviceEnv`] couplings.
3108    #[embassy_executor::task]
3109    async fn device_task(
3110        boot_reason: Status,
3111        proto_store: ProtoStore,
3112        boot_snapshot: Option<BootSnapshot>,
3113        identity_store: ProtoStore,
3114        boot_identity: Option<[u8; 32]>,
3115        identity_rng: IdentityRng,
3116        node_counters: &'static NodeCountersMutex,
3117    ) {
3118        // The retained hardware reset cause answers the first
3119        // PROP_LAST_STATUS query; attach itself never modifies it.
3120        let session = Session::new(
3121            session_config(),
3122            boot_reason,
3123            CryptoEngine::new(SoftwareAes, SoftwareSha256),
3124        );
3125        driver::run(
3126            session,
3127            boot_snapshot.as_deref(),
3128            boot_identity,
3129            DeviceRuntime {
3130                input: &INPUT_CH,
3131                radio: &SESSION_CH,
3132                ctl: &DEVICE_CTL,
3133                out: &OUT_CH,
3134                session_gen: &SESSION_GEN,
3135            },
3136            BoardDeviceEnv {
3137                proto_store,
3138                identity_store,
3139                identity_rng,
3140                node_counters,
3141                // The driver is the only receiver; the slot count is
3142                // sized for exactly that, so this cannot fail.
3143                #[cfg(feature = "cap-battery-saadc")]
3144                battery: board_power::BATTERY_ANNOUNCE
3145                    .dyn_receiver()
3146                    .expect("BATTERY_ANNOUNCE receiver slot"),
3147                #[cfg(feature = "cap-gnss")]
3148                gnss_announce: umsh_ulcp_runtime::gnss::announcer()
3149                    .expect("GNSS announcement receiver slot"),
3150            },
3151        )
3152        .await
3153    }
3154
3155    /// Backing store for the identity page's two strings, which
3156    /// [`screen::StatusModel`] only borrows.
3157    ///
3158    /// Rendered rather than stored: the node key is 32 octets and the
3159    /// page needs base58, so somewhere has to own the digits. Here is
3160    /// the display's own frame, which is the shortest life that works.
3161    #[cfg(feature = "has-display")]
3162    #[derive(Default)]
3163    struct IdentityText {
3164        hint: heapless::String<8>,
3165        address: heapless::String<{ umsh_core::base58::ENCODED_LEN }>,
3166    }
3167
3168    #[cfg(feature = "has-display")]
3169    impl IdentityText {
3170        /// The running node's address, or empty before bring-up.
3171        ///
3172        /// Read from the node rather than from the session's
3173        /// `PROP_DEV_KEY`: this is the key the device is answering to on
3174        /// the air, which is what someone comparing an address against a
3175        /// phone screen is checking.
3176        fn current() -> Self {
3177            let Some(key) = super::device_node::node_key() else {
3178                return Self::default();
3179            };
3180            use core::fmt::Write as _;
3181            let mut text = Self::default();
3182            let _ = write!(
3183                text.hint,
3184                "{}",
3185                umsh_core::NodeHint::from_public_key(&umsh_core::PublicKey(key))
3186            );
3187            for digit in umsh_core::base58::encode(&key) {
3188                let _ = text.address.push(digit as char);
3189            }
3190            text
3191        }
3192
3193        fn model(&self) -> Option<screen::IdentityModel<'_>> {
3194            if self.address.is_empty() {
3195                return None;
3196            }
3197            Some(screen::IdentityModel {
3198                hint: &self.hint,
3199                address: &self.address,
3200            })
3201        }
3202    }
3203
3204    /// The four switches the settings menu offers, as they stand now.
3205    ///
3206    /// Each is `None` on a build that cannot answer, which the renderer
3207    /// draws as no state at all rather than as "off" — a switch labeled
3208    /// with a guess is worse than one labeled with nothing.
3209    #[cfg(feature = "has-display")]
3210    fn ui_settings() -> screen::SettingsModel {
3211        screen::SettingsModel {
3212            bluetooth: Some(BLE_ENABLED.load(Ordering::Acquire)),
3213            #[cfg(feature = "cap-gnss")]
3214            gnss: Some(umsh_ulcp_runtime::gnss::enabled()),
3215            #[cfg(not(feature = "cap-gnss"))]
3216            gnss: None,
3217            #[cfg(feature = "cap-gnss")]
3218            share_location: Some(umsh_ulcp_runtime::gnss::policy().update_identity),
3219            #[cfg(not(feature = "cap-gnss"))]
3220            share_location: None,
3221            forwarding: Some(super::device_node::repeater_enabled()),
3222        }
3223    }
3224
3225    /// Everything the shared renderer draws that is not menu state.
3226    ///
3227    /// The device name and the identity text are passed in rather than
3228    /// read here: reading the name is async and the model borrows both,
3229    /// so the display task snapshots them once per frame and lends them
3230    /// to this.
3231    #[cfg(feature = "has-display")]
3232    fn ui_status<'a>(name: &'a DeviceName, identity: &'a IdentityText) -> screen::StatusModel<'a> {
3233        screen::StatusModel {
3234            device_name: core::str::from_utf8(name).unwrap_or(DEFAULT_DEVICE_NAME),
3235            settings: ui_settings(),
3236            identity: identity.model(),
3237            battery: ui_battery(),
3238            battery_mv: ui_battery_mv(),
3239            // A live host outranks discoverability: "somebody is talking
3240            // to me" is the fact worth a row, and advertising with nobody
3241            // there is the resting state the page no longer mentions.
3242            link: match BleLinkState::from_code(BLE_LINK.load(Ordering::Acquire)) {
3243                Some(BleLinkState::Attached) => screen::LinkState::Attached,
3244                Some(BleLinkState::Connected) => screen::LinkState::Connected,
3245                _ if advertising_permitted() => screen::LinkState::Advertising,
3246                // The operator's own switch, not the wired-host suppression:
3247                // "off (wired)" on a device whose Bluetooth was turned off
3248                // reads as someone else's doing.
3249                _ if !BLE_ENABLED.load(Ordering::Acquire) => screen::LinkState::Disabled,
3250                _ => screen::LinkState::OffWired,
3251            },
3252            stats: ui_stats(),
3253            bonds: BLE_BOND_COUNT.load(Ordering::Acquire),
3254            // Bluetooth off outranks everything — a lockout on a
3255            // transport that is off is not a state anyone can act on —
3256            // then lockout outranks the window: while locked out there
3257            // is no window to describe.
3258            pairing: if !BLE_ENABLED.load(Ordering::Acquire) {
3259                screen::PairingState::Closed
3260            } else if PAIRING_LOCKED_OUT.load(Ordering::Acquire) {
3261                screen::PairingState::LockedOut
3262            } else if PAIRING_MODE.load(Ordering::Acquire) {
3263                screen::PairingState::Open {
3264                    pin: match PAIRING_PIN.load(Ordering::Acquire) {
3265                        u32::MAX => None,
3266                        pin => Some(pin),
3267                    },
3268                }
3269            } else {
3270                screen::PairingState::Closed
3271            },
3272            // `None` whenever the device does not know what time it is,
3273            // which the renderer draws as nothing at all. There is
3274            // deliberately no fallback here: a placeholder would be an
3275            // indication of the current time, and a device that does not
3276            // have one must not give any.
3277            clock: umsh_hal::wall_clock::local_hhmm()
3278                .map(|(hour, minute)| screen::ClockModel { hour, minute }),
3279        }
3280    }
3281
3282    /// Radio activity for the stats page.
3283    ///
3284    /// Sampled when a frame is drawn rather than pushed: the counters move
3285    /// with every frame on the air, and waking a panel for each one would
3286    /// re-ink an e-paper display continuously to report numbers nobody is
3287    /// looking at.
3288    #[cfg(feature = "has-display")]
3289    fn ui_stats() -> screen::StatsModel {
3290        // The same ledger the host reads over ULCP, so a reset from the
3291        // phone clears this page too. `rx_frames` is everything the radio
3292        // handed up, UMSH or not, which is what the page has always meant.
3293        screen::StatsModel {
3294            tx_frames: STATS.get(Counter::TxPackets),
3295            rx_frames: STATS.get(Counter::RxPackets) + STATS.get(Counter::RxNonUmsh),
3296            rx_accepted: STATS.get(Counter::RxAccepted),
3297            forwarded: STATS.get(Counter::Forwarded),
3298            tx_power_dbm: super::device_node::tx_power_dbm(),
3299            // The ledger's scale is 0-65535 for 0-100%; the page shows
3300            // tenths of a percent, which is the range a tracker lives in.
3301            duty_permille: (u32::from(DUTY_LEDGER.usage(Instant::now().as_millis())) * 1_000
3302                / 65_535) as u16,
3303        }
3304    }
3305
3306    /// Cached battery reading for the indicator. Reads the monitor's
3307    /// published atomics rather than requesting a sample: the request path
3308    /// is single-consumer and already belongs to the ULCP driver.
3309    #[cfg(feature = "has-display")]
3310    fn ui_battery() -> screen::BatteryIndicator {
3311        #[cfg(feature = "cap-battery-saadc")]
3312        {
3313            screen::BatteryIndicator {
3314                level_percent: board_power::battery_level(),
3315                charge: Some(umsh_ux_tracker::battery::charge_class(
3316                    board_power::battery_state(),
3317                )),
3318            }
3319        }
3320        #[cfg(not(feature = "cap-battery-saadc"))]
3321        {
3322            screen::BatteryIndicator::UNKNOWN
3323        }
3324    }
3325
3326    /// Completes when the charge class or level moves, so the panel can
3327    /// redraw its indicator. Never completes on a board built without the
3328    /// SAADC monitor, which keeps the display tasks' `select` shape the
3329    /// same either way.
3330    #[cfg(feature = "has-display")]
3331    async fn battery_ui_changed() {
3332        #[cfg(feature = "cap-battery-saadc")]
3333        board_power::BATTERY_UI_CHANGED.wait().await;
3334        #[cfg(not(feature = "cap-battery-saadc"))]
3335        core::future::pending::<()>().await
3336    }
3337
3338    /// Drive the board's GNSS receiver.
3339    ///
3340    /// The whole of the per-board GNSS code: construct the UART and the
3341    /// board's power control, then hand both to the shared pump. An
3342    /// `#[embassy_executor::task]` cannot be generic, which is the only
3343    /// reason this shim exists at all — the loop it delegates to lives in
3344    /// `umsh_gnss::pump` and is common to both cargo workspaces.
3345    ///
3346    /// The receiver stays powered down until `PROP_GNSS_ENABLED` says
3347    /// otherwise, including on a board that has never been configured.
3348    #[cfg(feature = "cap-gnss")]
3349    #[embassy_executor::task]
3350    async fn gnss_task(
3351        #[allow(unused_mut)] mut uart: GnssUart,
3352        #[allow(unused_mut)] mut control: BoardGnss,
3353    ) {
3354        let Some(enable) = umsh_ulcp_runtime::gnss::EnableSource::new() else {
3355            debug_log(format_args!("gnss: enable receiver already taken"));
3356            return;
3357        };
3358
3359        // On a board whose only surviving real-time clock lives inside the
3360        // receiver, read it back before the pump takes over — otherwise a
3361        // device that was switched off knowing the time boots not knowing
3362        // it, and the clock the backup domain was kept powered to preserve
3363        // is never actually consulted.
3364        //
3365        // Gated on trust and not on `PROP_GNSS_ENABLED`, because this is a
3366        // clock operation: a device with positioning switched off still
3367        // wants to know what time it is. Waiting for the device domain is
3368        // what makes the trust flag mean the saved setting rather than the
3369        // post-reset default.
3370        #[cfg(feature = "gnss-holds-the-clock")]
3371        {
3372            umsh_ulcp_runtime::gnss::wait_configured().await;
3373            if !umsh_hal::wall_clock::is_set() && umsh_ulcp_runtime::gnss::policy().trust_time {
3374                match umsh_gnss::pump::rtc_read_once(&mut uart, &mut control, embassy_time::Delay)
3375                    .await
3376                    .and_then(|at| at.to_unix())
3377                {
3378                    Some(epoch) => {
3379                        umsh_hal::wall_clock::apply(
3380                            epoch,
3381                            umsh_hal::wall_clock::TimeSource::GnssRtc,
3382                            true,
3383                        );
3384                        debug_log(format_args!("gnss: clock restored from receiver RTC"));
3385                    }
3386                    // What a receiver whose backup domain lost power looks
3387                    // like. The device simply does not know the time.
3388                    None => debug_log(format_args!("gnss: receiver RTC had no time")),
3389                }
3390            }
3391        }
3392
3393        umsh_gnss::pump::run(
3394            uart,
3395            control,
3396            enable,
3397            umsh_ulcp_runtime::gnss::FixSink,
3398            embassy_time::Delay,
3399        )
3400        .await
3401    }
3402
3403    /// Completes at the next minute boundary, so a clock row can advance.
3404    ///
3405    /// The display layer's standing rule is that panels redraw on events
3406    /// and never on a timer, because a timer on a bistable panel is a
3407    /// battery drain that reports nothing. A clock is the one thing that
3408    /// has to move on its own, so this is the sanctioned exception — and
3409    /// it is bounded to exactly the case that needs it. It never
3410    /// completes unless the panel is already awake (`awake`) *and* the
3411    /// device knows what time it is, so a sleeping panel is never woken
3412    /// by it and a device with no clock never arms it at all. A panel
3413    /// that was asleep catches up on its next event-driven redraw.
3414    #[cfg(feature = "has-display")]
3415    async fn clock_tick(awake: bool) {
3416        if !awake {
3417            core::future::pending::<()>().await;
3418        }
3419        match umsh_hal::wall_clock::millis_to_next_minute() {
3420            Some(millis) => Timer::after_millis(u64::from(millis)).await,
3421            None => core::future::pending().await,
3422        }
3423    }
3424
3425    #[cfg(feature = "has-display")]
3426    fn ui_battery_mv() -> Option<u16> {
3427        #[cfg(feature = "cap-battery-saadc")]
3428        {
3429            board_power::battery_millivolts()
3430        }
3431        #[cfg(not(feature = "cap-battery-saadc"))]
3432        {
3433            None
3434        }
3435    }
3436
3437    #[cfg(feature = "display-epd")]
3438    fn render_ui_frame(
3439        buf: &mut [u8; display::BUF_SIZE],
3440        model: &UiModel,
3441        status: &screen::StatusModel<'_>,
3442    ) {
3443        screen::render_frame(
3444            &mut display::EpdFb(buf),
3445            &screen::Layout::EPD_200X200,
3446            model,
3447            status,
3448        );
3449    }
3450
3451    #[cfg(feature = "display-epd")]
3452    fn render_message_frame(
3453        buf: &mut [u8; display::BUF_SIZE],
3454        status: &screen::StatusModel<'_>,
3455        title: &str,
3456        detail: &str,
3457    ) {
3458        screen::render_message(
3459            &mut display::EpdFb(buf),
3460            &screen::Layout::EPD_200X200,
3461            status,
3462            title,
3463            detail,
3464        );
3465    }
3466
3467    /// Which device-domain switch a menu toggle names.
3468    ///
3469    /// The UX crate knows nothing about ULCP and the driver knows nothing
3470    /// about menus; this is the whole of the translation between them.
3471    #[cfg(feature = "has-display")]
3472    const fn ulcp_setting(id: ToggleId) -> driver::Setting {
3473        match id {
3474            ToggleId::Bluetooth => driver::Setting::Bluetooth,
3475            ToggleId::Gnss => driver::Setting::Gnss,
3476            ToggleId::ShareLocation => driver::Setting::ShareLocation,
3477            ToggleId::Forwarding => driver::Setting::Forwarding,
3478        }
3479    }
3480
3481    /// Everything this board's menu can do.
3482    ///
3483    /// A board enables the subset it can perform and navigation skips the
3484    /// rest — a submenu whose entries are all disabled is not shown at
3485    /// all, rather than opening onto a list containing only Back. Every
3486    /// display tracker in this family has Bluetooth and a radio; what
3487    /// varies is the receiver.
3488    #[cfg(feature = "has-display")]
3489    fn board_menu_items() -> MenuItems {
3490        #[allow(unused_mut)]
3491        let mut items = MenuItems::all();
3492        #[cfg(not(feature = "cap-gnss"))]
3493        {
3494            use umsh_ux_display_tracker::menu::MenuItem;
3495            items = items
3496                .without(MenuItem::GnssToggle)
3497                .without(MenuItem::ShareLocation);
3498        }
3499        items
3500    }
3501
3502    /// Owns the e-paper bus and renders the BLE menu. Input is serialized
3503    /// through the full-refresh cycle so Select can never activate an item the
3504    /// user has not yet seen on the panel.
3505    ///
3506    /// The panel is bistable, so attention lapsing never turns it off —
3507    /// it drops whatever the user was in the middle of and returns to
3508    /// the status page, so a press after walking away starts somewhere
3509    /// whose meaning is on screen.
3510    #[cfg(feature = "display-epd")]
3511    #[embassy_executor::task]
3512    async fn display_task(
3513        mut spi: Spim<'static>,
3514        mut cs: Output<'static>,
3515        mut dc: Output<'static>,
3516        mut rst: Output<'static>,
3517        mut busy: Input<'static>,
3518    ) {
3519        let mut model = UiModel::new(board_menu_items());
3520        let mut attention = Attention::new(
3521            DisplayKind::Persistent,
3522            AttentionConfig::PERSISTENT,
3523            Instant::now().as_millis(),
3524        );
3525        let mut shown = [0xff; display::BUF_SIZE];
3526        let mut next = [0xff; display::BUF_SIZE];
3527        {
3528            let name = device_name_snapshot().await;
3529            let identity = IdentityText::current();
3530            render_ui_frame(&mut next, &model, &ui_status(&name, &identity));
3531        }
3532        display::init(&mut spi, &mut cs, &mut dc, &mut rst, &mut busy).await;
3533        display::render(&mut spi, &mut cs, &mut dc, &mut busy, &next).await;
3534        shown.copy_from_slice(&next);
3535
3536        // The panel borrows five peripherals mutably; a closure capturing
3537        // all of them would conflict with the `next` buffer it draws
3538        // into, so the push stays a macro.
3539        macro_rules! push {
3540            () => {
3541                display::render_partial(&mut spi, &mut cs, &mut dc, &mut busy, &mut shown, &next)
3542                    .await
3543            };
3544        }
3545
3546        loop {
3547            // The name changes rarely but is needed by every frame this
3548            // pass might draw, including the message frames below, so it
3549            // is snapshotted once and lent out. The rest of the status is
3550            // rebuilt at each draw, since an effect handled below can
3551            // change it.
3552            let name = device_name_snapshot().await;
3553            let identity = IdentityText::current();
3554
3555            // Both holds are edge-published by other tasks, but re-deriving
3556            // them here each pass is idempotent and cannot miss an edge.
3557            let now = Instant::now().as_millis();
3558            attention.set_hold(HoldReason::Pairing, pairing_window_open(), now);
3559            attention.set_hold(HoldReason::Alert, alert_active(), now);
3560
3561            let lapse = async {
3562                match attention.next_deadline() {
3563                    Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
3564                    None => core::future::pending().await,
3565                }
3566            };
3567
3568            // True unless the arm already pushed its own frame.
3569            let mut redraw = true;
3570            match select4(
3571                UI_INPUT_CH.receive(),
3572                select4(
3573                    UI_REFRESH.wait(),
3574                    UI_NOTICE.wait(),
3575                    UI_ALERT_CHANGED.wait(),
3576                    select(battery_ui_changed(), clock_tick(attention.accepts_redraw())),
3577                ),
3578                DISPLAY_SHUTDOWN.wait(),
3579                lapse,
3580            )
3581            .await
3582            {
3583                Either4::First(input) => {
3584                    debug_log(format_args!("ui input={input:?}"));
3585                    attention.wake(Instant::now().as_millis());
3586                    match model.apply(input) {
3587                        Some(UiEffect::CheckIn) => {
3588                            super::device_node::request_beacon(
3589                                super::device_node::BeaconTrigger::Button,
3590                            );
3591                            model.set_notice(UiNotice::CheckInRequested);
3592                        }
3593                        Some(UiEffect::StartPairing) => {
3594                            render_message_frame(
3595                                &mut next,
3596                                &ui_status(&name, &identity),
3597                                "Starting",
3598                                "pairing mode...",
3599                            );
3600                            push!();
3601                            redraw = false;
3602                            PAIRING_MODE_REQUEST.signal(true);
3603                        }
3604                        Some(UiEffect::ClearBonds) => {
3605                            render_message_frame(
3606                                &mut next,
3607                                &ui_status(&name, &identity),
3608                                "Clearing",
3609                                "bonds + PIN...",
3610                            );
3611                            push!();
3612                            redraw = false;
3613                            BLE_WIPE_REQUEST.signal(());
3614                        }
3615                        Some(UiEffect::Toggle(id)) => {
3616                            // The switch is applied by the ULCP session, so
3617                            // the property, an attached host and the saved
3618                            // snapshot all see the same flip; poking the
3619                            // subsystem here would be undone by the next
3620                            // device-domain sync.
3621                            //
3622                            // Which is also why the frame is *not* drawn
3623                            // here. The session runs in another task and
3624                            // has not moved the value yet, so a redraw on
3625                            // this pass would push the old state back onto
3626                            // the panel and call it fresh. The frame that
3627                            // shows the flip is the one `UI_REFRESH` will
3628                            // drive out of `publish_dev_domain`, and on a
3629                            // bistable panel that is one partial refresh
3630                            // rather than two. Nothing else on screen
3631                            // needed this press: a notice only ever lives
3632                            // on the status page, and a toggle entry never
3633                            // does.
3634                            INPUT_CH.send(InEvent::Toggle(ulcp_setting(id))).await;
3635                            redraw = false;
3636                        }
3637                        None => {}
3638                    }
3639                }
3640                Either4::Second(refresh) => {
3641                    match refresh {
3642                        // The first three are consequences of something the
3643                        // user or their phone just did, so all of them
3644                        // count as attention.
3645                        Either4::First(()) => {
3646                            attention.wake(Instant::now().as_millis());
3647                            model.clear_notice();
3648                        }
3649                        Either4::Second(notice) => {
3650                            attention.wake(Instant::now().as_millis());
3651                            model.set_notice(notice);
3652                        }
3653                        Either4::Third(()) => {
3654                            attention.wake(Instant::now().as_millis());
3655                            if alert_active() {
3656                                render_message_frame(
3657                                    &mut next,
3658                                    &ui_status(&name, &identity),
3659                                    "Locate alert",
3660                                    "Press to stop",
3661                                );
3662                                push!();
3663                                redraw = false;
3664                            }
3665                        }
3666                        // A battery sample and a minute boundary are the
3667                        // two things here nobody asked for, so they
3668                        // redraw without counting as attention — waking
3669                        // on either would reset the lapse timer forever.
3670                        // The panel is bistable and already showing the
3671                        // old reading, so the redraw is a partial refresh
3672                        // of the indicator or the clock and little else.
3673                        Either4::Fourth(_) => {}
3674                    }
3675                }
3676                Either4::Third(()) => {
3677                    render_message_frame(
3678                        &mut next,
3679                        &ui_status(&name, &identity),
3680                        "Sleeping",
3681                        "Good night",
3682                    );
3683                    push!();
3684                    display::sleep(&mut spi, &mut cs, &mut dc).await;
3685                    DISPLAY_SHUTDOWN_DONE.signal(());
3686                    core::future::pending::<()>().await;
3687                }
3688                Either4::Fourth(()) => {
3689                    redraw = matches!(
3690                        attention.poll(Instant::now().as_millis()),
3691                        Some(Transition::Lapsed)
3692                    ) && !model.is_home();
3693                    if redraw {
3694                        model.go_home();
3695                    }
3696                }
3697            }
3698
3699            if redraw {
3700                render_ui_frame(&mut next, &model, &ui_status(&name, &identity));
3701                push!();
3702            }
3703        }
3704    }
3705
3706    /// What this board's own button means, power-off aside.
3707    ///
3708    /// A board whose only control is this button has to carry the whole
3709    /// vocabulary on it: click advances, double-click selects, and a
3710    /// 1–4 second hold released by the user goes back one entry. That is
3711    /// the only situation worth a chord recognizer — beside a pad the
3712    /// button is only what the case labels it, and
3713    /// [`back_button_task`] resolves it without one.
3714    #[cfg(all(feature = "button-nav", not(feature = "dpad-nav")))]
3715    const fn nav_input(event: ButtonEvent) -> Option<UiInput> {
3716        match event {
3717            ButtonEvent::Single => Some(UiInput::Forward),
3718            ButtonEvent::Double => Some(UiInput::Select),
3719            ButtonEvent::Long => Some(UiInput::Backward),
3720            ButtonEvent::Triple | ButtonEvent::Quad | ButtonEvent::VeryLong => None,
3721        }
3722    }
3723
3724    /// Resolves the board's nav button (active-low, pull-up) into the
3725    /// display-tracker vocabulary — see [`nav_input`] for which gestures
3726    /// mean what on this board — and powers off on a continuing
3727    /// four-second hold whatever else the button does.
3728    ///
3729    /// What a gesture means is decided by [`Gate`] at the press that
3730    /// starts it, not at the event that ends it, so a chord begun while
3731    /// something else owned the button is judged as a whole.
3732    #[cfg(all(feature = "button-nav", not(feature = "dpad-nav")))]
3733    #[embassy_executor::task]
3734    async fn button_task(mut button: Input<'static>) {
3735        const DEBOUNCE: Duration = Duration::from_millis(10);
3736        let mut fsm = ButtonFsm::new(umsh_ux_display_tracker::button_timings());
3737        let mut gate = Gate::new();
3738        let mut pressed = button.is_low();
3739        loop {
3740            let event = {
3741                let now_ms = Instant::now().as_millis();
3742                let edge_fut = async {
3743                    if pressed {
3744                        button.wait_for_high().await;
3745                        Timer::after(DEBOUNCE).await;
3746                        ButtonEdge::Release
3747                    } else {
3748                        button.wait_for_low().await;
3749                        Timer::after(DEBOUNCE).await;
3750                        ButtonEdge::Press
3751                    }
3752                };
3753                let deadline = fsm.next_deadline().unwrap_or(now_ms.saturating_add(60_000));
3754                match select(edge_fut, Timer::at(Instant::from_millis(deadline))).await {
3755                    Either::First(edge) => {
3756                        pressed = matches!(edge, ButtonEdge::Press);
3757                        if pressed {
3758                            // Read on the press edge, not at the last loop
3759                            // iteration: this task can park for a minute
3760                            // awaiting an edge, and both an alert starting
3761                            // and the panel lapsing dark happen during
3762                            // exactly such a park.
3763                            gate.set(GateReason::AlertActive, alert_active());
3764                            #[cfg(feature = "display-oled")]
3765                            gate.set(GateReason::ScreenOff, SCREEN_OFF.load(Ordering::Acquire));
3766                            gate.on_press();
3767                            // Wake on the press, not on the resolved
3768                            // gesture, so the panel is already lit while
3769                            // the user is still deciding what the press
3770                            // will become.
3771                            #[cfg(feature = "display-oled")]
3772                            UI_WAKE.signal(());
3773                        }
3774                        fsm.on_edge(edge, Instant::now().as_millis())
3775                    }
3776                    Either::Second(()) => fsm.poll(Instant::now().as_millis()),
3777                }
3778            };
3779
3780            if let Some(event) = event {
3781                match gate.disposition(event) {
3782                    // Whoever found the radio meant to silence it, not to
3783                    // navigate its menus.
3784                    Disposition::CancelAlert => INPUT_CH.send(InEvent::CancelAlert).await,
3785                    Disposition::ConsumedByWake | Disposition::Discard => {}
3786                    Disposition::Deliver => {
3787                        let input = match event {
3788                            ButtonEvent::VeryLong => {
3789                                pressed = false;
3790                                fsm = ButtonFsm::new(umsh_ux_display_tracker::button_timings());
3791                                SHUTDOWN_SIGNAL.signal(());
3792                                None
3793                            }
3794                            event => nav_input(event),
3795                        };
3796                        if let Some(input) = input {
3797                            UI_INPUT_CH.send(input).await;
3798                        }
3799                    }
3800                }
3801            }
3802
3803            gate.settle(fsm.next_deadline().is_none());
3804        }
3805    }
3806
3807    /// The Back button on a board that also has a pad (active-low,
3808    /// pull-up): press to leave the screen, hold four seconds to power
3809    /// off.
3810    ///
3811    /// Deliberately *not* [`ButtonFsm`]. The recognizer exists so a board
3812    /// whose only control is one button can carry a whole vocabulary on
3813    /// it, and it pays for that in latency: a click is not a click until
3814    /// the chord gap has passed without a second press, so every Back
3815    /// costs 400 ms — and pressing Back three times quickly to climb out
3816    /// of the tree resolves as one triple-click, which on this board
3817    /// means nothing at all. Beside a pad the button is only what the
3818    /// case labels it, so it acts on the release edge and the only other
3819    /// thing it can mean is the power-off hold.
3820    ///
3821    /// [`Gate`] still decides what a press means, by the same
3822    /// alert-cancel and wake-the-panel rules every control obeys. The
3823    /// hold passes the gate regardless: a device that has gone dark still
3824    /// has to be switchable off.
3825    #[cfg(feature = "dpad-nav")]
3826    #[embassy_executor::task]
3827    async fn back_button_task(mut button: Input<'static>) {
3828        const DEBOUNCE: Duration = Duration::from_millis(15);
3829        // Taken from the shared class timings rather than written again
3830        // here, so the hold that powers this board off is the same hold
3831        // that powers off every other board in the class.
3832        let power_off = umsh_ux_display_tracker::button_timings()
3833            .very_long_press
3834            .map_or(Duration::from_secs(4), |hold| {
3835                Duration::from_millis(hold.as_millis() as u64)
3836            });
3837        let mut gate = Gate::new();
3838        loop {
3839            button.wait_for_low().await;
3840            Timer::after(DEBOUNCE).await;
3841            if !button.is_low() {
3842                continue;
3843            }
3844
3845            gate.set(GateReason::AlertActive, alert_active());
3846            #[cfg(feature = "display-oled")]
3847            gate.set(GateReason::ScreenOff, SCREEN_OFF.load(Ordering::Acquire));
3848            gate.on_press();
3849            // Wake on the press, not on the release, so the panel is lit
3850            // while the user is still deciding how long to hold.
3851            #[cfg(feature = "display-oled")]
3852            UI_WAKE.signal(());
3853
3854            // The hold fires while still held rather than on release, so
3855            // it confirms itself while the user is committing to it.
3856            let held = match select(button.wait_for_high(), Timer::after(power_off)).await {
3857                Either::First(()) => ButtonEvent::Single,
3858                Either::Second(()) => ButtonEvent::VeryLong,
3859            };
3860
3861            match gate.disposition(held) {
3862                // Whoever found the radio meant to silence it, not to
3863                // leave the screen.
3864                Disposition::CancelAlert => INPUT_CH.send(InEvent::CancelAlert).await,
3865                Disposition::ConsumedByWake | Disposition::Discard => {}
3866                Disposition::Deliver => match held {
3867                    ButtonEvent::VeryLong => SHUTDOWN_SIGNAL.signal(()),
3868                    _ => UI_INPUT_CH.send(UiInput::Back).await,
3869                },
3870            }
3871            gate.settle(true);
3872
3873            // Whatever the press became, the button is done until it is
3874            // let go — otherwise a four-second hold would also deliver
3875            // the Back its release looks like.
3876            button.wait_for_high().await;
3877            Timer::after(DEBOUNCE).await;
3878        }
3879    }
3880
3881    /// Resolves the board's four-way pad and its center press (all
3882    /// active-low with pull-ups) into the display-tracker vocabulary.
3883    ///
3884    /// Nothing here is a chord: a pad key means one thing, so there is
3885    /// no recognizer and no timing to get wrong. [`Gate`] still decides
3886    /// what a press means, by the same alert-cancel and wake-the-panel
3887    /// rules the button obeys — a press against a dark panel lights it
3888    /// and goes no further, whichever control it arrived on.
3889    ///
3890    /// One key at a time: the task waits out the release of whichever
3891    /// key it acted on before looking at the others again, which is what
3892    /// a momentary switch means and what keeps a rocked pad from
3893    /// resolving into two directions at once.
3894    #[cfg(feature = "dpad-nav")]
3895    #[embassy_executor::task]
3896    async fn dpad_task(mut keys: [Input<'static>; 5]) {
3897        const DEBOUNCE: Duration = Duration::from_millis(15);
3898        // What each key means, in the order the pins are passed. All
3899        // four directions walk the list: the pad is one control for
3900        // moving through it, however the user happens to hold the board.
3901        // Leaving a screen is the Back button's job and nothing else's.
3902        const MEANING: [UiInput; 5] = [
3903            UiInput::Backward, // up
3904            UiInput::Forward,  // down
3905            UiInput::Backward, // left
3906            UiInput::Forward,  // right
3907            UiInput::Select,   // center
3908        ];
3909        let mut gate = Gate::new();
3910        loop {
3911            let index = {
3912                let [up, down, left, right, center] = &mut keys;
3913                select_array([
3914                    up.wait_for_low(),
3915                    down.wait_for_low(),
3916                    left.wait_for_low(),
3917                    right.wait_for_low(),
3918                    center.wait_for_low(),
3919                ])
3920                .await
3921                .1
3922            };
3923            Timer::after(DEBOUNCE).await;
3924            if !keys[index].is_low() {
3925                continue;
3926            }
3927
3928            gate.set(GateReason::AlertActive, alert_active());
3929            #[cfg(feature = "display-oled")]
3930            gate.set(GateReason::ScreenOff, SCREEN_OFF.load(Ordering::Acquire));
3931            gate.on_press();
3932            #[cfg(feature = "display-oled")]
3933            UI_WAKE.signal(());
3934            match gate.disposition(ButtonEvent::Single) {
3935                Disposition::CancelAlert => INPUT_CH.send(InEvent::CancelAlert).await,
3936                Disposition::ConsumedByWake | Disposition::Discard => {}
3937                Disposition::Deliver => UI_INPUT_CH.send(MEANING[index]).await,
3938            }
3939            gate.settle(true);
3940
3941            keys[index].wait_for_high().await;
3942            Timer::after(DEBOUNCE).await;
3943        }
3944    }
3945
3946    /// The capacitive touch button remains dedicated to the unusual e-paper
3947    /// backlight. T-Echo defines P0.11 as active-low with a pull-up: illuminate
3948    /// on a debounced low level and turn it off on the corresponding release.
3949    ///
3950    /// Deliberately outside the attention and gate models: this is a
3951    /// plain momentary light for reading a bistable panel in the dark,
3952    /// not a navigation control, so holding it neither counts as
3953    /// activity nor consumes a gesture.
3954    #[cfg(feature = "display-epd")]
3955    #[embassy_executor::task]
3956    async fn touch_task(mut touch: Input<'static>) {
3957        const DEBOUNCE: Duration = Duration::from_millis(20);
3958        loop {
3959            touch.wait_for_low().await;
3960            Timer::after(DEBOUNCE).await;
3961            if !touch.is_low() {
3962                continue;
3963            }
3964            BACKLIGHT_TOUCH.store(true, Ordering::Release);
3965            BACKLIGHT_CHANGED.signal(());
3966            debug_log(format_args!("backlight touch=true"));
3967            touch.wait_for_high().await;
3968            Timer::after(DEBOUNCE).await;
3969            BACKLIGHT_TOUCH.store(false, Ordering::Release);
3970            BACKLIGHT_CHANGED.signal(());
3971            debug_log(format_args!("backlight touch=false"));
3972        }
3973    }
3974
3975    /// Arbitrates the one bright output this board has.
3976    ///
3977    /// A locate alert outranks the touch button: the backlight is by far
3978    /// the most conspicuous thing on a T-Echo, and being conspicuous is
3979    /// the entire point of an alert. The indicator LED keeps its own
3980    /// alert blink — this adds a channel rather than moving one — and
3981    /// the touch button behaves exactly as before whenever no alert is
3982    /// running.
3983    ///
3984    /// The alert pattern is a double flash per second, which no other
3985    /// use of this pin resembles.
3986    #[cfg(feature = "display-epd")]
3987    #[embassy_executor::task]
3988    async fn backlight_task(mut backlight: Output<'static>) {
3989        const PERIOD_MS: u64 = 1_000;
3990        const STEP: Duration = Duration::from_millis(25);
3991        loop {
3992            if alert_active() {
3993                let phase = Instant::now().as_millis() % PERIOD_MS;
3994                backlight.set_level(if phase < 100 || (200..300).contains(&phase) {
3995                    Level::High
3996                } else {
3997                    Level::Low
3998                });
3999                // Poll rather than sleep to the next edge: the alert can
4000                // end at any moment and the pin must not be left lit.
4001                let _ = select(Timer::after(STEP), BACKLIGHT_CHANGED.wait()).await;
4002            } else {
4003                backlight.set_level(if BACKLIGHT_TOUCH.load(Ordering::Acquire) {
4004                    Level::High
4005                } else {
4006                    Level::Low
4007                });
4008                BACKLIGHT_CHANGED.wait().await;
4009            }
4010        }
4011    }
4012
4013    /// The panel is the same 128×64 on every OLED board in the family;
4014    /// what differs is what the user drives it with, and the gesture
4015    /// hints have to name controls the board actually has.
4016    #[cfg(feature = "display-oled")]
4017    const OLED_LAYOUT: screen::Layout = screen::Layout {
4018        #[cfg(feature = "dpad-nav")]
4019        controls: screen::Controls::Dpad,
4020        ..screen::Layout::OLED_128X64
4021    };
4022
4023    #[cfg(feature = "display-oled")]
4024    fn render_oled_frame(
4025        fb: &mut display::Sh1106Fb,
4026        model: &UiModel,
4027        status: &screen::StatusModel<'_>,
4028    ) {
4029        screen::render_frame(fb, &OLED_LAYOUT, model, status);
4030    }
4031
4032    #[cfg(feature = "display-oled")]
4033    fn render_oled_message(
4034        fb: &mut display::Sh1106Fb,
4035        status: &screen::StatusModel<'_>,
4036        title: &str,
4037        detail: &str,
4038    ) {
4039        screen::render_message(fb, &OLED_LAYOUT, status, title, detail);
4040    }
4041
4042    /// Owns the SH1106 panel and the display attention policy.
4043    ///
4044    /// The panel is emissive, so attention lapsing actually turns it off:
4045    /// full brightness for 20 s, a second-long fall into the dim warning,
4046    /// dark at 30 s. It stays lit for as long as a pairing window is open,
4047    /// because its PIN is the only place that number is shown, and for as
4048    /// long as a locate alert runs.
4049    #[cfg(feature = "display-oled")]
4050    #[embassy_executor::task]
4051    async fn oled_display_task(mut oled: display::Sh1106<'static>) {
4052        let mut model = UiModel::new(board_menu_items());
4053        let mut attention = Attention::new(
4054            DisplayKind::Emissive,
4055            AttentionConfig::EMISSIVE,
4056            Instant::now().as_millis(),
4057        );
4058        let mut fb = display::Sh1106Fb::new();
4059        oled.init().await;
4060        {
4061            let name = device_name_snapshot().await;
4062            let identity = IdentityText::current();
4063            render_oled_frame(&mut fb, &model, &ui_status(&name, &identity));
4064        }
4065        oled.flush(&fb).await;
4066
4067        loop {
4068            // The name changes rarely but every frame this pass might draw
4069            // needs it, so it is snapshotted once and lent out; the rest of
4070            // the status is rebuilt at each draw.
4071            let name = device_name_snapshot().await;
4072            let identity = IdentityText::current();
4073
4074            // Both holds are edge-published by other tasks, but re-deriving
4075            // them here each pass is idempotent and cannot miss an edge.
4076            let now = Instant::now().as_millis();
4077            attention.set_hold(HoldReason::Pairing, pairing_window_open(), now);
4078            attention.set_hold(HoldReason::Alert, alert_active(), now);
4079            SCREEN_OFF.store(attention.is_lapsed(), Ordering::Release);
4080
4081            let lapse = async {
4082                match attention.next_deadline() {
4083                    Some(deadline) => Timer::at(Instant::from_millis(deadline)).await,
4084                    None => core::future::pending().await,
4085                }
4086            };
4087
4088            let mut redraw = false;
4089            let mut alert_frame = false;
4090            let mut transition = None;
4091            match select4(
4092                UI_INPUT_CH.receive(),
4093                select4(
4094                    // All three are "content moved, redraw if the panel
4095                    // is already lit"; they differ only in what they do
4096                    // to the model, so they share an arm.
4097                    select3(
4098                        UI_REFRESH.wait(),
4099                        battery_ui_changed(),
4100                        clock_tick(attention.accepts_redraw()),
4101                    ),
4102                    UI_NOTICE.wait(),
4103                    UI_WAKE.wait(),
4104                    UI_ALERT_CHANGED.wait(),
4105                ),
4106                DISPLAY_SHUTDOWN.wait(),
4107                lapse,
4108            )
4109            .await
4110            {
4111                Either4::First(input) => {
4112                    debug_log(format_args!("ui input={input:?}"));
4113                    transition = attention.wake(Instant::now().as_millis());
4114                    redraw = true;
4115                    match model.apply(input) {
4116                        Some(UiEffect::CheckIn) => {
4117                            super::device_node::request_beacon(
4118                                super::device_node::BeaconTrigger::Button,
4119                            );
4120                            model.set_notice(UiNotice::CheckInRequested);
4121                        }
4122                        Some(UiEffect::StartPairing) => PAIRING_MODE_REQUEST.signal(true),
4123                        Some(UiEffect::ClearBonds) => BLE_WIPE_REQUEST.signal(()),
4124                        Some(UiEffect::Toggle(id)) => {
4125                            // The switch is applied by the ULCP session, so
4126                            // the property, an attached host and the saved
4127                            // snapshot all see the same flip; poking the
4128                            // subsystem here would be undone by the next
4129                            // device-domain sync.
4130                            //
4131                            // Which is also why the frame is *not* drawn
4132                            // here. The session runs in another task and
4133                            // has not moved the value yet, so a redraw on
4134                            // this pass would push the old state back onto
4135                            // the panel and call it fresh. The frame that
4136                            // shows the flip is the one `UI_REFRESH` will
4137                            // drive out of `publish_dev_domain`. Nothing
4138                            // else on screen needed this press: a notice
4139                            // only ever lives on the status page, and a
4140                            // toggle entry never does.
4141                            INPUT_CH.send(InEvent::Toggle(ulcp_setting(id))).await;
4142                            redraw = false;
4143                        }
4144                        None => {}
4145                    }
4146                }
4147                Either4::Second(event) => match event {
4148                    // Content the user did not ask for: redraw if the
4149                    // panel is already lit, but never light it. That rule
4150                    // is what keeps a battery sample from waking a tracker
4151                    // in a drawer every few minutes.
4152                    Either4::First(Either3::First(())) => {
4153                        model.clear_notice();
4154                        redraw = true;
4155                    }
4156                    // A battery sample and a minute boundary both move
4157                    // content without touching the model.
4158                    Either4::First(Either3::Second(()) | Either3::Third(())) => redraw = true,
4159                    Either4::Second(notice) => {
4160                        model.set_notice(notice);
4161                        transition = attention.wake(Instant::now().as_millis());
4162                        redraw = true;
4163                    }
4164                    // A wake on its own changes no content — a lit panel
4165                    // is already showing the truth, and the events that do
4166                    // change something raise `UI_REFRESH` alongside this.
4167                    Either4::Third(()) => {
4168                        transition = attention.wake(Instant::now().as_millis());
4169                        redraw = transition.is_some();
4170                    }
4171                    // An alert takes the whole panel: being conspicuous is
4172                    // the point, and the hold above keeps it lit until the
4173                    // alert ends.
4174                    Either4::Fourth(()) => {
4175                        transition = attention.wake(Instant::now().as_millis());
4176                        redraw = true;
4177                        alert_frame = alert_active();
4178                    }
4179                },
4180                Either4::Third(()) => {
4181                    render_oled_message(
4182                        &mut fb,
4183                        &ui_status(&name, &identity),
4184                        "Powering off",
4185                        "press to wake",
4186                    );
4187                    oled.flush(&fb).await;
4188                    oled.set_contrast(display::CONTRAST_NORMAL).await;
4189                    oled.set_display_on(true).await;
4190                    Timer::after(Duration::from_millis(1_200)).await;
4191                    oled.set_display_on(false).await;
4192                    DISPLAY_SHUTDOWN_DONE.signal(());
4193                    core::future::pending::<()>().await;
4194                }
4195                Either4::Fourth(()) => transition = attention.poll(Instant::now().as_millis()),
4196            }
4197
4198            match transition {
4199                Some(Transition::Lapsed) => {
4200                    // Waking always lands on the status page rather than
4201                    // on whatever was abandoned here.
4202                    model.go_home();
4203                    oled.set_display_on(false).await;
4204                    redraw = false;
4205                }
4206                // One step of the fall, not the whole of it: the policy
4207                // sends one of these per ramp step and says where between
4208                // the panel's two contrasts to sit. Nothing is redrawn —
4209                // a contrast write costs three bytes and leaves the
4210                // framebuffer alone, which is what makes a fade affordable
4211                // on a panel that redraws only on events.
4212                Some(Transition::Dimming) => {
4213                    oled.set_contrast(display::contrast_for(attention.brightness_permille()))
4214                        .await;
4215                    redraw = false;
4216                }
4217                Some(Transition::Woke) | None => {}
4218            }
4219
4220            if redraw && attention.accepts_redraw() {
4221                let status = ui_status(&name, &identity);
4222                if alert_frame {
4223                    render_oled_message(&mut fb, &status, "Locate alert", "Press to stop");
4224                } else {
4225                    render_oled_frame(&mut fb, &model, &status);
4226                }
4227                oled.flush(&fb).await;
4228            }
4229            // Ordered after the redraw so the panel never lights on a
4230            // stale frame.
4231            if matches!(transition, Some(Transition::Woke)) {
4232                oled.set_contrast(display::CONTRAST_NORMAL).await;
4233                oled.set_display_on(true).await;
4234            }
4235        }
4236    }
4237
4238    /// Wio Tracker L1 piezo driver. Kept as a task shim so the BSP's
4239    /// generic async runner is monomorphized in this binary.
4240    #[cfg(feature = "cap-buzzer")]
4241    #[embassy_executor::task]
4242    async fn wio_buzzer_task(pwm: SimplePwm<'static>) {
4243        umsh_bsp_wio_tracker_l1::buzzer::run(pwm).await;
4244    }
4245
4246    /// T-1000E piezo driver. Kept as a task shim so the BSP's generic async
4247    /// runner is monomorphized in this binary.
4248    #[cfg(feature = "t1000e")]
4249    #[embassy_executor::task]
4250    async fn t1000e_buzzer_task(
4251        pwm: SimplePwm<'static>,
4252        enable: Output<'static>,
4253        initially_silenced: bool,
4254    ) {
4255        umsh_bsp_t1000e::buzzer::run(pwm, enable, initially_silenced).await;
4256    }
4257
4258    /// Apply the T-1000E device profile: the unsupported single and quadruple
4259    /// slots remain inert, double-click toggles persisted Silence State,
4260    /// triple-click is reserved for unsupported GPS control, and the
4261    /// three-second long press enters persisted Sleep State. A startup-held press has already
4262    /// been consumed by the force-pairing ceremony, so it is ignored through
4263    /// its release instead of becoming an immediate shutdown.
4264    #[cfg(feature = "t1000e")]
4265    #[embassy_executor::task]
4266    async fn t1000e_button_task(
4267        mut button: Input<'static>,
4268        held_at_boot: bool,
4269        mut ux_store: ProtoStore,
4270    ) {
4271        const DEBOUNCE: Duration = Duration::from_millis(10);
4272
4273        if held_at_boot {
4274            button.wait_for_low().await;
4275            Timer::after(DEBOUNCE).await;
4276        }
4277
4278        let mut fsm = ButtonFsm::new(ButtonTimings::default());
4279        let mut pressed = false;
4280        loop {
4281            let event = {
4282                let now_ms = Instant::now().as_millis();
4283                let edge_fut = async {
4284                    if pressed {
4285                        button.wait_for_low().await;
4286                        Timer::after(DEBOUNCE).await;
4287                        ButtonEdge::Release
4288                    } else {
4289                        button.wait_for_high().await;
4290                        Timer::after(DEBOUNCE).await;
4291                        ButtonEdge::Press
4292                    }
4293                };
4294                let deadline = fsm.next_deadline().unwrap_or(now_ms.saturating_add(60_000));
4295                match select(edge_fut, Timer::at(Instant::from_millis(deadline))).await {
4296                    Either::First(edge) => {
4297                        pressed = matches!(edge, ButtonEdge::Press);
4298                        // A press means eyes on the LED and, likely, an
4299                        // environment that just changed — a device pulled
4300                        // from a pocket should not confirm at last
4301                        // minute's brightness. Re-evaluate ambient light
4302                        // now: the ~80 ms measurement completes well
4303                        // inside click recognition, so whatever
4304                        // confirmation follows renders at the fresh
4305                        // level, and at press-down the LED is almost
4306                        // certainly in a dark phase, so the sampler's
4307                        // blanking is invisible. User-initiated, so it
4308                        // deliberately bypasses the battery cadence.
4309                        if pressed {
4310                            umsh_bsp_t1000e::light::request_sample();
4311                        }
4312                        fsm.on_edge(edge, Instant::now().as_millis())
4313                    }
4314                    Either::Second(()) => fsm.poll(Instant::now().as_millis()),
4315                }
4316            };
4317
4318            // Whoever found the beeping radio gets to silence it with
4319            // whatever they press first, and that press does nothing
4320            // else — fumbling for an alarm must not fire off a beacon or
4321            // flip the silence preference. The long press is the
4322            // exception the spec allows: powering the radio off is
4323            // deliberate enough to mean it, and it ends the alert anyway.
4324            if event.is_some() && alert_active() && !matches!(event, Some(ButtonEvent::Long)) {
4325                INPUT_CH.send(InEvent::CancelAlert).await;
4326                continue;
4327            }
4328
4329            match event {
4330                Some(ButtonEvent::Single) => {
4331                    // Primary action: beacon from the device identity. The
4332                    // node task emits the confirmation (LED + melody) only
4333                    // when the MAC accepts the send; with no identity the
4334                    // node is dormant and the slot stays inert, with no
4335                    // false confirmation.
4336                    super::device_node::request_beacon(super::device_node::BeaconTrigger::Button);
4337                }
4338                Some(ButtonEvent::Double) => {
4339                    let preferences = umsh_bsp_t1000e::preferences::toggle_silent();
4340                    umsh_bsp_t1000e::BUZZER_SILENCE_SET.signal(preferences.silent);
4341                    umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL
4342                        .signal(LedSequence::ActionConfirm);
4343                    let _ = persist_ux_preferences(&mut ux_store, preferences).await;
4344                }
4345                Some(ButtonEvent::Triple) => {
4346                    // The receiver switch, which the UX profile reserves
4347                    // this slot for. Routed through the ULCP session rather
4348                    // than straight at the pins, so the property, an
4349                    // attached host and the saved snapshot all see the same
4350                    // flip — poking the driver here would be undone by the
4351                    // next device-domain sync. A build with no receiver
4352                    // leaves the slot inert, confirmation included.
4353                    //
4354                    // Nothing is indicated here: the press does not know
4355                    // which way the switch went, and the session answers
4356                    // that through `gnss_switched`.
4357                    #[cfg(feature = "cap-gnss")]
4358                    INPUT_CH.send(InEvent::Toggle(driver::Setting::Gnss)).await;
4359                }
4360                Some(ButtonEvent::Long) => {
4361                    pressed = false;
4362                    fsm = ButtonFsm::new(ButtonTimings::default());
4363                    let preferences = umsh_bsp_t1000e::preferences::set_asleep(true);
4364                    let _ = persist_ux_preferences(&mut ux_store, preferences).await;
4365                    umsh_bsp_t1000e::SHUTDOWN_SIGNAL.signal(());
4366                }
4367                Some(ButtonEvent::Quad | ButtonEvent::VeryLong) | None => {}
4368            }
4369        }
4370    }
4371
4372    #[cfg(feature = "t1000e")]
4373    #[embassy_executor::task]
4374    async fn t1000e_shutdown_task() -> ! {
4375        umsh_bsp_t1000e::shutdown::run().await
4376    }
4377
4378    #[cfg(feature = "t1000e")]
4379    #[embassy_executor::task]
4380    async fn t1000e_power_task(
4381        saadc: Peri<'static, peripherals::SAADC>,
4382        battery_pin: Peri<'static, peripherals::P0_02>,
4383        light_pin: Peri<'static, peripherals::P0_29>,
4384        sensor_rail: Output<'static>,
4385        sensor_enable: Output<'static>,
4386        external_power: Input<'static>,
4387        charge_active: Input<'static>,
4388    ) {
4389        // The BSP builds a single-channel converter per measurement — the
4390        // battery's and the light sensor's configurations have nothing in
4391        // common — so it takes the peripheral and `Irqs` rather than a
4392        // built `Saadc`. This shim is where `Irqs` is named concretely.
4393        umsh_bsp_t1000e::power::run_battery_monitor(
4394            saadc,
4395            Irqs,
4396            battery_pin,
4397            light_pin,
4398            sensor_rail,
4399            sensor_enable,
4400            external_power,
4401            charge_active,
4402        )
4403        .await;
4404    }
4405
4406    /// SenseCAP Solar battery monitor task: SAADC + active-low divider gate.
4407    /// No charge-detect / external-power GPIO (see BSP `power` module).
4408    #[cfg(feature = "board-sensecap-solar")]
4409    #[embassy_executor::task]
4410    async fn sensecap_power_task(saadc: Saadc<'static, 1>, divider_gate: Output<'static>) {
4411        umsh_bsp_sensecap_solar::power::run_battery_monitor(saadc, divider_gate).await;
4412    }
4413
4414    /// XIAO nRF52840 kit battery monitor task: SAADC plus three held
4415    /// pins. The divider is **ungated** — P0.14 is its low side and is
4416    /// driven LOW for the life of the program, because both alternatives
4417    /// exceed P0.31's absolute maximum (see the BSP `power` module). The
4418    /// BQ25100 does report its own state, so unlike the other boards here
4419    /// this one distinguishes charging from charge-complete.
4420    #[cfg(feature = "board-xiao-nrf52")]
4421    #[embassy_executor::task]
4422    async fn xiao_power_task(
4423        saadc: Saadc<'static, 1>,
4424        divider_low: Output<'static>,
4425        charge_status_n: Input<'static>,
4426        charge_current_hi: Output<'static>,
4427    ) {
4428        umsh_bsp_xiao_nrf52::power::run_battery_monitor(
4429            saadc,
4430            divider_low,
4431            charge_status_n,
4432            charge_current_hi,
4433        )
4434        .await;
4435    }
4436
4437    /// Headless System OFF for the XIAO nRF52840 kit. The sole producer
4438    /// is the BSP's protective low-battery cutoff: this board has no
4439    /// button to hold, and there is no remote power-off command in this
4440    /// firmware. Nothing is armed as a wake source either — there is
4441    /// nothing on the board to arm. See the BSP `shutdown` module.
4442    #[cfg(feature = "board-xiao-nrf52")]
4443    #[embassy_executor::task]
4444    async fn xiao_shutdown_task() -> ! {
4445        umsh_bsp_xiao_nrf52::shutdown::run().await
4446    }
4447
4448    /// T-Echo battery monitor task: SAADC only. The divider is hard-wired
4449    /// (no gate) and the charger exposes no status pin, so external power
4450    /// comes from usbregstatus (see BSP `power` module).
4451    #[cfg(feature = "board-techo")]
4452    #[embassy_executor::task]
4453    async fn techo_power_task(saadc: Saadc<'static, 1>) {
4454        umsh_bsp_techo::power::run_battery_monitor(saadc).await;
4455    }
4456
4457    /// Wio Tracker L1 battery monitor task: SAADC + **active-high**
4458    /// divider gate (P0.04 / `BAT_READ`). The charger exposes no status
4459    /// pin, so external power comes from usbregstatus (see BSP `power`
4460    /// module).
4461    #[cfg(feature = "board-wio-tracker-l1")]
4462    #[embassy_executor::task]
4463    async fn wio_power_task(saadc: Saadc<'static, 1>, divider_gate: Output<'static>) {
4464        umsh_bsp_wio_tracker_l1::power::run_battery_monitor(saadc, divider_gate).await;
4465    }
4466
4467    /// Controlled power-off for the Wio Tracker L1: blank the OLED, hold
4468    /// the radio in reset, tri-state the peripheral signal pins, and
4469    /// enter System OFF with the nav button armed as the wake source.
4470    ///
4471    /// This board has a mechanical power switch, so System OFF is a
4472    /// convenience rather than the only way to stop the drain — but it is
4473    /// still what keeps the protective low-battery cutoff from letting an
4474    /// unattended pack deep-discharge with the switch left on.
4475    ///
4476    /// Unlike the T-Echo there is no board-wide peripheral rail to drop;
4477    /// the hardware reconstruction found no equivalent of that board's
4478    /// P0.12. So, like the SenseCAP Solar (the other rail-less SX1262
4479    /// board), the radio is parked by holding RST low — driven outputs
4480    /// keep their level through System OFF — and everything else is
4481    /// tri-stated.
4482    ///
4483    /// The low-battery path diverges in one place: it leaves the divider
4484    /// connected and arms LPCOMP, so the board can come back on its own
4485    /// when the cell recharges. See the divider-gate comment below.
4486    #[cfg(feature = "system-off-wio")]
4487    #[embassy_executor::task]
4488    async fn wio_shutdown_task() -> ! {
4489        // Two producers: the nav button's four-second hold (the local
4490        // signal) and the battery monitor's protective low-voltage cutoff
4491        // (the BSP's). The teardown is the same either way except for the
4492        // battery-recovery wake, which only the cutoff asks for — a node
4493        // somebody switched off should stay off.
4494        let reason = match select(
4495            SHUTDOWN_SIGNAL.wait(),
4496            umsh_bsp_wio_tracker_l1::power::SHUTDOWN_SIGNAL.wait(),
4497        )
4498        .await
4499        {
4500            Either::First(()) => ShutdownReason::Requested,
4501            Either::Second(reason) => reason,
4502        };
4503        let battery_recovery = reason == ShutdownReason::BatteryCritical;
4504
4505        DISPLAY_SHUTDOWN.signal(());
4506        let _ = select(
4507            DISPLAY_SHUTDOWN_DONE.wait(),
4508            Timer::after(Duration::from_secs(5)),
4509        )
4510        .await;
4511
4512        // The usual trigger is the nav button's four-second hold, which
4513        // means the button is often still down right now — and it is also
4514        // the wake pin. Arming DETECT-low while it is held would wake the
4515        // chip the instant it powers off, so wait for the release first
4516        // (plus a debounce margin), the same dance the SenseCAP Solar
4517        // does with its power button.
4518        connect_input(Port::P0, 8, WakePull::Up);
4519        while !read_pin(Port::P0, 8) {
4520            Timer::after(Duration::from_millis(50)).await;
4521        }
4522        Timer::after(Duration::from_millis(50)).await;
4523
4524        // No switchable rail, so the SX1262 would otherwise keep whatever
4525        // mode it was in — typically continuous RX at milliamps — under a
4526        // System OFF that draws microamps. Holding RST (active-low) low
4527        // collapses it to its reset-state minimum.
4528        drive_pin_low(Port::P1, 7);
4529        // Battery divider gate, active-high, driven either way rather than
4530        // tri-stated — a floating FET gate is not a gate that is provably
4531        // anything, and driven levels are retained through System OFF.
4532        //
4533        // LOW disconnects the divider and its quiescent draw is provably
4534        // gone, which is what a requested power-off wants. On the
4535        // low-battery cutoff, though, that tap is the only thing LPCOMP can
4536        // watch, so it stays HIGH and the board pays the divider's ~2 µA
4537        // for the ability to wake itself when the cell comes back. On a
4538        // board that may be up a mast on a solar pack that is a trade worth
4539        // making; on a board somebody flipped off by hand it is not.
4540        if battery_recovery {
4541            drive_pin_high(Port::P0, 4);
4542        } else {
4543            drive_pin_low(Port::P0, 4);
4544        }
4545        // The L76K GNSS shares the always-on rail, so System OFF does not
4546        // reach it: its standby line (active-high wake) is the only thing
4547        // that decides whether the board's floor is microamps or the tens
4548        // of milliamps an acquiring receiver draws. The BSP has driven it
4549        // since boot and the pump leaves it wherever `PROP_GNSS_ENABLED`
4550        // last put it, so this is only the belt to that suspenders — but
4551        // it has to happen here, while the module can still act on it.
4552        //
4553        // Deliberately *not* a full teardown: the module keeps its power
4554        // and its backup domain through System OFF, which is where this
4555        // board's clock comes from on the next boot. Asking it to sleep is
4556        // the whole intent; taking anything else away would cost the time.
4557        drive_pin_low(Port::P1, 9);
4558        // Three more control lines that drive real loads. Same argument as
4559        // the divider gate above: a tri-stated gate is not a gate that is
4560        // provably off, and driven levels are retained through System OFF.
4561        drive_pin_low(Port::P1, 8); // RXEN, active-high → LNA unbiased
4562        drive_pin_low(Port::P1, 1); // user LED, active-high
4563        drive_pin_low(Port::P1, 0); // piezo
4564
4565        // OLED I²C (TWIM0):      SDA=P0.06, SCL=P0.05
4566        // Radio SPI (TWISPI1):   SCK=P0.30, MISO=P0.03, MOSI=P0.28
4567        // Radio control:         CS=P1.14, BUSY=P1.10, DIO1=P0.07
4568        //                        (RST, RXEN, LED, and piezo pinned above)
4569        // The display, radio, and battery tasks still own these pins;
4570        // direct PIN_CNF writes are deliberate here because every task is
4571        // about to lose its clock.
4572        for (port, pin) in [
4573            (Port::P0, 6u8),
4574            (Port::P0, 5u8),
4575            (Port::P0, 30u8),
4576            (Port::P0, 3u8),
4577            (Port::P0, 28u8),
4578            (Port::P1, 14u8),
4579            (Port::P1, 10u8),
4580            (Port::P0, 7u8), // radio DIO1 ← has SENSE set by async radio wait
4581        ] {
4582            tristate_pin(port, pin);
4583        }
4584
4585        if battery_recovery {
4586            // Let the tap settle: the gate went high a moment ago and
4587            // P0.31 was floating before that.
4588            Timer::after(Duration::from_millis(10)).await;
4589
4590            // Wake when the cell recovers. AIN7 is P0.31, the divider tap,
4591            // and 9/16 VDD is the right step for a *half* divider: on a
4592            // regulated 3.3 V rail the tap threshold is 1.856 V, so the
4593            // crossing is at ≈3.71 V of cell — above the firmware's Low
4594            // threshold, far above Critical (≈3.1 V), and nowhere near
4595            // re-triggering a cutoff that needs five minutes of sustained
4596            // critical anyway. (1/2 would land at 3.30 V, under Low with no
4597            // margin for sag; 5/8 at 4.13 V, essentially "only when full".)
4598            //
4599            // This board has no published schematic, so unlike the XIAO and
4600            // the Solar P1 we cannot say for certain that its rail is
4601            // regulated at 3.3 V — and the reference is VDD-relative. The
4602            // choice of fraction makes that safe rather than merely
4603            // hopeful: a half divider puts the tap at exactly 1/2 of VDD
4604            // whenever VDD tracks the cell, which is *below* 9/16, so an
4605            // unregulated rail means the comparator simply never trips —
4606            // the no-autonomous-wake status quo, never a wake loop. Bench
4607            // measurement settles which of the two this board is.
4608            arm_lpcomp_wake_up(LpcompInput::AnalogInput7, LpcompReference::Ref916vdd);
4609        }
4610
4611        // P0.08 is the nav button. Active-low, pull-up → DETECT-low wakes.
4612        // Armed on both paths, so a battery-recovery shutdown is revived by
4613        // a charge or by a press, whichever comes first.
4614        power_off(&[WakePin {
4615            port: Port::P0,
4616            pin: 8,
4617            sense: WakeSense::Low,
4618        }])
4619    }
4620
4621    /// Dedicated power-button (P1.01, active-low) state machine for the
4622    /// Solar P1. This board has a button reserved for power, so — unlike the
4623    /// single-button boards that overload one button into a gesture FSM — it
4624    /// drives *nothing but power*: a hold past `HOLD_OFF` acknowledges on
4625    /// LED_A and requests System OFF, and a short press does nothing at all.
4626    /// Everything a user might otherwise want from a press is on USR; see
4627    /// [`sensecap_usr_button_task`]. Powering back on happens by pressing USR
4628    /// while in System OFF — a PWR press there reaches the bootloader instead.
4629    #[cfg(feature = "power-button")]
4630    #[embassy_executor::task]
4631    async fn sensecap_pwr_button_task(mut button: Input<'static>) {
4632        const HOLD_OFF: Duration = Duration::from_millis(1500);
4633        const DEBOUNCE: Duration = Duration::from_millis(20);
4634
4635        // If PWR is still held when we boot, ignore it through release so the
4636        // press that started us is not misread as an immediate power-off hold.
4637        // (The force-pairing ceremony is on USR/P1.07, not this button, so it
4638        // never reaches here.)
4639        if button.is_low() {
4640            button.wait_for_high().await;
4641            Timer::after(DEBOUNCE).await;
4642        }
4643
4644        loop {
4645            button.wait_for_low().await;
4646            Timer::after(DEBOUNCE).await;
4647            if button.is_high() {
4648                continue; // bounce
4649            }
4650            // Power off only if held past HOLD_OFF; release before that is a
4651            // short press, which this button deliberately ignores.
4652            match select(button.wait_for_high(), Timer::after(HOLD_OFF)).await {
4653                Either::First(()) => {}
4654                Either::Second(()) => {
4655                    // Hold accepted: acknowledge on LED_A and wait for the
4656                    // blinks to finish before tearing the board down, or the
4657                    // teardown would cut the acknowledgement it just asked
4658                    // for. Bounded, so a wedged indicator cannot block
4659                    // powering off.
4660                    ATTENTION_LED.signal(LedSequence::PowerOff);
4661                    let _ = select(
4662                        ATTENTION_LED_DONE.wait(),
4663                        Timer::after(Duration::from_millis(1500)),
4664                    )
4665                    .await;
4666                    umsh_bsp_sensecap_solar::power::SHUTDOWN_SIGNAL
4667                        .signal(ShutdownReason::Requested);
4668                    // The shutdown task waits for PWR release before arming
4669                    // wake; park here until it powers us off.
4670                    button.wait_for_high().await;
4671                }
4672            }
4673        }
4674    }
4675
4676    /// The user button (USR / P1.07, active-low) on the Solar P1.
4677    ///
4678    /// This is the board's whole interactive surface while running — PWR
4679    /// does power and nothing else — so it carries the primary-action slot
4680    /// the UX profile describes: a press asks the device node to beacon,
4681    /// putting a signed identity (with its position, when the identity
4682    /// auto-update is on) on the air.
4683    ///
4684    /// Except while the locate alert is running, when the first press
4685    /// silences it and does nothing else. Whoever found the blinking radio
4686    /// gets to stop it with whatever they press; fumbling for it must not
4687    /// also fire off a beacon.
4688    ///
4689    /// No confirmation is emitted here. The node answers an accepted send
4690    /// through `NodeHooks::beacon_confirm`, so a board with no identity —
4691    /// where the node is dormant and the slot is genuinely inert — stays
4692    /// silent rather than acknowledging something that did not happen.
4693    #[cfg(feature = "power-button")]
4694    #[embassy_executor::task]
4695    async fn sensecap_usr_button_task(mut button: Input<'static>) {
4696        const DEBOUNCE: Duration = Duration::from_millis(20);
4697
4698        // The press that woke the board from System OFF, or the one that
4699        // ran the force-pairing ceremony, is still down. Neither is a
4700        // beacon request.
4701        if button.is_low() {
4702            button.wait_for_high().await;
4703            Timer::after(DEBOUNCE).await;
4704        }
4705
4706        loop {
4707            button.wait_for_low().await;
4708            Timer::after(DEBOUNCE).await;
4709            if button.is_high() {
4710                continue; // bounce
4711            }
4712            if alert_active() {
4713                INPUT_CH.send(InEvent::CancelAlert).await;
4714            } else {
4715                super::device_node::request_beacon(super::device_node::BeaconTrigger::Button);
4716            }
4717            // One action per press, however long it is held.
4718            button.wait_for_high().await;
4719            Timer::after(DEBOUNCE).await;
4720        }
4721    }
4722
4723    /// One-shot sequences for LED_A, the Solar P1's attention indicator.
4724    #[cfg(feature = "power-button")]
4725    static ATTENTION_LED: Signal<ThreadModeRawMutex, LedSequence> = Signal::new();
4726
4727    /// Fires when a requested sequence has finished playing, so a caller
4728    /// that is about to take the board down can let it finish.
4729    #[cfg(feature = "power-button")]
4730    static ATTENTION_LED_DONE: Signal<ThreadModeRawMutex, ()> = Signal::new();
4731
4732    /// Confirm an accepted local action on LED_A. Reachable as a plain
4733    /// `fn()` because that is the shape `NodeHooks` takes.
4734    #[cfg(feature = "power-button")]
4735    pub fn confirm_attention_action() {
4736        ATTENTION_LED.signal(LedSequence::ActionConfirm);
4737    }
4738
4739    /// Drives LED_A (P0.15, white, active-high) on the Solar P1.
4740    ///
4741    /// The board has two LEDs and gives them separate jobs. LED_B (blue) is
4742    /// the status light: heartbeat, BLE pairing blink — the "this thing is
4743    /// alive, here is its link state" story, which is worth glancing at and
4744    /// not worth looking up for. LED_A is the one meant to catch an eye
4745    /// across a field: the locate alert, and the short confirmations that
4746    /// answer a button press.
4747    ///
4748    /// It idles dark. A second heartbeat would only compete with the first.
4749    #[cfg(feature = "power-button")]
4750    #[embassy_executor::task]
4751    async fn sensecap_attention_led_task(mut led: Output<'static>) -> ! {
4752        let mut engine = LedEngine::attention_only(Instant::now().as_millis());
4753        loop {
4754            // On a board with no buzzer the blink is the entire alert, and
4755            // it outranks the confirmations inside the engine.
4756            if alert_active() {
4757                engine.start_alert(Instant::now().as_millis());
4758            } else {
4759                engine.stop_alert();
4760            }
4761
4762            let decision = engine.tick(Instant::now().as_millis());
4763            if decision.on {
4764                led.set_high();
4765            } else {
4766                led.set_low();
4767            }
4768            // Nothing pending and nothing to show: report the sequence
4769            // finished, for whoever is waiting on it before powering off.
4770            if !decision.on && !engine.alert_active() {
4771                ATTENTION_LED_DONE.signal(());
4772            }
4773
4774            match select3(
4775                Timer::at(Instant::from_millis(decision.next_deadline_ms)),
4776                ALERT_CHANGED.wait(),
4777                ATTENTION_LED.wait(),
4778            )
4779            .await
4780            {
4781                Either3::Third(sequence) => {
4782                    ATTENTION_LED_DONE.reset();
4783                    engine.play(sequence, Instant::now().as_millis());
4784                }
4785                Either3::First(()) | Either3::Second(()) => {}
4786            }
4787        }
4788    }
4789
4790    #[cfg(feature = "power-button")]
4791    #[embassy_executor::task]
4792    async fn sensecap_shutdown_task() -> ! {
4793        umsh_bsp_sensecap_solar::shutdown::run().await
4794    }
4795
4796    /// Controlled power-off: put the e-paper controller to sleep, tri-state
4797    /// peripheral signal pins, drop the rail, and enter System OFF.
4798    #[cfg(feature = "system-off-techo")]
4799    #[embassy_executor::task]
4800    async fn shutdown_task(peripheral_power: Output<'static>, power_enable: Output<'static>) -> ! {
4801        // Two producers on the T-Echo: the button's four-second hold (the
4802        // local signal) and the battery monitor's protective low-voltage
4803        // cutoff (the BSP's). Either one runs the same teardown.
4804        #[cfg(feature = "board-techo")]
4805        let _ = select(
4806            SHUTDOWN_SIGNAL.wait(),
4807            umsh_bsp_techo::power::SHUTDOWN_SIGNAL.wait(),
4808        )
4809        .await;
4810        #[cfg(not(feature = "board-techo"))]
4811        SHUTDOWN_SIGNAL.wait().await;
4812
4813        DISPLAY_SHUTDOWN.signal(());
4814        let _ = select(
4815            DISPLAY_SHUTDOWN_DONE.wait(),
4816            Timer::after(Duration::from_secs(5)),
4817        )
4818        .await;
4819
4820        // The usual trigger is the side button's four-second hold, which
4821        // means the button is often still down right now — and it is also
4822        // the wake pin. Arming DETECT-low while it is held would wake the
4823        // chip the instant it powers off, so wait for the release first
4824        // (plus a debounce margin), the same dance the SenseCAP Solar
4825        // does with its power button.
4826        connect_input(Port::P1, 10, WakePull::Up);
4827        while !read_pin(Port::P1, 10) {
4828            Timer::after(Duration::from_millis(50)).await;
4829        }
4830        Timer::after(Duration::from_millis(50)).await;
4831
4832        // Nothing below this point awaits, so the heartbeat task cannot run
4833        // again and take the status LED back.
4834        //
4835        // The LED and the e-paper backlight are the two pins still driving a
4836        // load, and driven levels are retained through System OFF. Both are
4837        // pinned to their off state rather than tri-stated: their loads hang
4838        // off the always-on rail, where a floating pin is not provably dark.
4839        // The remaining RGB channel (P0.15) is never configured by this
4840        // firmware, so it sits at reset — a disconnected input that cannot
4841        // sink the LED. (P0.13, which the Meshtastic/MeshCore variant files
4842        // call the red channel, is PWR_EN per the schematic and is handled
4843        // with the rail below.)
4844        drive_pin_high(Port::P0, 14); // status LED, active-low → high is off
4845        drive_pin_low(Port::P1, 11); // e-paper backlight, active-high
4846
4847        // The L76K GNSS. Dropping the rail below unpowers it on battery, but
4848        // not on USB: VBUS keeps VDD_POWR alive through a path PWR_EN does
4849        // not gate (hw-observed 2026-08-06), so this state must be correct
4850        // for a module that stays powered indefinitely, not just for one
4851        // about to lose its rail.
4852        //
4853        // Standby/WAKEUP (P1.02) is internally pulled up — floating means
4854        // awake — so it is driven low: a valid logic low into a powered
4855        // module (Standby, its proper low-power state) and no current into
4856        // an unpowered one. Reset (P1.05) is tri-stated, NOT driven: the
4857        // L76K hardware design has RESET_N internally pulled up ("leave
4858        // N/C if unused"), so released it idles high on a powered module —
4859        // holding it low instead pinned the powered module in reset, its
4860        // *worst* state, with the PPS pull-up faintly lighting the internal
4861        // blue LED as the tell. The UART line into the module (P1.08) is
4862        // driven low: low is a legal idle-adjacent level either way,
4863        // whereas its usual high idle would back-power a dead module.
4864        // P1.09 carries the module's output and is never driven by this
4865        // chip, so it is only released.
4866        drive_pin_low(Port::P1, 2);
4867        tristate_pin(Port::P1, 5);
4868        drive_pin_low(Port::P1, 8);
4869        tristate_pin(Port::P1, 9);
4870
4871        // E-paper SPI bus (SPIM2): SCK=P0.31, MISO=P1.07, MOSI=P0.29
4872        // E-paper control:         CS=P0.30, DC=P0.28, RST=P0.02, BUSY=P0.03
4873        // Radio SPI bus (TWISPI1): SCK=P0.19, MOSI=P0.22, MISO=P0.23
4874        // Radio control:           CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
4875        // The display and touch tasks still own these pins; direct PIN_CNF
4876        // writes are deliberate here because every task is about to lose power.
4877        for (port, pin) in [
4878            (Port::P0, 31u8),
4879            (Port::P1, 7u8),
4880            (Port::P0, 29u8),
4881            (Port::P0, 30u8),
4882            (Port::P0, 28u8),
4883            (Port::P0, 2u8),
4884            (Port::P0, 3u8),
4885            (Port::P0, 11u8), // touch input ← async wait may have set SENSE
4886            (Port::P0, 19u8),
4887            (Port::P0, 22u8),
4888            (Port::P0, 23u8),
4889            (Port::P0, 24u8),
4890            (Port::P0, 25u8),
4891            (Port::P0, 17u8),
4892            (Port::P0, 20u8), // radio DIO1 ← has SENSE set by async radio wait
4893        ] {
4894            tristate_pin(port, pin);
4895        }
4896
4897        // The rail is switched by two pins, not one: per the schematic,
4898        // SX1262 = PWR_EN (P0.13), VDD_POWR = PWR_EN ∧ (PWR_ON (P0.12)
4899        // ∨ VBUS). PWR_EN is the master gate — and because VBUS stands in
4900        // for PWR_ON, it is the only input that keeps "off" off while the
4901        // board is on USB. (The Meshtastic/MeshCore variant files call
4902        // P0.13 the red LED channel; the schematic disagrees, and it was
4903        // the schematic that explained the off-state symptom: with PWR_EN
4904        // left floating, the rail only half-collapsed, and the L76K sat
4905        // browned-up with its PPS pull-up faintly lighting the internal
4906        // blue LED.)
4907        //
4908        // Dropping the `Output`s only hands the pins back to embassy,
4909        // which writes PIN_CNF = INPUT:Disconnect with no pull — floating,
4910        // the same trap. Pin both low so the LoRa module, GNSS, sensors,
4911        // and e-paper bias generator are provably unpowered rather than
4912        // left to a floating gate.
4913        drop(peripheral_power);
4914        drop(power_enable);
4915        drive_pin_low(Port::P0, 12);
4916        drive_pin_low(Port::P0, 13);
4917
4918        // P1.10 is the side user button. Active-low, pull-up → DETECT-low wakes.
4919        power_off(&[WakePin {
4920            port: Port::P1,
4921            pin: 10,
4922            sense: WakeSense::Low,
4923        }])
4924    }
4925
4926    // ─── Main ────────────────────────────────────────────────────────────────
4927
4928    #[embassy_executor::main]
4929    async fn main(spawner: Spawner) {
4930        // Temporary freeze diagnostics: recover the previous boot's last
4931        // breadcrumb stage, then mark progress through boot. Stage map:
4932        //  1 main entered            8 USB built, core tasks spawned
4933        //  2 embassy init done       9 chirp signaled / pre-join
4934        //  3 WDT armed              10 ble_app entered
4935        //  4 radio ready            11 trouble stack + GATT server built
4936        //  5 MPSL ready             12 advertising loop reached
4937        //  6 bond store ready       13 usb.run() first polled
4938        //  7 SDC built
4939        // Point the shared runtime's log seam at this board's debug
4940        // channel, before anything shared runs. `debug_log` itself
4941        // buffers until a transport is up, so installing it this early
4942        // costs nothing and means the journal mount lines are not lost.
4943        umsh_ulcp_runtime::log::set_debug_log(debug_log);
4944
4945        // Re-power the GNSS backup domain as the very first thing this
4946        // image does, before embassy init and before any peripheral is
4947        // touched.
4948        //
4949        // nRF52840 System OFF retains driven pin levels *while it is off*,
4950        // but waking from it is a reset: GPIO returns to its disconnected
4951        // reset configuration, and stays there until something drives it
4952        // again. On this board that pin gates the only real-time clock
4953        // there is, so every millisecond between the reset and this write
4954        // is a millisecond the clock is running on whatever charge is left
4955        // on the rail. Asserting it in the normal peripheral-init block —
4956        // after the bootloader, embassy, the radio and the journal — is
4957        // far too late to expect it to survive.
4958        //
4959        // Whether it survives even from here is a question about the
4960        // bootloader's own startup time and the rail's capacitance, not
4961        // about this firmware. If it does not, the receiver comes back
4962        // reporting its own epoch, which `umsh-gnss` rejects, and the
4963        // device honestly reports that it does not know the time.
4964        #[cfg(all(feature = "gnss-holds-the-clock", feature = "t1000e"))]
4965        umsh_bsp_nrf52840::system_off::drive_pin_high(umsh_bsp_nrf52840::system_off::Port::P0, 8);
4966
4967        let (previous_crumb, previous_beats) = super::panic::breadcrumb_take();
4968        PREV_BOOT_CRUMB.store(previous_crumb, Ordering::Release);
4969        PREV_BOOT_BEATS.store(previous_beats, Ordering::Release);
4970        let wdt_capture = super::panic::wdt_capture_take();
4971        let mut pc_ring = [0u32; super::panic::PC_RING_ENTRIES];
4972        let pc_ring_count = super::panic::pc_ring_take(&mut pc_ring);
4973        PREV_RING_COUNT.store(pc_ring_count as u16, Ordering::Release);
4974        super::panic::breadcrumb_mark(1);
4975
4976        // Init heap before any alloc-using code (the device node's
4977        // bring-up allocates a small bounded amount). 8 KiB matches the
4978        // CLI firmware's budget for the same node stack.
4979        {
4980            use core::mem::MaybeUninit;
4981            const HEAP_SIZE: usize = 8192;
4982            static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
4983            unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
4984        }
4985
4986        // Crystal-less boards (XIAO-based SenseCAP Solar) run the LFCLK from
4987        // the internal RC oscillator; boards with a 32.768 kHz crystal use it.
4988        #[cfg(not(feature = "lfclk-rc"))]
4989        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::ble_config());
4990        #[cfg(feature = "lfclk-rc")]
4991        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::ble_config_lfrc());
4992        // GPIO state survives the soft reset/DFU handoff. Silence the T-1000E
4993        // piezo before any potentially lengthy radio, flash, or BLE work so a
4994        // retained PWM/enable state cannot sound until the buzzer task starts.
4995        #[cfg(feature = "t1000e")]
4996        {
4997            drive_pin_low(Port::P0, 25); // buzzer PWM input
4998            drive_pin_low(Port::P1, 5); // buzzer driver enable
4999        }
5000        super::panic::breadcrumb_mark(2);
5001        // RESETREAS survives reset. Capture and clear it before starting the
5002        // watchdog so a later host query can distinguish a watchdog reboot
5003        // from a cold start or an external reset.
5004        let hardware_reset_reasons = pac::POWER.resetreas().read();
5005        pac::POWER.resetreas().write(|reasons| reasons.0 = u32::MAX);
5006        BOOT_RESETREAS.store(hardware_reset_reasons.0, Ordering::Release);
5007        #[cfg(feature = "t1000e")]
5008        let t1000e_external_power = umsh_bsp_t1000e::power::usb_power_present();
5009        #[cfg(feature = "t1000e")]
5010        let t1000e_gpregret_state = umsh_bsp_t1000e::preferences::load_retained();
5011        #[cfg(feature = "t1000e")]
5012        let t1000e_retained_critical =
5013            t1000e_gpregret_state.is_some_and(|preferences| preferences.battery_critical);
5014        #[cfg(feature = "t1000e")]
5015        let mut t1000e_retained_state = mapped_ux_preferences().unwrap_or_default();
5016        #[cfg(feature = "t1000e")]
5017        {
5018            t1000e_retained_state.battery_critical = t1000e_retained_critical;
5019            umsh_bsp_t1000e::preferences::store(t1000e_retained_state);
5020        }
5021        #[cfg(feature = "t1000e")]
5022        if t1000e_retained_state.battery_critical && !t1000e_external_power {
5023            umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
5024        }
5025        #[cfg(feature = "t1000e")]
5026        if t1000e_retained_state.battery_critical && t1000e_external_power {
5027            umsh_bsp_t1000e::preferences::set_battery_critical(false);
5028        }
5029        // RESETREAS.OFF alone proves a button wake: P0.06 is the only GPIO
5030        // DETECT source armed at System OFF entry (USB insertion wakes via
5031        // the native VBUS detector and sets its own reason bit). The pin
5032        // itself cannot be sampled this early — PIN_CNF resets to
5033        // input-disconnected, so the IN register reads 0 regardless of the
5034        // physical level.
5035        #[cfg(feature = "t1000e")]
5036        let t1000e_wake_requested = hardware_reset_reasons.off()
5037            || (hardware_reset_reasons.sreq()
5038                && t1000e_gpregret_state.is_some_and(|preferences| !preferences.asleep));
5039        #[cfg(feature = "t1000e")]
5040        let t1000e_wake_cleared_sleep = t1000e_wake_requested && t1000e_retained_state.asleep;
5041        #[cfg(feature = "t1000e")]
5042        if t1000e_wake_requested {
5043            umsh_bsp_t1000e::preferences::set_asleep(false);
5044        } else if umsh_bsp_t1000e::preferences::load().asleep {
5045            if t1000e_external_power {
5046                let mut led_config = SimpleConfig::default();
5047                led_config.prescaler = Prescaler::Div16;
5048                let led_pwm = SimplePwm::new_1ch(p.PWM1, p.P0_24, &led_config);
5049                let sleep_button = Input::new(p.P0_06, Pull::Down);
5050                let sleep_external_power = Input::new(p.P0_05, Pull::Down);
5051                let sleep_charge_active = Input::new(p.P1_03, Pull::Up);
5052                umsh_bsp_t1000e::shutdown::run_charging_sleep(
5053                    led_pwm,
5054                    sleep_button,
5055                    sleep_external_power,
5056                    sleep_charge_active,
5057                )
5058                .await;
5059            } else {
5060                umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
5061            }
5062        }
5063
5064        // Disarm POWER USB interrupt state inherited across the DFU
5065        // handoff. The bootloader's USB stack enables the POWER
5066        // USBDETECTED/USBREMOVED/USBPWRRDY interrupts, and POWER lives
5067        // in the always-on domain, so the enables survive the DFU
5068        // activation reset. MPSL later owns the shared CLOCK_POWER
5069        // vector but services only CLOCK events, so with VBUS present a
5070        // pending USB power event re-enters the handler forever,
5071        // starving thread mode until the watchdog fires — the post-DFU
5072        // first-boot freeze. This firmware never uses these interrupts
5073        // (USB runs on SoftwareVbusDetect precisely because MPSL owns
5074        // POWER), so clear the enables and any pending events before
5075        // MPSL takes the vector.
5076        pac::POWER.intenclr().write(|w| {
5077            w.set_usbdetected(true);
5078            w.set_usbremoved(true);
5079            w.set_usbpwrrdy(true);
5080        });
5081        pac::POWER.events_usbdetected().write_value(0);
5082        pac::POWER.events_usbremoved().write_value(0);
5083        pac::POWER.events_usbpwrrdy().write_value(0);
5084
5085        // TEMPORARY freeze diagnostics: format the previous boot's
5086        // watchdog capture and PC-sample ring for the ASCII dump on the
5087        // first USB connect.
5088        let wdt_report: Option<&'static str> = (wdt_capture.is_some()
5089            || (pc_ring_count > 0 && hardware_reset_reasons.dog()))
5090        .then(|| {
5091            use core::fmt::Write as _;
5092            static REPORT: StaticCell<heapless09::String<2048>> = StaticCell::new();
5093            let report = REPORT.init(heapless09::String::new());
5094            let _ = write!(report, "\r\n=== WDT CAPTURE (previous boot) ===\r\n");
5095            if let Some(capture) = wdt_capture {
5096                PREV_WDT_PC.store(capture.pc, Ordering::Release);
5097                PREV_WDT_LR.store(capture.lr, Ordering::Release);
5098                PREV_WDT_PSR.store(capture.xpsr, Ordering::Release);
5099                let _ = write!(
5100                    report,
5101                    "pc={:#010x} lr={:#010x} psr={:#010x} exc={:#010x} sp={:#010x}\r\n",
5102                    capture.pc, capture.lr, capture.xpsr, capture.exc_return, capture.sp,
5103                );
5104                let _ = write!(
5105                    report,
5106                    "CLOCK hfstat={:#x} lfstat={:#x} inten={:#x} evhf={} evlf={} evdone={} evctto={} lfsrc={:#x}\r\n",
5107                    capture.clock[0],
5108                    capture.clock[1],
5109                    capture.clock[2],
5110                    capture.clock[3],
5111                    capture.clock[4],
5112                    capture.clock[5],
5113                    capture.clock[6],
5114                    capture.clock[7],
5115                );
5116                let _ = write!(report, "stack above frame:\r\n");
5117                for row in capture.stack.chunks(4) {
5118                    for word in row {
5119                        let _ = write!(report, "{word:#010x} ");
5120                    }
5121                    let _ = write!(report, "\r\n");
5122                }
5123            } else {
5124                let _ = write!(report, "no exception-frame capture (WDT IRQ shielded)\r\n");
5125            }
5126            let _ = write!(report, "pc ring ({pc_ring_count} samples, oldest first):\r\n");
5127            for row in pc_ring[..pc_ring_count].chunks(4) {
5128                for word in row {
5129                    let _ = write!(report, "{word:#010x} ");
5130                }
5131                let _ = write!(report, "\r\n");
5132            }
5133            let _ = write!(report, "=== END WDT CAPTURE ===\r\n");
5134            report.as_str()
5135        });
5136        #[cfg(feature = "ble-debug")]
5137        {
5138            set_security_trace_handler(Some(trouble_security_trace));
5139            set_security_diagnostic_trace_handler(Some(trouble_security_diagnostic_trace));
5140            set_connection_trace_handler(Some(trouble_connection_trace));
5141        }
5142
5143        // Board power (schematic): SX1262 = PWR_EN (P0.13); VDD_POWR =
5144        // PWR_EN ∧ (PWR_ON (P0.12) ∨ VBUS). Both must be high before the
5145        // LoRa module is addressed. PWR_EN floating happens to work — its
5146        // reset state leaks enough to run the board, which is exactly how
5147        // the half-collapsed off-state rail went unnoticed — but the
5148        // radio's supply gate deserves a driven level, not a lucky float.
5149        // Ownership of both transfers to shutdown_task.
5150        #[cfg(feature = "system-off-techo")]
5151        let peripheral_power = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
5152        #[cfg(feature = "system-off-techo")]
5153        let power_enable = Output::new(p.P0_13, Level::High, OutputDrive::Standard);
5154
5155        // On T-1000E, seize LR1110 reset before any lengthy initialization.
5156        // The user button is active-high. Holding it through power-on is the
5157        // BLE spec's physical-presence ceremony; it must not invoke the
5158        // bootloader. The runtime task suppresses this same press until release.
5159        #[cfg(feature = "t1000e")]
5160        let radio_rst = Output::new(p.P1_10, Level::Low, OutputDrive::Standard);
5161        #[cfg(feature = "t1000e")]
5162        let mut button = Input::new(p.P0_06, Pull::Down);
5163        #[cfg(feature = "t1000e")]
5164        cortex_m::asm::delay(640_000);
5165        // A short press is how a powered-off T-1000E is started normally, so
5166        // the initial HIGH level alone cannot distinguish force pairing. Only
5167        // a press still held after one second is the deliberate ceremony.
5168        #[cfg(feature = "t1000e")]
5169        let force_pairing_at_boot = if button.is_high() {
5170            match select(button.wait_for_low(), Timer::after_secs(1)).await {
5171                Either::First(()) => false,
5172                Either::Second(()) => button.is_high(),
5173            }
5174        } else {
5175            false
5176        };
5177        #[cfg(feature = "t1000e")]
5178        FORCE_PAIRING_AT_BOOT.store(force_pairing_at_boot, Ordering::Release);
5179
5180        // SenseCAP Solar: the same physical-presence ceremony, carried by the
5181        // secondary user button — enclosure "USR", P1.07, active-low.
5182        //
5183        // It cannot live on the power button (enclosure "PWR", P1.01): any
5184        // press of PWR while the node is in System OFF enters the stock
5185        // bootloader's DFU mode unconditionally — duration is irrelevant, a
5186        // bare tap does it — so that press never reaches this code. Escaping
5187        // that needs a different bootloader. The same fact makes USR the only
5188        // button that actually powers the node back on, which is what makes it
5189        // the natural carrier for a hold-through-power-on gesture.
5190        //
5191        // A wake press is how a powered-off node is started, so the level at
5192        // t=0 cannot distinguish the ceremony from an ordinary power-on — only
5193        // a press still held after one second is deliberate. The button is
5194        // claimed here rather than later because FORCE_PAIRING_AT_BOOT must be
5195        // set before the BLE store seeds PAIRING_MODE.
5196        #[cfg(feature = "power-button")]
5197        let mut usr_button = Input::new(p.P1_07, Pull::Up);
5198        #[cfg(feature = "power-button")]
5199        let mut pwr_led = Output::new(p.P0_15, Level::Low, OutputDrive::Standard);
5200        #[cfg(feature = "power-button")]
5201        cortex_m::asm::delay(640_000);
5202        #[cfg(feature = "power-button")]
5203        {
5204            let force_pairing_at_boot = if usr_button.is_low() {
5205                match select(usr_button.wait_for_high(), Timer::after_secs(1)).await {
5206                    Either::First(()) => false,
5207                    Either::Second(()) => usr_button.is_low(),
5208                }
5209            } else {
5210                false
5211            };
5212            FORCE_PAIRING_AT_BOOT.store(force_pairing_at_boot, Ordering::Release);
5213            // Acknowledge the accepted ceremony on LED_A (white, active-high)
5214            // the instant the threshold is crossed, while the user is still
5215            // holding. Without this the only feedback is the LED_B pairing
5216            // blink, which is indistinguishable from an unbonded node's — so a
5217            // gesture that silently missed looked identical to one that
5218            // worked. Two blinks, deliberately distinct from the three that
5219            // acknowledge hold-to-power-off. Runs before the WDT is armed.
5220            if force_pairing_at_boot {
5221                for _ in 0..2 {
5222                    pwr_led.set_high();
5223                    Timer::after_millis(120).await;
5224                    pwr_led.set_low();
5225                    Timer::after_millis(120).await;
5226                }
5227            }
5228        }
5229
5230        // WDT: 8 s timeout, petted by the heartbeat task every ~2 s.
5231        let mut wdt_config = WdtConfig::default();
5232        wdt_config.timeout_ticks = 32768 * 8;
5233        let (mut wdt, [wdt_handle]) =
5234            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
5235        // Freeze diagnostics: the TIMEOUT interrupt fires ~61 µs before
5236        // the watchdog reset; its handler (panic.rs `WDT`) records the
5237        // interrupted context's stacked PC/LR/xPSR into retained RAM.
5238        // Highest priority so it can preempt a storming lower-priority
5239        // handler; it runs only in the doomed final microseconds.
5240        wdt.enable_interrupt();
5241        unsafe {
5242            let mut peripherals = cortex_m::Peripherals::steal();
5243            peripherals
5244                .NVIC
5245                .set_priority(embassy_nrf::pac::Interrupt::WDT, 0);
5246            cortex_m::peripheral::NVIC::unmask(embassy_nrf::pac::Interrupt::WDT);
5247        }
5248        // Freeze diagnostics: 1 kHz PC sampler on TIMER2 (free on both
5249        // boards; MPSL owns TIMER0 only). Priority 1 — above the
5250        // thread-mode executor and MPSL's low-priority signal
5251        // processing, below MPSL's radio-critical priority 0.
5252        {
5253            let timer = pac::TIMER2;
5254            timer
5255                .mode()
5256                .write(|w| w.set_mode(pac::timer::vals::Mode::Timer));
5257            timer
5258                .bitmode()
5259                .write(|w| w.set_bitmode(pac::timer::vals::Bitmode::_32bit));
5260            timer.prescaler().write(|w| w.set_prescaler(4)); // 16 MHz / 2^4 = 1 MHz
5261            timer.cc(0).write_value(1_000); // 1 kHz sampling
5262            timer.shorts().write(|w| w.set_compare_clear(0, true));
5263            timer.intenset().write(|w| w.set_compare(0, true));
5264            timer.tasks_start().write_value(1);
5265            unsafe {
5266                let mut peripherals = cortex_m::Peripherals::steal();
5267                peripherals
5268                    .NVIC
5269                    .set_priority(embassy_nrf::pac::Interrupt::TIMER2, 1 << 5);
5270                cortex_m::peripheral::NVIC::unmask(embassy_nrf::pac::Interrupt::TIMER2);
5271            }
5272        }
5273        #[cfg(feature = "board-techo")]
5274        let led = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
5275        // SenseCAP Solar: LED_B (P0.19, blue, active-high) is the heartbeat.
5276        #[cfg(feature = "board-sensecap-solar")]
5277        let led = Output::new(p.P0_19, Level::Low, OutputDrive::Standard);
5278        // Wio Tracker L1: the user LED (D11 / P1.01) is active-high.
5279        #[cfg(feature = "board-wio-tracker-l1")]
5280        let led = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
5281        // XIAO nRF52840: blue segment of the common-anode RGB LED (P0.06),
5282        // **active-low** — Level::High is off. Blue is the status color
5283        // here (MeshCore's choice on this board); red stays free as a TX
5284        // indicator and green is the 10 kΩ leg, noticeably dimmer.
5285        #[cfg(feature = "board-xiao-nrf52")]
5286        let led = Output::new(p.P0_06, Level::High, OutputDrive::Standard);
5287        #[cfg(feature = "t1000e")]
5288        let led = {
5289            let mut config = SimpleConfig::default();
5290            config.prescaler = Prescaler::Div16;
5291            SimplePwm::new_1ch(p.PWM1, p.P0_24, &config)
5292        };
5293        // Service the watchdog throughout radio, persistence, MPSL, and BLE
5294        // initialization. Deferring this task until the final steady-state
5295        // join caused first-boot persistence to consume the entire watchdog
5296        // window and reset once before the device became usable.
5297        spawner.spawn(heartbeat(led, wdt_handle).unwrap());
5298        super::panic::breadcrumb_mark(3);
5299
5300        // A message in the panic slot means the last reset was a crash;
5301        // report that as the reset reason and keep the message text for
5302        // the first-USB-reader dump. The slot is cleared either way.
5303        let mut panic_report: Option<&'static str> = None;
5304        let boot_reason = {
5305            let mut slot = PanicSlot::new(super::panic::panic_region());
5306            if let Some(message) = slot.read() {
5307                use core::fmt::Write as _;
5308                static PANIC_REPORT: StaticCell<heapless09::String<1100>> = StaticCell::new();
5309                let text = PANIC_REPORT.init(heapless09::String::new());
5310                let _ = write!(text, "\r\n=== PANIC (previous boot) ===\r\n");
5311                for byte in message.iter().take(1000) {
5312                    let c = *byte as char;
5313                    let _ = text.push(if c.is_ascii_graphic() || c == ' ' {
5314                        c
5315                    } else {
5316                        '.'
5317                    });
5318                }
5319                let _ = write!(text, "\r\n=== END PANIC ===\r\n");
5320                panic_report = Some(text.as_str());
5321                slot.clear();
5322                Status::RESET_CRASH
5323            } else if hardware_reset_reasons.dog() {
5324                Status::RESET_WATCHDOG
5325            } else if hardware_reset_reasons.lockup() {
5326                Status::RESET_CRASH
5327            } else if hardware_reset_reasons.resetpin() {
5328                Status::RESET_EXTERNAL
5329            } else if hardware_reset_reasons.sreq() {
5330                Status::RESET_SOFTWARE
5331            } else {
5332                Status::RESET_POWER_ON
5333            }
5334        };
5335
5336        // ── SX1262 LoRa radio ────────────────────────────────────────────────
5337        // Pin assignment (T-Echo hardware, firmware-confirmed):
5338        //   SPI bus: SCK=P0.19, MOSI=P0.22, MISO=P0.23 (TWISPI1)
5339        //   CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
5340        //   DIO2: internal RF switch; DIO3: 1.8 V TCXO.
5341        #[cfg(feature = "board-techo")]
5342        {
5343            let mut cfg = SpimConfig::default();
5344            // SX1262 datasheet §8.2: max SCK = 16 MHz, Mode 0.
5345            cfg.frequency = Frequency::M16;
5346            let radio_bus = Spim::new(
5347                p.TWISPI1, Irqs, p.P0_19, // SCK
5348                p.P0_23, // MISO
5349                p.P0_22, // MOSI
5350                cfg,
5351            );
5352            let radio_cs = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
5353            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
5354
5355            let radio_rst = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
5356            let radio_dio1 = Input::new(p.P0_20, Pull::None);
5357            let radio_busy = Input::new(p.P0_17, Pull::None);
5358
5359            let iv = GenericSx126xInterfaceVariant::new(
5360                radio_rst, radio_dio1, radio_busy,
5361                None, // rf_switch_rx: DIO2 wired internally on the T-Echo module
5362                None, // rf_switch_tx: same
5363            )
5364            .unwrap();
5365
5366            let lora_config = LoraConfig {
5367                chip: Sx1262,
5368                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
5369                use_dcdc: true,
5370                rx_boost: true,
5371            };
5372
5373            // enable_public_network=false → sync word 0x1424 (private).
5374            // session_config().sync_word must match this choice.
5375            // Radio init failure degrades to a USB/BLE-only device (RF dead)
5376            // rather than a startup panic → reboot loop: a display-less
5377            // field node must stay reachable to diagnose.
5378            match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
5379                Ok(lora) => {
5380                    spawner.spawn(radio_task(lora).unwrap());
5381                }
5382                Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
5383            }
5384        }
5385
5386        // ── LR1110 LoRa radio (T-1000E) ─────────────────────────────────────
5387        #[cfg(feature = "t1000e")]
5388        {
5389            let mut cfg = SpimConfig::default();
5390            cfg.frequency = Frequency::M8;
5391            let radio_bus = Spim::new(p.TWISPI0, Irqs, p.P0_11, p.P1_08, p.P1_09, cfg);
5392            let radio_cs = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
5393            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
5394            let radio_interrupt = Input::new(p.P1_01, Pull::Down);
5395            let radio_busy = Input::new(p.P0_07, Pull::None);
5396            let iv = GenericLr1110InterfaceVariant::new(
5397                radio_rst,
5398                radio_interrupt,
5399                radio_busy,
5400                None,
5401                None,
5402            )
5403            .unwrap_or_else(|_| panic!("lr1110 iv"));
5404            let lora_config = LoraConfig {
5405                chip: Lr1110Chip::with_pa(PaSelection::Hp),
5406                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V6),
5407                use_dcdc: false,
5408                rx_boost: true,
5409                rf_switch: Some(RF_SWITCH),
5410            };
5411            match LoRa::new(Lr1110::new(radio_spi, iv, lora_config), false, Delay).await {
5412                Ok(lora) => {
5413                    spawner.spawn(radio_task(lora).unwrap());
5414                }
5415                Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
5416            }
5417        }
5418
5419        // ── SX1262 LoRa radio (SenseCAP Solar Node, XIAO nRF52840 kit) ──────
5420        // Byte-for-byte the Wio Tracker L1 SX1262 bring-up on this pin map
5421        // (external RXEN, DIO2 internal RF switch, DIO3 1.8 V TCXO):
5422        //   SPI TWISPI1 @16MHz: SCK=P1.13, MISO=P1.14, MOSI=P1.15, CS=P0.04
5423        //   RST=P0.28, BUSY=P0.29, DIO1=P0.03, RXEN=P0.05 (rf_switch_rx)
5424        //
5425        // Two boards share this block verbatim, and not by coincidence:
5426        // both are XIAO-pinout carriers around the same Wio SX1262 module,
5427        // so the wiring is identical pin for pin. The XIAO kit's copy is
5428        // additionally schematic-confirmed (Wio-SX1262 for XIAO V1.0)
5429        // rather than reconstructed from vendor firmware.
5430        //
5431        // Two carrier details worth knowing here, both from that schematic:
5432        // RESET has a 10 kΩ pull-up, so a floating pin does *not* hold the
5433        // radio down — the explicit reset below is what does. RXEN has no
5434        // pull at all, which is why it is clamped at construction rather
5435        // than left to lora-phy's first transition.
5436        #[cfg(any(feature = "board-sensecap-solar", feature = "board-xiao-nrf52"))]
5437        {
5438            let mut cfg = SpimConfig::default();
5439            cfg.frequency = Frequency::M16;
5440            let radio_bus = Spim::new(
5441                p.TWISPI1, Irqs, p.P1_13, // SCK
5442                p.P1_14, // MISO
5443                p.P1_15, // MOSI
5444                cfg,
5445            );
5446            let radio_cs = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
5447            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
5448
5449            let radio_rst = Output::new(p.P0_28, Level::High, OutputDrive::Standard);
5450            let radio_dio1 = Input::new(p.P0_03, Pull::None);
5451            let radio_busy = Input::new(p.P0_29, Pull::None);
5452            // RXEN clamped low at construction (safety contract) until
5453            // lora-phy drives it HIGH in RX / LOW in TX.
5454            let radio_rxen = Output::new(p.P0_05, Level::Low, OutputDrive::Standard);
5455
5456            let iv = GenericSx126xInterfaceVariant::new(
5457                radio_rst,
5458                radio_dio1,
5459                radio_busy,
5460                Some(radio_rxen), // rf_switch_rx
5461                None,             // rf_switch_tx: none
5462            )
5463            .unwrap();
5464
5465            let lora_config = LoraConfig {
5466                chip: Sx1262,
5467                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
5468                use_dcdc: true,
5469                rx_boost: true,
5470            };
5471
5472            // Radio init failure degrades to a USB/BLE-only device (RF dead)
5473            // rather than a startup panic → reboot loop. The SX1262 bring-up
5474            // on these pins is hardware-proven on the SenseCAP Solar
5475            // (bidirectional RF, 2026-07-23); on the XIAO kit it is still
5476            // only schematic-confirmed, which is exactly the case this
5477            // degrade-instead-of-panic path exists for.
5478            match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
5479                Ok(lora) => {
5480                    spawner.spawn(radio_task(lora).unwrap());
5481                }
5482                Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
5483            }
5484        }
5485
5486        // ── SX1262 LoRa radio (Wio Tracker L1) ──────────────────────────────
5487        // The board the Solar P1 block above was itself ported from
5488        // (external RXEN, DIO2 internal RF switch, DIO3 1.8 V TCXO):
5489        //   SPI TWISPI1 @16MHz: SCK=P0.30, MISO=P0.03, MOSI=P0.28, CS=P1.14
5490        //   RST=P1.07, BUSY=P1.10, DIO1=P0.07, RXEN=P1.08 (rf_switch_rx)
5491        #[cfg(feature = "board-wio-tracker-l1")]
5492        {
5493            let mut cfg = SpimConfig::default();
5494            cfg.frequency = Frequency::M16;
5495            let radio_bus = Spim::new(
5496                p.TWISPI1, Irqs, p.P0_30, // SCK
5497                p.P0_03, // MISO
5498                p.P0_28, // MOSI
5499                cfg,
5500            );
5501            let radio_cs = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
5502            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
5503
5504            let radio_rst = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
5505            let radio_dio1 = Input::new(p.P0_07, Pull::None);
5506            let radio_busy = Input::new(p.P1_10, Pull::None);
5507            // RXEN clamped low at construction (safety contract). Holding
5508            // the external LNA biased through a +22 dBm transmit is the
5509            // one way firmware can damage this board.
5510            let radio_rxen = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
5511
5512            let iv = GenericSx126xInterfaceVariant::new(
5513                radio_rst,
5514                radio_dio1,
5515                radio_busy,
5516                Some(radio_rxen), // rf_switch_rx
5517                None,             // rf_switch_tx: no separate TX enable pin
5518            )
5519            .unwrap();
5520
5521            let lora_config = LoraConfig {
5522                chip: Sx1262,
5523                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
5524                use_dcdc: true,
5525                rx_boost: true,
5526            };
5527
5528            // Radio init failure degrades to a USB/BLE-only device (RF dead)
5529            // rather than a startup panic → reboot loop.
5530            match LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay).await {
5531                Ok(lora) => {
5532                    spawner.spawn(radio_task(lora).unwrap());
5533                }
5534                Err(error) => debug_log(format_args!("radio init FAILED (RF disabled): {error:?}")),
5535            }
5536        }
5537
5538        // The mux is the radio runner's only client; the session (and,
5539        // later, the device node) transmit and receive through their
5540        // virtual bundles.
5541        spawner.spawn(radio_mux_task().unwrap());
5542
5543        super::panic::breadcrumb_mark(4);
5544
5545        // Device-node counter state exists in every image; the BLE
5546        // branch below attaches its journal once the shared flash is
5547        // up. Must precede device_task/bring_up, which hold references.
5548        let node_counters = init_node_counters();
5549
5550        // ── MPSL + Nordic SoftDevice Controller ────────────────────────────
5551        // MPSL owns CLOCK/POWER, RADIO, RTC0, TIMER0, TEMP, and the listed
5552        // PPI channels. embassy-time remains on RTC1; LoRa remains on SPIM1.
5553        //
5554        // MPSL, the flash driver, and the protocol journals come up in
5555        // BOTH images: the flash driver needs only the MPSL timeslot
5556        // scheduler, not the BLE controller on top of it. The `no-ble`
5557        // diagnostic build skips just the SDC/Trouble construction
5558        // below; it is then a USB-only device with identical persistence
5559        // and clock configuration.
5560        let mut rng = rng::Rng::new(p.RNG, Irqs);
5561        // Everything this RNG feeds is key material — the device identity
5562        // secret, the CSPRNG seeds, the BLE local IRK — so take the
5563        // bias-corrected output. The nRF52840 TRNG is measurably biased
5564        // towards one bit value without it; correction costs roughly
5565        // 120 µs a byte instead of 40, which at a few 32-byte draws per
5566        // boot is not worth trading entropy quality for.
5567        rng.set_bias_correction(true);
5568        #[cfg(not(feature = "no-ble"))]
5569        let mut sdc_memory = sdc::Mem::<8192>::new();
5570        let mpsl = {
5571            let mpsl_peripherals = mpsl::Peripherals::new(
5572                p.RTC0, p.TIMER0, p.TEMP, p.PPI_CH19, p.PPI_CH30, p.PPI_CH31,
5573            );
5574            // Boards with a 32.768 kHz crystal use it (20 ppm); crystal-less
5575            // boards (XIAO-based SenseCAP Solar) run MPSL's LFCLK from the
5576            // internal RC oscillator, periodically calibrated against the HF
5577            // clock (rc_ctiv = 16 → every 4 s; rc_temp_ctiv = 2 → also on
5578            // ~0.5 °C drift). Must match the embassy `lfclk_source` above.
5579            #[cfg(not(feature = "lfclk-rc"))]
5580            let lfclk = mpsl::raw::mpsl_clock_lfclk_cfg_t {
5581                source: mpsl::raw::MPSL_CLOCK_LF_SRC_XTAL as u8,
5582                rc_ctiv: 0,
5583                rc_temp_ctiv: 0,
5584                accuracy_ppm: 20,
5585                skip_wait_lfclk_started: false,
5586            };
5587            #[cfg(feature = "lfclk-rc")]
5588            let lfclk = mpsl::raw::mpsl_clock_lfclk_cfg_t {
5589                source: mpsl::raw::MPSL_CLOCK_LF_SRC_RC as u8,
5590                rc_ctiv: 16,
5591                rc_temp_ctiv: 2,
5592                accuracy_ppm: 250,
5593                skip_wait_lfclk_started: false,
5594            };
5595            static MPSL: StaticCell<MultiprotocolServiceLayer> = StaticCell::new();
5596            static TIMESLOT_MEM: StaticCell<mpsl::SessionMem<1>> = StaticCell::new();
5597            let mpsl: &'static MultiprotocolServiceLayer = MPSL.init(
5598                MultiprotocolServiceLayer::with_timeslots(
5599                    mpsl_peripherals,
5600                    Irqs,
5601                    lfclk,
5602                    TIMESLOT_MEM.init(mpsl::SessionMem::new()),
5603                )
5604                .unwrap_or_else(|_| panic!("mpsl init")),
5605            );
5606            spawner.spawn(mpsl_task(mpsl).unwrap());
5607            mpsl
5608        };
5609        super::panic::breadcrumb_mark(5);
5610        static SHARED_FLASH: StaticCell<SharedFlash> = StaticCell::new();
5611        let flash = SHARED_FLASH.init(Mutex::new(JournalFlash(nrf_mpsl::Flash::take(
5612            mpsl, p.NVMC,
5613        ))));
5614        // Mount the protocol journals before the ULCP session starts: a
5615        // stored snapshot must be restored (and the PHY re-applied) and
5616        // the persisted device identity installed before the first host
5617        // command.
5618        let (proto_store, boot_snapshot) = ProtoStore::mount(flash, proto_store::PAGE0).await;
5619        let (mut identity_store, identity_payload) =
5620            ProtoStore::mount(flash, proto_store::IDENTITY_PAGE0).await;
5621        let (ux_store, _) = ProtoStore::mount(flash, proto_store::UX_PAGE0).await;
5622        mount_node_counters(node_counters, flash).await;
5623        #[cfg(feature = "t1000e")]
5624        let mut ux_store = ux_store;
5625        #[cfg(feature = "t1000e")]
5626        if t1000e_wake_cleared_sleep {
5627            let _ =
5628                persist_ux_preferences(&mut ux_store, umsh_bsp_t1000e::preferences::load()).await;
5629        }
5630        // Both halves of the persisted keypair: the public key seeds
5631        // the session's PROP_DEV_KEY surface, the secret brings up
5632        // the device node's MAC identity.
5633        //
5634        // A device identity always exists. When the journal is empty —
5635        // a factory-fresh board, or the boot that completes a factory
5636        // reset — one is generated here and persisted before anything
5637        // can observe its absence, so identity is never a commissioning
5638        // step the operator has to perform. Installing a *specific*
5639        // identity later (`PROP_DEV_PRIVATE_KEY`) stays available and is
5640        // recovery, not setup.
5641        //
5642        // The secret comes straight from the hardware TRNG, which
5643        // blocks until the peripheral has produced each byte and is
5644        // bias-corrected above. It is deliberately not drawn from the
5645        // ChaCha20 stream seeded below: that stream is fine, but there
5646        // is no reason to put a derivation between the noise source and
5647        // a key that outlives the device's configuration.
5648        let mut identity_keys = identity_payload
5649            .as_deref()
5650            .and_then(proto_store::decode_identity);
5651        if identity_keys.is_none() {
5652            let mut secret = [0u8; 32];
5653            rng.fill_bytes(&mut secret).await;
5654            let (public, record) = driver::device_identity_record(&secret);
5655            // A persist failure is not fatal: the device runs on this
5656            // key for the current boot and generates another next time.
5657            // Reporting it matters more than refusing to boot, because
5658            // the alternative is a radio that is silently inert.
5659            match identity_store.persist(&record).await {
5660                Ok(()) => debug_log(format_args!(
5661                    "device identity generated at first boot key={}",
5662                    umsh_core::PublicKey(public)
5663                )),
5664                Err(()) => debug_log(format_args!(
5665                    "device identity generated but persist=FAILED — volatile this boot"
5666                )),
5667            }
5668            identity_keys = Some((secret, public));
5669        }
5670        let boot_identity_keys = identity_keys;
5671        // A replaced identity leaves its TX boundary behind in the
5672        // counter journal; drop it so the map cannot silt up.
5673        if let Some((_, public)) = boot_identity_keys.as_ref() {
5674            prune_stale_tx_counters(node_counters, public).await;
5675        }
5676        // Seed the identity-generation and device-node CSPRNGs from the
5677        // TRNG; in the BLE image this must happen while the peripheral
5678        // is still ours — build_sdc below hands the RNG to the
5679        // SoftDevice Controller for its lifetime.
5680        let mut identity_seed = [0u8; 32];
5681        rng.fill_bytes(&mut identity_seed).await;
5682        let identity_rng = <IdentityRng as rand_core::SeedableRng>::from_seed(identity_seed);
5683        let mut node_seed = [0u8; 32];
5684        rng.fill_bytes(&mut node_seed).await;
5685        // The Node Management cursor nonce. Drawn from the TRNG for the
5686        // same reason as the seeds above and at the same moment, while
5687        // the peripheral is still ours: it is what keeps a cursor issued
5688        // before a reboot from being honored after one.
5689        let mut admin_nonce = [0u8; 2];
5690        rng.fill_bytes(&mut admin_nonce).await;
5691        let admin_nonce = u16::from_be_bytes(admin_nonce);
5692
5693        #[cfg(not(feature = "no-ble"))]
5694        let (controller, ble_store) = {
5695            let mut ble_store = BleStore::mount(flash).await;
5696            // Deliberate recovery image for hardware testing. This runs before the
5697            // Trouble host is constructed, so there is no live bond table to keep
5698            // in sync: the empty persisted snapshot becomes the host's initial
5699            // state below. Preserve the device's local IRK, matching the normal
5700            // security-wipe operation.
5701            #[cfg(feature = "ble-wipe-on-boot")]
5702            {
5703                debug_log(format_args!(
5704                    "ONE-TIME BLE WIPE begin bonds={} pin={}",
5705                    ble_store.snapshot().bonds.len(),
5706                    ble_store.snapshot().pin.is_some(),
5707                ));
5708                ble_store
5709                    .clear_security()
5710                    .await
5711                    .unwrap_or_else(|_| panic!("one-time ble wipe failed"));
5712                debug_log(format_args!(
5713                    "ONE-TIME BLE WIPE complete bonds={} pin={}",
5714                    ble_store.snapshot().bonds.len(),
5715                    ble_store.snapshot().pin.is_some(),
5716                ));
5717            }
5718            BLE_BONDS_AT_BOOT.store(ble_store.snapshot().bonds.len() as u8, Ordering::Release);
5719            set_bond_count(ble_store.snapshot().bonds.len() as u8);
5720            // See the matching seed in `ble_app` for why
5721            // `boot-pairing-window` boards force this true every boot.
5722            PAIRING_MODE.store(
5723                ble_store.snapshot().bonds.is_empty()
5724                    || FORCE_PAIRING_AT_BOOT.load(Ordering::Acquire)
5725                    || cfg!(feature = "boot-pairing-window"),
5726                Ordering::Release,
5727            );
5728
5729            let sdc_peripherals = sdc::Peripherals::new(
5730                p.PPI_CH17, p.PPI_CH18, p.PPI_CH20, p.PPI_CH21, p.PPI_CH22, p.PPI_CH23, p.PPI_CH24,
5731                p.PPI_CH25, p.PPI_CH26, p.PPI_CH27, p.PPI_CH28, p.PPI_CH29,
5732            );
5733            if ble_store.snapshot().local_irk.is_none() {
5734                let mut local_irk = [0u8; 16];
5735                rng.fill_bytes(&mut local_irk).await;
5736                if local_irk == [0; 16] {
5737                    local_irk[0] = 1;
5738                }
5739                ble_store
5740                    .set_local_irk(local_irk)
5741                    .await
5742                    .unwrap_or_else(|_| panic!("local irk persist"));
5743            }
5744            #[cfg(feature = "ble-store-fault-inject")]
5745            {
5746                BLE_STORE_FAULT_ARMED.store(true, Ordering::Release);
5747                debug_log(format_args!(
5748                    "STORE FAULT INJECTION ARMED: all runtime writes and erases will fail"
5749                ));
5750            }
5751            super::panic::breadcrumb_mark(6);
5752            let controller = build_sdc(sdc_peripherals, &mut rng, mpsl, &mut sdc_memory)
5753                .unwrap_or_else(|_| panic!("sdc init"));
5754            super::panic::breadcrumb_mark(7);
5755            (controller, ble_store)
5756        };
5757        // The session surfaces only the public key; the secret stays with
5758        // the device node.
5759        let boot_identity = boot_identity_keys.map(|(_secret, public)| public);
5760
5761        // ── USB stack ────────────────────────────────────────────────────────
5762        // HardwareVbusDetect cannot share POWER with MPSL. This tethered device
5763        // treats USB as present/ready; CDC connection state still supplies the
5764        // protocol attach/detach edges used by advertising arbitration.
5765        static VBUS: StaticCell<SoftwareVbusDetect> = StaticCell::new();
5766        let vbus = VBUS.init(SoftwareVbusDetect::new(true, true));
5767        let driver = Driver::new(p.USBD, Irqs, &*vbus);
5768
5769        let mut config = Config::new(0x16c0, 0x27dd);
5770        config.manufacturer = Some("UMSH");
5771        #[cfg(feature = "board-techo")]
5772        {
5773            config.product = Some("T-Echo UMSH Radio");
5774            config.serial_number = Some("techo");
5775        }
5776        #[cfg(feature = "t1000e")]
5777        {
5778            config.product = Some("T-1000E UMSH Radio");
5779            config.serial_number = Some("t1000e");
5780        }
5781        #[cfg(feature = "board-sensecap-solar")]
5782        {
5783            config.product = Some("Solar Node UMSH Radio");
5784            config.serial_number = Some("sensecap-solar");
5785        }
5786        #[cfg(feature = "board-wio-tracker-l1")]
5787        {
5788            config.product = Some("Wio Tracker UMSH Radio");
5789            config.serial_number = Some("wio-tracker-l1");
5790        }
5791        #[cfg(feature = "board-xiao-nrf52")]
5792        {
5793            config.product = Some("XIAO nRF52 UMSH Radio");
5794            config.serial_number = Some("xiao-nrf52");
5795        }
5796        config.max_power = 100;
5797        config.max_packet_size_0 = 64;
5798
5799        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
5800        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
5801        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
5802        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
5803        static STATE: StaticCell<State> = StaticCell::new();
5804
5805        let mut builder = Builder::new(
5806            driver,
5807            config,
5808            CONFIG_DESC.init([0; 256]),
5809            BOS_DESC.init([0; 256]),
5810            MSOS_DESC.init([0; 0]),
5811            CONTROL_BUF.init([0; 64]),
5812        );
5813
5814        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
5815        let mut usb = builder.build();
5816
5817        let (tx, raw_rx, ctrl) = class.split_with_control();
5818        let rx = CdcAcmRescue::new(raw_rx, ctrl);
5819
5820        spawner.spawn(output_task(tx, wdt_report, panic_report).unwrap());
5821        spawner.spawn(usb_in_task(rx).unwrap());
5822        spawner.spawn(
5823            device_task(
5824                boot_reason,
5825                proto_store,
5826                boot_snapshot,
5827                identity_store,
5828                boot_identity,
5829                identity_rng,
5830                node_counters,
5831            )
5832            .unwrap(),
5833        );
5834
5835        // ── Device node ─────────────────────────────────────────────────────
5836        // The device identity always exists by this point, so the full
5837        // MAC/node stack always comes up on mux client B; whether it
5838        // transmits is a matter of configuration (the PHY enable state
5839        // and the forwarding switch), not of whether a key was ever
5840        // provisioned. The airtime hint is the worst case at the
5841        // MeshCore-US default profile — the MAC scheduler only uses it as
5842        // a conservative bound.
5843        //
5844        // The one exception is a crash reboot: skip one boot of the
5845        // device node so the surviving boot stays reachable and prints
5846        // the previous panic over USB.
5847        let (identity_secret, _public) = boot_identity_keys
5848            .as_ref()
5849            .expect("a device identity is generated at boot when none is stored");
5850        if panic_report.is_none() {
5851            let t_frame_ms = umsh_radio_loraphy::airtime_ms(
5852                lora_phy::mod_params::SpreadingFactor::_7,
5853                lora_phy::mod_params::Bandwidth::_62KHz,
5854                umsh_radio_loraphy::MAX_PAYLOAD,
5855            );
5856            super::device_node::bring_up(
5857                spawner,
5858                identity_secret,
5859                node_seed,
5860                t_frame_ms,
5861                node_counters,
5862                &INPUT_CH,
5863                admin_nonce,
5864            )
5865            .await;
5866        }
5867        super::panic::breadcrumb_mark(8);
5868
5869        // The touch button only asks for the e-paper backlight; a locate
5870        // alert can ask for it too, so the pin belongs to the arbiter
5871        // rather than to either caller. Menu input is exclusively the
5872        // side button below.
5873        #[cfg(feature = "board-techo")]
5874        {
5875            let touch = Input::new(p.P0_11, Pull::Up);
5876            let backlight = Output::new(p.P1_11, Level::Low, OutputDrive::Standard);
5877            spawner.spawn(touch_task(touch).unwrap());
5878            spawner.spawn(backlight_task(backlight).unwrap());
5879
5880            let mut display_config = SpimConfig::default();
5881            display_config.frequency = Frequency::M4;
5882            let display_spi = Spim::new(p.SPI2, Irqs, p.P0_31, p.P1_07, p.P0_29, display_config);
5883            let display_cs = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
5884            let display_dc = Output::new(p.P0_28, Level::Low, OutputDrive::Standard);
5885            let display_reset = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
5886            let display_busy = Input::new(p.P0_03, Pull::None);
5887            spawner.spawn(
5888                display_task(
5889                    display_spi,
5890                    display_cs,
5891                    display_dc,
5892                    display_reset,
5893                    display_busy,
5894                )
5895                .unwrap(),
5896            );
5897
5898            let button = Input::new(p.P1_10, Pull::Up);
5899            spawner.spawn(button_task(button).unwrap());
5900            spawner.spawn(shutdown_task(peripheral_power, power_enable).unwrap());
5901
5902            // Quectel L76K on UARTE0. `BufferedUarte` rather than a plain
5903            // one because NMEA arrives as lines of unpredictable length:
5904            // a plain read would block until its buffer filled, holding a
5905            // complete sentence hostage to the start of the next one.
5906            //
5907            // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
5908            // TIMER0 and PPI 19/30/31, the softdevice controller holds
5909            // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
5910            let mut gnss_config = UarteConfig::default();
5911            gnss_config.baudrate = UarteBaudrate::Baud9600;
5912            static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
5913            static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
5914            let gnss_uart = BufferedUarte::new(
5915                p.UARTE0,
5916                p.TIMER1,
5917                p.PPI_CH0,
5918                p.PPI_CH1,
5919                p.PPI_GROUP0,
5920                // rxd, then txd. Measured, not taken from the variant
5921                // files: the module's TX — the line carrying NMEA — is
5922                // P1.09, the opposite of what the upstream pin names
5923                // suggest. See docs/hardware/lilygo-techo-hardware.md.
5924                p.P1_09,
5925                p.P1_08,
5926                Irqs,
5927                gnss_config,
5928                GNSS_RX.init([0; 256]),
5929                // Nothing is sent to this receiver: the L76K needs no
5930                // configuration to emit what UMSH reads. The buffer is
5931                // the smallest the driver will take.
5932                GNSS_TX.init([0; 16]),
5933            );
5934            spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_02, p.P1_05)).unwrap());
5935        }
5936
5937        #[cfg(feature = "t1000e")]
5938        {
5939            let mut buzzer_config = SimpleConfig::default();
5940            buzzer_config.prescaler = Prescaler::Div16;
5941            let buzzer_pwm = SimplePwm::new_1ch(p.PWM0, p.P0_25, &buzzer_config);
5942            let buzzer_enable = Output::new(p.P1_05, Level::Low, OutputDrive::Standard);
5943            let initial_preferences = umsh_bsp_t1000e::preferences::load();
5944            spawner.spawn(
5945                t1000e_buzzer_task(buzzer_pwm, buzzer_enable, initial_preferences.silent).unwrap(),
5946            );
5947            // The normal power-on chirp is intentional. Early startup already
5948            // forced both buzzer pins low, so this is the first and only sound.
5949            umsh_bsp_t1000e::BUZZER_SIGNAL.signal(&buzzer_melodies::POWER_ON);
5950
5951            let sensor_rail = Output::new(p.P1_06, Level::Low, OutputDrive::Standard);
5952            // The light sensor's own enable, downstream of the rail.
5953            let sensor_enable = Output::new(p.P0_04, Level::Low, OutputDrive::Standard);
5954            // AIN0 the battery divider, AIN5 the ambient light sensor. The
5955            // two are never wanted at the same instant and want opposite
5956            // converter configurations, so the BSP builds a single-channel
5957            // `Saadc` per measurement rather than scanning both.
5958            let external_power = Input::new(p.P0_05, Pull::Down);
5959            let charge_active = Input::new(p.P1_03, Pull::Up);
5960            spawner.spawn(
5961                t1000e_power_task(
5962                    p.SAADC,
5963                    p.P0_02,
5964                    p.P0_29,
5965                    sensor_rail,
5966                    sensor_enable,
5967                    external_power,
5968                    charge_active,
5969                )
5970                .unwrap(),
5971            );
5972            spawner.spawn(t1000e_button_task(button, force_pairing_at_boot, ux_store).unwrap());
5973            spawner.spawn(t1000e_shutdown_task().unwrap());
5974
5975            // VRTC, main enable, sleep interrupt, reset, RTC interrupt,
5976            // and the stop line. All six matter: the receiver stays silent
5977            // if the last two are left floating.
5978            #[allow(unused_mut)]
5979            let mut gnss_control =
5980                BoardGnss::new(p.P0_08, p.P1_11, p.P1_12, p.P1_15, p.P0_15, p.P1_14);
5981
5982            // Airoha AG3335 on UARTE0, at 115200 rather than the L76K
5983            // boards' 9600. `BufferedUarte` rather than a plain one because
5984            // NMEA arrives as lines of unpredictable length: a plain read
5985            // would block until its buffer filled, holding a complete
5986            // sentence hostage to the start of the next one.
5987            //
5988            // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds TIMER0
5989            // and PPI 19/30/31, the softdevice controller holds 17/18 and
5990            // 20–29, and the freeze diagnostics hold TIMER2.
5991            let mut gnss_config = UarteConfig::default();
5992            gnss_config.baudrate = UarteBaudrate::Baud115200;
5993            // Twice the L76K boards' buffer. The same sentences arrive
5994            // twelve times faster here, and an overrun costs a whole fix
5995            // cycle rather than a sentence.
5996            static GNSS_RX: StaticCell<[u8; 512]> = StaticCell::new();
5997            // Big enough for the whole wake command in one pass, so
5998            // enabling the optional sentences is not several round trips.
5999            static GNSS_TX: StaticCell<[u8; 64]> = StaticCell::new();
6000            let gnss_uart = BufferedUarte::new(
6001                p.UARTE0,
6002                p.TIMER1,
6003                p.PPI_CH0,
6004                p.PPI_CH1,
6005                p.PPI_GROUP0,
6006                // rxd, then txd. Unlike the T-Echo, the upstream names here
6007                // agree with the electrical direction: `GPS_RX_PIN` is the
6008                // MCU's RX, and P0.14 carries NMEA. That is the same
6009                // reading of `GPS_RX_PIN` that turned out to be correct on
6010                // the T-Echo once its contradictory `PIN_SERIAL1_*` names
6011                // were discarded. See docs/hardware/t1000e-hardware.md.
6012                p.P0_14,
6013                p.P0_13,
6014                Irqs,
6015                gnss_config,
6016                GNSS_RX.init([0; 512]),
6017                // Used: the AG3335 persists its NMEA output selection, so
6018                // the BSP re-enables GSA and GSV on every wake. See
6019                // `umsh_bsp_t1000e::gnss`.
6020                GNSS_TX.init([0; 64]),
6021            );
6022            spawner.spawn(gnss_task(gnss_uart, gnss_control).unwrap());
6023        }
6024
6025        // SenseCAP Solar battery monitor: SAADC on AIN7/P0.31, resistor
6026        // divider gated by P0.14 (active-low). Mirrors the T-1000E SAADC
6027        // configuration (12-bit, GAIN1_6, 0.6 V ref) so the BSP conversion
6028        // constant is comparable. No charge-detect / external-power GPIO
6029        // (the CN3165 exposes none); VBUS presence comes from usbregstatus.
6030        #[cfg(feature = "board-sensecap-solar")]
6031        {
6032            let saadc = Saadc::new(
6033                p.SAADC,
6034                Irqs,
6035                SaadcConfig::default(),
6036                [ChannelConfig::single_ended(p.P0_31)],
6037            );
6038            let divider_gate = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
6039            spawner.spawn(sensecap_power_task(saadc, divider_gate).unwrap());
6040
6041            // Quectel L76K on UARTE0, behind the one enable in this family
6042            // that really cuts the module's power — which on a solar node
6043            // is the whole point. `BufferedUarte` rather than a plain one
6044            // because NMEA arrives as lines of unpredictable length: a
6045            // plain read would block until its buffer filled, holding a
6046            // complete sentence hostage to the start of the next one.
6047            //
6048            // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
6049            // TIMER0 and PPI 19/30/31, the softdevice controller holds
6050            // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
6051            #[cfg(feature = "cap-gnss")]
6052            {
6053                let mut gnss_config = UarteConfig::default();
6054                gnss_config.baudrate = UarteBaudrate::Baud9600;
6055                static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
6056                static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
6057                let gnss_uart = BufferedUarte::new(
6058                    p.UARTE0,
6059                    p.TIMER1,
6060                    p.PPI_CH0,
6061                    p.PPI_CH1,
6062                    p.PPI_GROUP0,
6063                    // rxd, then txd. The `GPS_TX_PIN` / `GPS_RX_PIN` names
6064                    // on this board are the same trap as everywhere else in
6065                    // the family; measured, the module's output is P1.12 —
6066                    // the family rule that `GPS_RX_PIN` is the MCU's RX.
6067                    // See docs/hardware/sensecap-solar-node-p1-pro-hardware.md.
6068                    p.P1_12,
6069                    p.P1_11,
6070                    Irqs,
6071                    gnss_config,
6072                    GNSS_RX.init([0; 256]),
6073                    // Nothing is sent to this receiver: the L76K needs no
6074                    // configuration to emit what UMSH reads. The buffer is
6075                    // the smallest the driver will take.
6076                    GNSS_TX.init([0; 16]),
6077                );
6078                spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_05, p.P0_02)).unwrap());
6079            }
6080        }
6081
6082        // XIAO nRF52840 kit peripherals. Same SAADC channel and the same
6083        // physical 1M/510k network as the SenseCAP Solar above, but the
6084        // low side is *not* a gate: P0.14 is created LOW and stays LOW
6085        // forever, because driving it high sits P0.31 exactly at its
6086        // VDD+0.3 absolute maximum and releasing it takes P0.31 to the
6087        // full cell voltage. Seeed's own wiki documents the rule; the
6088        // shipping Meshtastic build for this board violates it.
6089        //
6090        // The BQ25100 adds what the other boards here lack: HICHG (P0.13,
6091        // LOW = 100 mA) and ~CHG (P0.17, open-drain, LOW while charging).
6092        // ~CHG shares its node with the red charge LED, so it is an input
6093        // and nothing else. Both pins are handed to the monitor so they
6094        // stay asserted for the life of the program.
6095        //
6096        // No button task and no force-pairing ceremony: a stock kit has
6097        // no user button at all (the carrier's K1 footprint ships bare),
6098        // so this board is headless by construction. The shutdown task
6099        // still runs, but only the low-battery cutoff can reach it, and
6100        // it arms no wake source.
6101        #[cfg(feature = "board-xiao-nrf52")]
6102        {
6103            let saadc = Saadc::new(
6104                p.SAADC,
6105                Irqs,
6106                SaadcConfig::default(),
6107                [ChannelConfig::single_ended(p.P0_31)],
6108            );
6109            let divider_low = Output::new(p.P0_14, Level::Low, OutputDrive::Standard);
6110            let charge_status_n = Input::new(p.P0_17, Pull::None);
6111            // 100 mA. Sensible for anything above ~500 mAh, but it is a
6112            // 1C-plus rate for a small cell — the kit ships without one,
6113            // so the pack is whatever the user attached.
6114            let charge_current_hi = Output::new(p.P0_13, Level::Low, OutputDrive::Standard);
6115            spawner.spawn(
6116                xiao_power_task(saadc, divider_low, charge_status_n, charge_current_hi).unwrap(),
6117            );
6118            spawner.spawn(xiao_shutdown_task().unwrap());
6119        }
6120
6121        // T-Echo battery monitor: SAADC on AIN2/P0.04. Same SAADC
6122        // configuration as the other two boards (12-bit, GAIN1_6, 0.6 V
6123        // ref), so only the BSP divider constant differs — this board's
6124        // 150k/150k bridge is hard-wired, with no gate pin to own.
6125        #[cfg(feature = "board-techo")]
6126        {
6127            let saadc = Saadc::new(
6128                p.SAADC,
6129                Irqs,
6130                SaadcConfig::default(),
6131                [ChannelConfig::single_ended(p.P0_04)],
6132            );
6133            spawner.spawn(techo_power_task(saadc).unwrap());
6134        }
6135
6136        // Wio Tracker L1 peripherals: SH1106 OLED on TWIM0, nav button,
6137        // piezo, and the SAADC battery monitor on AIN7/P0.31 behind the
6138        // active-high divider gate on P0.04.
6139        #[cfg(feature = "board-wio-tracker-l1")]
6140        {
6141            // TWIM EasyDMA reads from SRAM, so the driver needs a static
6142            // scratch buffer; one frame page plus the control byte is the
6143            // largest transfer it makes.
6144            static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new();
6145            let i2c = embassy_nrf::twim::Twim::new(
6146                p.TWISPI0,
6147                Irqs,
6148                p.P0_06, // SDA
6149                p.P0_05, // SCL
6150                embassy_nrf::twim::Config::default(),
6151                TWIM_BUF.init([0; 256]),
6152            );
6153            spawner.spawn(oled_display_task(display::Sh1106::new(i2c)).unwrap());
6154
6155            let mut buzzer_config = SimpleConfig::default();
6156            buzzer_config.prescaler = Prescaler::Div16;
6157            let buzzer_pwm = SimplePwm::new_1ch(p.PWM0, p.P1_00, &buzzer_config);
6158            spawner.spawn(wio_buzzer_task(buzzer_pwm).unwrap());
6159
6160            let saadc = Saadc::new(
6161                p.SAADC,
6162                Irqs,
6163                SaadcConfig::default(),
6164                [ChannelConfig::single_ended(p.P0_31)],
6165            );
6166            let divider_gate = Output::new(p.P0_04, Level::Low, OutputDrive::Standard);
6167            spawner.spawn(wio_power_task(saadc, divider_gate).unwrap());
6168
6169            // Every button on this board is active-low with a pull-up
6170            // (MeshCore configures all six as INPUT_PULLUP). D13 / P0.08
6171            // is the one the case labels Back, and is where the
6172            // four-second power-off hold lives.
6173            let button = Input::new(p.P0_08, Pull::Up);
6174            spawner.spawn(back_button_task(button).unwrap());
6175            // The four-way pad and its center press: D25–D29, in the
6176            // order `dpad_task` reads them.
6177            spawner.spawn(
6178                dpad_task([
6179                    Input::new(p.P1_04, Pull::Up), // D25, up
6180                    Input::new(p.P0_12, Pull::Up), // D26, down
6181                    Input::new(p.P0_11, Pull::Up), // D27, left
6182                    Input::new(p.P1_03, Pull::Up), // D28, right
6183                    Input::new(p.P1_05, Pull::Up), // D29, press
6184                ])
6185                .unwrap(),
6186            );
6187            spawner.spawn(wio_shutdown_task().unwrap());
6188
6189            // Quectel L76K on UARTE0. `BufferedUarte` rather than a plain
6190            // one because NMEA arrives as lines of unpredictable length:
6191            // a plain read would block until its buffer filled, holding a
6192            // complete sentence hostage to the start of the next one.
6193            //
6194            // TIMER1 and PPI 0/1 with group 0 are free — MPSL holds
6195            // TIMER0 and PPI 19/30/31, the softdevice controller holds
6196            // 17/18 and 20–29, and the freeze diagnostics hold TIMER2.
6197            #[cfg(feature = "cap-gnss")]
6198            {
6199                let mut gnss_config = UarteConfig::default();
6200                gnss_config.baudrate = UarteBaudrate::Baud9600;
6201                static GNSS_RX: StaticCell<[u8; 256]> = StaticCell::new();
6202                static GNSS_TX: StaticCell<[u8; 16]> = StaticCell::new();
6203                let gnss_uart = BufferedUarte::new(
6204                    p.UARTE0,
6205                    p.TIMER1,
6206                    p.PPI_CH0,
6207                    p.PPI_CH1,
6208                    p.PPI_GROUP0,
6209                    // rxd, then txd. The board notes contradict themselves
6210                    // about which of D6/D7 carries NMEA; measured, it is
6211                    // P0.26 — the family rule that `GPS_RX_PIN` is the
6212                    // MCU's RX, which now holds on all four boards. See
6213                    // docs/hardware/seeed-wio-tracker-l1-pro-hardware.md.
6214                    p.P0_26,
6215                    p.P0_27,
6216                    Irqs,
6217                    gnss_config,
6218                    GNSS_RX.init([0; 256]),
6219                    // Nothing is sent to this receiver: the L76K needs no
6220                    // configuration to emit what UMSH reads. The buffer is
6221                    // the smallest the driver will take.
6222                    GNSS_TX.init([0; 16]),
6223                );
6224                spawner.spawn(gnss_task(gnss_uart, BoardGnss::new(p.P1_09)).unwrap());
6225            }
6226        }
6227
6228        // Dedicated power button (enclosure "PWR", P1.01) + System OFF
6229        // teardown. LED_A (P0.15, white) is the power-off acknowledgement
6230        // blinker, claimed early because the force-pairing ceremony also
6231        // blinks it; the heartbeat keeps LED_B (P0.19). P1.01 is the physical
6232        // power button (MeshCore's PIN_USER_BTN); P1.07 (enclosure "USR") is
6233        // the secondary user button and carries the force-pairing gesture.
6234        #[cfg(feature = "power-button")]
6235        {
6236            let pwr_button = Input::new(p.P1_01, Pull::Up);
6237            spawner.spawn(sensecap_pwr_button_task(pwr_button).unwrap());
6238            // LED_A passes from the boot ceremony to the task that owns it
6239            // for the rest of the run.
6240            spawner.spawn(sensecap_attention_led_task(pwr_led).unwrap());
6241            spawner.spawn(sensecap_usr_button_task(usr_button).unwrap());
6242            spawner.spawn(sensecap_shutdown_task().unwrap());
6243        }
6244
6245        #[cfg(not(feature = "t1000e"))]
6246        drop(ux_store);
6247
6248        super::panic::breadcrumb_mark(9);
6249        #[cfg(not(feature = "no-ble"))]
6250        join(ble_app(controller, ble_store), async {
6251            super::panic::breadcrumb_mark(13);
6252            usb.run().await
6253        })
6254        .await;
6255        #[cfg(feature = "no-ble")]
6256        {
6257            super::panic::breadcrumb_mark(13);
6258            usb.run().await;
6259        }
6260    }
6261
6262    // ─── Heartbeat + WDT pet ─────────────────────────────────────────────────
6263
6264    #[cfg(not(feature = "t1000e"))]
6265    #[embassy_executor::task]
6266    async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
6267        let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
6268        loop {
6269            wdt.pet();
6270            super::panic::breadcrumb_beat();
6271            // The locate alert outranks every other use of the LED,
6272            // including the pairing blink: someone is looking for this
6273            // board right now, and on a board with no buzzer the blink
6274            // is the entire alert.
6275            //
6276            // Not on the Solar P1, which has a second LED. There the alert
6277            // belongs on LED_A (white) alongside the other things meant to
6278            // be seen from a distance, and this one stays the status light
6279            // — see `sensecap_attention_led_task`.
6280            #[cfg(not(feature = "power-button"))]
6281            if alert_active() {
6282                engine.start_alert(Instant::now().as_millis());
6283            } else {
6284                engine.stop_alert();
6285            }
6286            // The pairing blink yields to the alert on a one-LED board,
6287            // where they would otherwise be fighting over the same pin. On
6288            // the Solar P1 they are on different LEDs and can both run.
6289            #[cfg(not(feature = "power-button"))]
6290            let alert_holds_the_led = alert_active();
6291            #[cfg(feature = "power-button")]
6292            let alert_holds_the_led = false;
6293            // The pairing blink is a claim about the transport: with Bluetooth
6294            // off there is no window to advertise, so mode 1 reads as idle. The
6295            // wipe notice (2) is a completed act and stays.
6296            let ble_mode = match BLE_LED_MODE.load(Ordering::Acquire) {
6297                1 if !BLE_ENABLED.load(Ordering::Acquire) => 0,
6298                mode => mode,
6299            };
6300            if ble_mode != 0 && !alert_holds_the_led {
6301                let phase = Instant::now().as_millis() % 2_000;
6302                let on = if ble_mode == 1 {
6303                    phase < 100 || (500..600).contains(&phase)
6304                } else {
6305                    phase < 100 || (200..300).contains(&phase) || (400..500).contains(&phase)
6306                };
6307                #[cfg(feature = "led-active-low")]
6308                if on {
6309                    led.set_low();
6310                } else {
6311                    led.set_high();
6312                }
6313                #[cfg(not(feature = "led-active-low"))]
6314                if on {
6315                    led.set_high();
6316                } else {
6317                    led.set_low();
6318                }
6319                Timer::after_millis(50).await;
6320                continue;
6321            }
6322            let decision = engine.tick(Instant::now().as_millis());
6323            // Active-low (T-Echo P0.14) inverts; active-high (Solar P1 LED_B
6324            // P0.19) drives directly.
6325            #[cfg(feature = "led-active-low")]
6326            if decision.on {
6327                led.set_low()
6328            } else {
6329                led.set_high()
6330            }
6331            #[cfg(not(feature = "led-active-low"))]
6332            if decision.on {
6333                led.set_high()
6334            } else {
6335                led.set_low()
6336            }
6337            // An alert edge must reach the LED without waiting out the
6338            // heartbeat's multi-second deadline. A `Signal` has one useful
6339            // consumer, so on the Solar P1 this arm is gone entirely and
6340            // `ALERT_CHANGED` belongs to the attention LED that shows the
6341            // alert — two waiters would leave whichever registered first
6342            // asleep through the edge.
6343            #[cfg(not(feature = "power-button"))]
6344            let _ = select(
6345                Timer::at(Instant::from_millis(decision.next_deadline_ms)),
6346                ALERT_CHANGED.wait(),
6347            )
6348            .await;
6349            #[cfg(feature = "power-button")]
6350            Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
6351        }
6352    }
6353
6354    /// Ambient-sampling cadence for indicator dimming while on battery.
6355    /// Room light changes on the scale of minutes, and every sample
6356    /// cycles the sensor rail through 800 conversions, so once a minute
6357    /// is as often as the battery should pay for it.
6358    #[cfg(feature = "t1000e")]
6359    const AMBIENT_INTERVAL_BATTERY: Duration = Duration::from_secs(60);
6360
6361    /// Cadence on external power, where the energy is free and the
6362    /// indicator can follow changing light closely.
6363    #[cfg(feature = "t1000e")]
6364    const AMBIENT_INTERVAL_EXTERNAL: Duration = Duration::from_secs(10);
6365
6366    /// Written brightness (permille) at or below which the indicator is
6367    /// dark enough that the sampler's ~80 ms LED blackout is invisible.
6368    /// Wide enough that the charging breathe spends a comfortable window
6369    /// under it around each trough.
6370    #[cfg(feature = "t1000e")]
6371    const AMBIENT_NEAR_DARK_PERMILLE: u16 = 20;
6372
6373    /// Request an ambient light sample when one is due and the duty just
6374    /// written is near-dark.
6375    ///
6376    /// The LED task drives the cadence because only it knows the
6377    /// animation phase: a request made at a dark phase lets the
6378    /// sampler's blanking handshake confirm against an LED that is
6379    /// already off, so the measurement never visibly interrupts what the
6380    /// indicator is showing — the charging breathe in particular. Every
6381    /// state has dark phases (heartbeat gap, breathing trough, blink
6382    /// gaps), so sampling is never starved for a window.
6383    ///
6384    /// Fire-and-forget: the result is read back from
6385    /// [`ambient_millilux`](umsh_bsp_t1000e::light::ambient_millilux)
6386    /// on a later iteration, so this task keeps servicing the blanking
6387    /// handshake while the measurement runs.
6388    #[cfg(feature = "t1000e")]
6389    fn maybe_request_ambient_sample(last_sample: &mut Option<Instant>, brightness_permille: u16) {
6390        if brightness_permille > AMBIENT_NEAR_DARK_PERMILLE {
6391            return;
6392        }
6393        // Evaluated against the current power source on every
6394        // opportunity, so an unplug can never carry the fast external
6395        // cadence onto the battery.
6396        let interval = if umsh_bsp_t1000e::power::usb_power_present() {
6397            AMBIENT_INTERVAL_EXTERNAL
6398        } else {
6399            AMBIENT_INTERVAL_BATTERY
6400        };
6401        if last_sample.is_none_or(|taken| taken.elapsed() >= interval) {
6402            *last_sample = Some(Instant::now());
6403            umsh_bsp_t1000e::light::request_sample();
6404        }
6405    }
6406
6407    /// Write one LED duty, honouring the ambient-light blanking gate.
6408    ///
6409    /// Every duty write on this board goes through here so no future
6410    /// indicator state can accidentally light the LED during a
6411    /// measurement. The confirmation is raised **after** the write, so
6412    /// the sampler is told the LED is dark only once it actually is.
6413    #[cfg(feature = "t1000e")]
6414    fn write_led_duty(led: &mut SimplePwm<'static>, duty: u16) {
6415        let blanking = umsh_bsp_t1000e::indicator::blank_requested();
6416        let duty = if blanking { 0 } else { duty };
6417        led.set_duty(0, DutyCycle::inverted(duty));
6418        if blanking {
6419            umsh_bsp_t1000e::indicator::confirm_blanked();
6420        }
6421    }
6422
6423    #[cfg(feature = "t1000e")]
6424    #[embassy_executor::task]
6425    async fn heartbeat(mut led: SimplePwm<'static>, mut wdt: WatchdogHandle) -> ! {
6426        led.set_period(1_000);
6427        led.enable();
6428        let mut engine = T1000eLedEngine::new(Instant::now().as_millis());
6429        // `None` at boot, so the first dark phase — within the first
6430        // heartbeat interval — takes the first reading.
6431        let mut last_ambient_sample: Option<Instant> = None;
6432        loop {
6433            wdt.pet();
6434            super::panic::breadcrumb_beat();
6435            let battery = umsh_bsp_t1000e::battery_state();
6436            engine.set_battery(battery);
6437            engine.set_attention(umsh_bsp_t1000e::indicator::attention_requested());
6438            engine.set_ambient_millilux(umsh_bsp_t1000e::light::ambient_millilux());
6439            // Outranks the pairing blink, the battery states, and the
6440            // one-shot sequences alike (see `T1000eLedEngine::tick`).
6441            if alert_active() {
6442                engine.start_alert(Instant::now().as_millis());
6443            } else {
6444                engine.stop_alert();
6445            }
6446
6447            // As above: a pairing blink on a transport that is off is a lie.
6448            let ble_mode = match BLE_LED_MODE.load(Ordering::Acquire) {
6449                1 if !BLE_ENABLED.load(Ordering::Acquire) => 0,
6450                mode => mode,
6451            };
6452            if ble_mode != 0
6453                && !alert_active()
6454                && matches!(
6455                    battery,
6456                    umsh_ux_tracker::battery::BatteryState::BatteryOnly
6457                        | umsh_ux_tracker::battery::BatteryState::BatteryCharged
6458                )
6459            {
6460                let phase = Instant::now().as_millis() % 2_000;
6461                let on = if ble_mode == 1 {
6462                    phase < 100 || (500..600).contains(&phase)
6463                } else {
6464                    phase < 100 || (200..300).contains(&phase) || (400..500).contains(&phase)
6465                };
6466                // This branch bypasses the engine, so it applies the
6467                // ambient dim itself — the connection blink dims with
6468                // the room like everything else.
6469                let brightness = if on {
6470                    umsh_ux_tracker::led::ambient_dim_permille(
6471                        umsh_bsp_t1000e::light::ambient_millilux(),
6472                    )
6473                } else {
6474                    0
6475                };
6476                let duty = ((u32::from(led.max_duty()) * u32::from(brightness)) / 1_000) as u16;
6477                write_led_duty(&mut led, duty);
6478                maybe_request_ambient_sample(&mut last_ambient_sample, brightness);
6479                match select(
6480                    Timer::after_millis(50),
6481                    umsh_bsp_t1000e::indicator::LED_BLANK_CHANGED.wait(),
6482                )
6483                .await
6484                {
6485                    Either::First(()) | Either::Second(()) => {}
6486                }
6487                continue;
6488            }
6489
6490            let decision = engine.tick(Instant::now().as_millis());
6491            let duty =
6492                ((u32::from(led.max_duty()) * u32::from(decision.brightness)) / 1_000) as u16;
6493            write_led_duty(&mut led, duty);
6494            maybe_request_ambient_sample(&mut last_ambient_sample, decision.brightness);
6495            match select(
6496                select4(
6497                    select(
6498                        Timer::at(Instant::from_millis(decision.next_deadline_ms)),
6499                        ALERT_CHANGED.wait(),
6500                    ),
6501                    umsh_bsp_t1000e::BATTERY_STATE_CHANGED.wait(),
6502                    umsh_bsp_t1000e::indicator::INDICATOR_CHANGED.wait(),
6503                    umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL.wait(),
6504                ),
6505                // An ambient light measurement wants the LED dark, and
6506                // wants it now — it waits on the confirmation below.
6507                umsh_bsp_t1000e::indicator::LED_BLANK_CHANGED.wait(),
6508            )
6509            .await
6510            {
6511                Either::First(Either4::First(_))
6512                | Either::First(Either4::Second(_))
6513                | Either::First(Either4::Third(()))
6514                | Either::Second(()) => {}
6515                Either::First(Either4::Fourth(sequence)) => {
6516                    engine.play(sequence, Instant::now().as_millis());
6517                }
6518            }
6519        }
6520    }
6521}