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