firmware_t1000e_console/
main.rs

1// Seeed SenseCAP T1000-E CLI console firmware — Phase 3 bringup.
2//
3// Phases 0-2 established: bootloader recon, USB-CDC, WDT, panic persist,
4// DFU rescue paths, button FSM (long-press → shutdown, triple-tap → DFU),
5// ordered System OFF with GPIO wake.
6//
7// Phase 2.5 proved the LR1110 radio receives MeshCore-US packets. The
8// root fixes were in the lora-phy fork (read_buffer offset+length framing,
9// calibration mask 0x3F, RadioLib-matching init sequence).
10//
11// Phase 3 wires CliSession over USB-CDC using the same MAC + radio_runner
12// pattern as techo-console (T-Echo Phase 6). Changes from Phase 2.5:
13//   - echo_task replaced by umsh_task (Host::run + CliSession::run)
14//   - radio_task replaced by radio_runner_task (umsh_radio_loraphy::runner)
15//   - output_task upgraded to cli_io::drain_to_sender (64-byte chunk drain)
16//   - NVMC identity persistence (first-boot TRNG key generation)
17//   - NVMC counter / peer / channel persistence
18//   - PowerSignaler connects /poweroff CLI command to SHUTDOWN_SIGNAL
19//
20// Boot sequence:
21//   1. Init heap allocator.
22//   2. Hold LR1110 RESET low (keeps radio quiet during USB init).
23//   3. Read button (P0.06); if held, enter serial DFU immediately.
24//   4. Arm the watchdog (8 s timeout, petted by heartbeat).
25//   5. Read any panic message left by the previous boot.
26//   6. Init NVMC storage (64 KB at 0xE4000..0xF4000).
27//   7. Load or TRNG-generate the local Ed25519 secret key.
28//   8. Build Mac<T1000EPlatform> and load persisted TX counter.
29//   9. Init LR1110 SPI + LoRa::new; derive MeshCore-US params.
30//  10. Set up USB-CDC with CdcAcmRescue (1200-baud touch + escape rescue).
31//  11. Spawn output_task, radio_runner_task, button_task, shutdown_task,
32//      umsh_task.
33//  12. Join usb.run / heartbeat in main.
34//
35// Task layout:
36//   - main():              joins usb.run / heartbeat
37//   - output_task:         owns USB Sender; drains cli_io::OUTPUT_CH
38//   - radio_runner_task:   owns LoRa<LR1110>; loops continuous RX ↔ TX
39//   - umsh_task:           host.run() + cli.run() via select; owns CdcInput
40//   - button_task:         owns P0.06 Input; runs ButtonFsm
41//   - shutdown_task:       awaits SHUTDOWN_SIGNAL; performs ordered System OFF
42//   - heartbeat (inline):  LED + WDT pet; runs in join with usb.run
43//
44// T1000-E pin notes (all confirmed against MeshCore variants/t1000-e):
45//   LED:    P0.24  active-HIGH  (set_high = on)
46//   Button: P0.06  active-HIGH  pull-down  (HIGH = pressed, WakeSense::High)
47//   LR1110: SCK=P0.11, CS=P0.12, MISO=P1.08, MOSI=P1.09, RST=P1.10
48//           DIO1/IRQ=P1.01, BUSY=P0.07
49//           DIO3: 1.6V TCXO control (set by lora-phy)
50//           DIO5-8: internal RF switch (set via RfSwitchConfig)
51
52#![cfg_attr(target_os = "none", no_std)]
53#![cfg_attr(target_os = "none", no_main)]
54
55#[cfg(target_os = "none")]
56extern crate alloc;
57
58#[cfg(not(target_os = "none"))]
59fn main() {}
60
61#[cfg(target_os = "none")]
62mod panic;
63
64#[cfg(target_os = "none")]
65mod cli_io;
66
67// lora-phy 3.x unconditionally depends on defmt. A zero-overhead no-op global
68// logger satisfies the link without adding any debug transport — every log
69// call compiles out at release. Same pattern as techo-console.
70#[cfg(target_os = "none")]
71mod defmt_logger {
72    #[defmt::global_logger]
73    struct Logger;
74    unsafe impl defmt::Logger for Logger {
75        fn acquire() {}
76        unsafe fn flush() {}
77        unsafe fn release() {}
78        unsafe fn write(_: &[u8]) {}
79    }
80    defmt::timestamp!("{=u32}", 0u32);
81}
82
83// Global heap allocator. umsh-sync (AsyncRefCell) and umsh-cli/node use alloc
84// for Rc/RefCell/Vec. 8 KiB is generous; actual runtime allocation is minimal
85// since all MAC state is in static arrays.
86#[cfg(target_os = "none")]
87#[global_allocator]
88static ALLOCATOR: embedded_alloc::Heap = embedded_alloc::Heap::empty();
89
90#[cfg(target_os = "none")]
91mod firmware {
92    use core::mem::MaybeUninit;
93
94    use embassy_executor::Spawner;
95    use embassy_futures::join::join;
96    use embassy_futures::select::{Either, Either4, select, select4};
97    use embassy_nrf::Peri;
98    use embassy_nrf::bind_interrupts;
99    use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
100    use embassy_nrf::nvmc::Nvmc;
101    use embassy_nrf::peripherals;
102    use embassy_nrf::pwm::{DutyCycle, Prescaler, SimpleConfig, SimplePwm};
103    use embassy_nrf::spim::{Config as SpimConfig, Frequency, Spim};
104    use embassy_nrf::usb::Driver;
105    use embassy_nrf::usb::vbus_detect::HardwareVbusDetect;
106    use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
107    use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
108    use embassy_sync::signal::Signal;
109    use embassy_time::{Delay, Duration, Instant, Timer};
110    use embassy_usb::class::cdc_acm::{CdcAcmClass, Sender, State};
111    use embassy_usb::{Builder, Config};
112    use embedded_hal_bus::spi::ExclusiveDevice;
113    use lora_phy::LoRa;
114    use lora_phy::iv::GenericLr1110InterfaceVariant;
115    use lora_phy::lr1110::{
116        Config as LoraConfig, Lr1110, TcxoCtrlVoltage, radio_kind_params::PaSelection,
117        variant::Lr1110 as Lr1110Chip,
118    };
119    use lora_phy::mod_params::{
120        Bandwidth, CodingRate, ModulationParams, PacketParams, SpreadingFactor,
121    };
122    use static_cell::StaticCell;
123    use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
124    use umsh_bsp_nrf52840::flash_store;
125    use umsh_bsp_nrf52840::flash_store::{
126        NvmcChannelStore, NvmcCounterStore, NvmcPeerStore, NvmcStorage,
127    };
128    use umsh_bsp_nrf52840::panic_persist::PanicSlot;
129    use umsh_bsp_nrf52840::{EmbassyClock, Nrf52840Rng};
130    use umsh_bsp_t1000e::{PowerSignaler, RF_SWITCH, SHUTDOWN_SIGNAL, T1000EMac, T1000EPlatform};
131    use umsh_core::PublicKey;
132    use umsh_crypto::{
133        CryptoEngine, NodeIdentity,
134        software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
135    };
136    use umsh_mac::{MacHandle, OperatingPolicy, RepeaterConfig, SendOptions};
137    use umsh_node::{Host, LocalNode};
138    use umsh_sync::AsyncRefCell;
139    use umsh_ux_tracker::button::{ButtonEdge, ButtonEvent, ButtonFsm, ButtonTimings};
140    use umsh_ux_tracker::buzzer::melodies as buzzer_melodies;
141    use umsh_ux_tracker::led::{LedSequence, T1000eLedEngine};
142
143    use super::cli_io;
144
145    bind_interrupts!(struct Irqs {
146        USBD        => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
147        CLOCK_POWER => embassy_nrf::usb::vbus_detect::InterruptHandler;
148        // Shared SPIM0/TWIM0 block — named TWISPI0 in embassy-nrf.
149        // LR1110 SPI is on this peripheral.
150        TWISPI0     => embassy_nrf::spim::InterruptHandler<peripherals::TWISPI0>;
151        SAADC       => embassy_nrf::saadc::InterruptHandler;
152    });
153
154    // ─── Constants ───────────────────────────────────────────────────────────
155
156    /// TX power for the LR1110 HP PA at max output (+22 dBm).
157    const TX_POWER_DBM: i32 = 22;
158
159    const DEBOUNCE: Duration = Duration::from_millis(10);
160
161    // ─── Platform ─────────────────────────────────────────────────────────────
162    //
163    // `T1000EPlatform`, `T1000EMac`, the embassy-backed clock, the nRF52840
164    // hardware-TRNG RNG, and the LR1110 RF-switch table all live in
165    // `umsh-bsp-t1000e` (which composes the chip-level pieces from
166    // `umsh-bsp-nrf52840`).
167
168    // `T1000EMac` is re-exported from `umsh_bsp_t1000e`. `Host` and `LocalNode`
169    // depend on `umsh-node` (alloc + software-crypto), which the BSP doesn't
170    // pull in, so the firmware owns those two aliases.
171    /// Host bound to the `'static` mac_cell. Owned by `mac_task`.
172    type T1000EHost = Host<MacHandle<'static, T1000EPlatform, 2, 8, 4, 4, 8, 255, 32>>;
173    /// LocalNode handle. Cheap to clone — passed to `cli_task` and `beacon_task`.
174    type T1000ENode = LocalNode<MacHandle<'static, T1000EPlatform, 2, 8, 4, 4, 8, 255, 32>>;
175
176    // ─── Concrete radio types ─────────────────────────────────────────────────
177
178    type RadioSpiBus = ExclusiveDevice<Spim<'static>, Output<'static>, Delay>;
179    type RadioIv = GenericLr1110InterfaceVariant<Output<'static>, Input<'static>>;
180    type RadioKindT = Lr1110<RadioSpiBus, RadioIv, Lr1110Chip>;
181    type LoraRadio = LoRa<RadioKindT, Delay>;
182
183    // ─── Static shared state ─────────────────────────────────────────────────
184
185    /// Channels shared between radio_runner_task and LoraphyRadio / MAC.
186    /// 4 inbound frames, 2 pending TX requests — same as T-Echo.
187    type RadioCh = umsh_radio_loraphy::Channels<ThreadModeRawMutex, 4, 2>;
188    static RADIO_CH: RadioCh = RadioCh::new();
189
190    static MAC_CELL: StaticCell<AsyncRefCell<T1000EMac>> = StaticCell::new();
191    static STORAGE: StaticCell<NvmcStorage> = StaticCell::new();
192
193    // SHUTDOWN_SIGNAL and PowerSignaler now live in `umsh-bsp-t1000e::power`;
194    // this firmware imports `SHUTDOWN_SIGNAL` for the long-press button source
195    // and uses `umsh_bsp_t1000e::PowerSignaler` for the CLI's PowerControl.
196
197    // BUZZER_SIGNAL now lives in `umsh_bsp_t1000e::buzzer` alongside the
198    // buzzer runner; firmware code uses `umsh_bsp_t1000e::BUZZER_SIGNAL`.
199
200    /// Button-driven beacon request, fired by the single-click primary
201    /// action slot.
202    static BEACON_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
203
204    // ─── USB types ────────────────────────────────────────────────────────────
205
206    type T1000eUsbDriver = Driver<'static, HardwareVbusDetect>;
207    type T1000eSender = Sender<'static, T1000eUsbDriver>;
208    type T1000eRescue = CdcAcmRescue<'static, T1000eUsbDriver>;
209
210    // ─── RF switch config ─────────────────────────────────────────────────────
211
212    // `RF_SWITCH` and `PowerSignaler` are re-exported from `umsh_bsp_t1000e`.
213
214    // ─── Tasks ───────────────────────────────────────────────────────────────
215
216    /// Drains cli_io::OUTPUT_CH to the USB sender. Decoupling the sender from
217    /// umsh_task lets RX keep flowing while TX awaits host IN polls.
218    #[embassy_executor::task]
219    async fn output_task(mut tx: T1000eSender) {
220        cli_io::drain_to_sender(&mut tx).await;
221    }
222
223    /// Owns the `lora_phy::LoRa` instance. Switches between continuous RX
224    /// and TX as TX requests arrive on `RADIO_CH.tx`.
225    #[embassy_executor::task]
226    async fn radio_runner_task(
227        lora: LoraRadio,
228        mdltn: ModulationParams,
229        rx_pkt: PacketParams,
230        tx_pkt: PacketParams,
231    ) {
232        umsh_radio_loraphy::runner(lora, &RADIO_CH, mdltn, rx_pkt, tx_pkt, TX_POWER_DBM).await;
233    }
234
235    /// Owns the piezo buzzer (PWM on P0.25 + power-enable on P1.05).
236    /// Body lives in `umsh_bsp_t1000e::buzzer`; this shim is required so
237    /// the embassy task macro sees concrete monomorphised types.
238    #[embassy_executor::task]
239    async fn buzzer_task(
240        pwm: SimplePwm<'static>,
241        enable: Output<'static>,
242        initially_silenced: bool,
243    ) {
244        umsh_bsp_t1000e::buzzer::run(pwm, enable, initially_silenced).await;
245    }
246
247    /// Drives the MAC coordinator. Independent of USB so radio RX/TX and
248    /// the MAC pump keep running whether or not a host terminal is attached.
249    #[embassy_executor::task]
250    async fn mac_task(mut host: T1000EHost) {
251        let _ = host.run().await;
252        panic!("host exited");
253    }
254
255    /// Listens for button-driven beacon requests. Independent of USB so
256    /// pressing the button broadcasts a beacon (and chirps) even when no
257    /// host terminal is attached.
258    #[embassy_executor::task]
259    async fn beacon_task(beacon_node: T1000ENode) {
260        use umsh_node::Transport as _;
261        loop {
262            BEACON_SIGNAL.wait().await;
263            // Audible feedback first so the user hears the press even if
264            // the MAC layer fails or stalls.
265            umsh_bsp_t1000e::BUZZER_SIGNAL.signal(&buzzer_melodies::BEACON_ACK);
266            let options = SendOptions::default().with_trace_route();
267            let _ = beacon_node.send_all(&[], &options).await;
268        }
269    }
270
271    /// Runs the `CliSession` over USB-CDC. This is the only task that
272    /// blocks on a host terminal connection — everything else (radio, MAC,
273    /// button, buzzer, beacon) runs without it.
274    #[embassy_executor::task]
275    async fn cli_task(
276        node: T1000ENode,
277        local_key: PublicKey,
278        storage: &'static NvmcStorage,
279        rx: T1000eRescue,
280        prev_panic_buf: &'static [u8; 256],
281        prev_panic_len: usize,
282    ) {
283        use umsh_cli::CliSession;
284        use umsh_cli::io::CliOutput;
285        use umsh_cli::logger::NullLogger;
286
287        let mut input = cli_io::CdcInput::new(rx);
288        let mut out = cli_io::CdcOutput::new();
289
290        // Wait for the host to open the CDC port before emitting the banner.
291        input.wait_connection().await;
292
293        let _ = out.write_line("").await;
294        let _ = out.write_line("UMSH CLI (T1000-E)").await;
295        let _ = out.write_line("type /help for commands").await;
296        if prev_panic_len > 0 {
297            let _ = out.write_line("[PREV PANIC]:").await;
298            if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
299                let _ = out.write_line(s).await;
300            }
301        }
302
303        let peer_store = NvmcPeerStore::new(storage);
304        let channel_store = NvmcChannelStore::new(storage);
305        let mut cli: CliSession<_, _, _, _, _, _, 4, 4, 2, 8, 128> = CliSession::new(
306            node,
307            local_key,
308            out,
309            NullLogger::new(),
310            peer_store,
311            channel_store,
312            PowerSignaler,
313        );
314
315        // `run` loads peers/channels from storage and registers them with the
316        // MAC (idempotent) and the CLI display tables before entering the loop.
317        let _ = cli.run(&mut input).await;
318        panic!("cli exited");
319    }
320
321    /// Resolves raw GPIO edges on the user button (P0.06, active-high, pull-down)
322    /// into `ButtonFsm` events. `Long` raises `SHUTDOWN_SIGNAL`;
323    /// `Triple` stays inert (reserved for GPS power control).
324    #[embassy_executor::task]
325    async fn button_task(mut button: Input<'static>, storage: &'static NvmcStorage) {
326        let mut fsm = ButtonFsm::new(ButtonTimings::default());
327        let mut pressed = button.is_high();
328        loop {
329            let event = {
330                let now_ms = Instant::now().as_millis();
331                let edge_fut = async {
332                    if pressed {
333                        button.wait_for_low().await;
334                        Timer::after(DEBOUNCE).await;
335                        ButtonEdge::Release
336                    } else {
337                        button.wait_for_high().await;
338                        Timer::after(DEBOUNCE).await;
339                        ButtonEdge::Press
340                    }
341                };
342                let timeout_deadline_ms =
343                    fsm.next_deadline().unwrap_or(now_ms.saturating_add(60_000));
344                let timer_fut = Timer::at(Instant::from_millis(timeout_deadline_ms));
345                match select(edge_fut, timer_fut).await {
346                    Either::First(edge) => {
347                        pressed = matches!(edge, ButtonEdge::Press);
348                        fsm.on_edge(edge, Instant::now().as_millis())
349                    }
350                    Either::Second(()) => fsm.poll(Instant::now().as_millis()),
351                }
352            };
353
354            match event {
355                Some(ButtonEvent::Single) => {
356                    umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL
357                        .signal(LedSequence::ActionConfirm);
358                    BEACON_SIGNAL.signal(());
359                }
360                Some(ButtonEvent::Double) => {
361                    let preferences = umsh_bsp_t1000e::preferences::toggle_silent();
362                    umsh_bsp_t1000e::BUZZER_SILENCE_SET.signal(preferences.silent);
363                    umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL
364                        .signal(LedSequence::ActionConfirm);
365                    // Confirmation reflects the applied (in-RAM) toggle. A
366                    // failed flash write costs only durability across the
367                    // next reset; there is no user-facing fault channel.
368                    let mut durable = preferences;
369                    durable.battery_critical = false;
370                    let _ = storage.store_tracker_preferences(durable.encode()).await;
371                }
372                Some(ButtonEvent::Triple) => {
373                    // Reserved for GPS power control. This firmware does not
374                    // yet own a GNSS task, so the slot remains inert.
375                }
376                Some(ButtonEvent::Quad) => {
377                    // No action defined for Quad yet
378                }
379                Some(ButtonEvent::Long) => {
380                    pressed = false;
381                    fsm = ButtonFsm::new(ButtonTimings::default());
382                    // shutdown_task persists the Asleep preference for every
383                    // shutdown source (button, PowerSignaler, battery cutoff).
384                    umsh_bsp_t1000e::preferences::set_asleep(true);
385                    SHUTDOWN_SIGNAL.signal(());
386                }
387                _ => {}
388            }
389        }
390    }
391
392    /// Orchestrates controlled power-off (LR1110 reset + GPIO tristate +
393    /// System OFF with button as wake). Body lives in
394    /// `umsh_bsp_t1000e::shutdown`.
395    #[embassy_executor::task]
396    async fn shutdown_task(storage: &'static NvmcStorage) -> ! {
397        SHUTDOWN_SIGNAL.wait().await;
398        let mut durable = umsh_bsp_t1000e::preferences::load();
399        durable.battery_critical = false;
400        let _ = storage.store_tracker_preferences(durable.encode()).await;
401        umsh_bsp_t1000e::shutdown::run_after_request().await
402    }
403
404    /// Monitors battery voltage via SAADC and forces shutdown on low VBAT.
405    /// Body lives in `umsh_bsp_t1000e::power`.
406    #[embassy_executor::task]
407    async fn power_task(
408        saadc: Peri<'static, peripherals::SAADC>,
409        battery_pin: Peri<'static, peripherals::P0_02>,
410        light_pin: Peri<'static, peripherals::P0_29>,
411        sensor_rail: Output<'static>,
412        sensor_enable: Output<'static>,
413        external_power: Input<'static>,
414        charge_active: Input<'static>,
415    ) {
416        umsh_bsp_t1000e::power::run_battery_monitor(
417            saadc,
418            Irqs,
419            battery_pin,
420            light_pin,
421            sensor_rail,
422            sensor_enable,
423            external_power,
424            charge_active,
425        )
426        .await;
427    }
428
429    async fn heartbeat(mut led: SimplePwm<'static>, mut wdt: WatchdogHandle) -> ! {
430        led.set_period(1_000);
431        led.enable();
432        let mut engine = T1000eLedEngine::new(Instant::now().as_millis());
433        loop {
434            wdt.pet();
435            engine.set_battery(umsh_bsp_t1000e::battery_state());
436            engine.set_attention(umsh_bsp_t1000e::indicator::attention_requested());
437            let decision = engine.tick(Instant::now().as_millis());
438            let duty =
439                ((u32::from(led.max_duty()) * u32::from(decision.brightness)) / 1_000) as u16;
440            // The LED sits beside the ambient light sensor, so a
441            // measurement in flight outranks whatever the engine wants to
442            // show. Confirmed after the write, never before.
443            let blanking = umsh_bsp_t1000e::indicator::blank_requested();
444            led.set_duty(0, DutyCycle::inverted(if blanking { 0 } else { duty }));
445            if blanking {
446                umsh_bsp_t1000e::indicator::confirm_blanked();
447            }
448            match select(
449                select4(
450                    Timer::at(Instant::from_millis(decision.next_deadline_ms)),
451                    umsh_bsp_t1000e::BATTERY_STATE_CHANGED.wait(),
452                    umsh_bsp_t1000e::indicator::INDICATOR_CHANGED.wait(),
453                    umsh_bsp_t1000e::indicator::LED_SEQUENCE_SIGNAL.wait(),
454                ),
455                umsh_bsp_t1000e::indicator::LED_BLANK_CHANGED.wait(),
456            )
457            .await
458            {
459                Either::First(Either4::First(()))
460                | Either::First(Either4::Second(_))
461                | Either::First(Either4::Third(()))
462                | Either::Second(()) => {}
463                Either::First(Either4::Fourth(sequence)) => {
464                    engine.play(sequence, Instant::now().as_millis());
465                }
466            }
467        }
468    }
469
470    #[embassy_executor::main]
471    async fn main(spawner: Spawner) {
472        // Init heap before any alloc-using code. 8 KiB is generous; runtime
473        // alloc is near-zero since all MAC state lives in static arrays.
474        {
475            const HEAP_SIZE: usize = 8192;
476            static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
477            unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
478        }
479
480        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
481
482        // Mount durable user preferences before deciding whether normal
483        // application startup is permitted. GPREGRET2 remains only a fast
484        // mirror and a retained critical-shutdown reason.
485        let storage: &'static NvmcStorage =
486            STORAGE.init(flash_store::new_storage(Nvmc::new(p.NVMC)));
487
488        let reset_reasons = embassy_nrf::pac::POWER.resetreas().read();
489        embassy_nrf::pac::POWER
490            .resetreas()
491            .write(|reasons| reasons.0 = u32::MAX);
492        // RESETREAS.OFF alone proves a button wake: P0.06 is the only GPIO
493        // DETECT source armed at System OFF entry (USB insertion wakes via
494        // the native VBUS detector and sets its own reason bit). The pin
495        // itself cannot be sampled this early — PIN_CNF resets to
496        // input-disconnected, so the IN register reads 0 regardless of the
497        // physical level.
498        let woke_from_system_off = reset_reasons.off();
499        let external_power_present = umsh_bsp_t1000e::power::usb_power_present();
500        let gpregret_state = umsh_bsp_t1000e::preferences::load_retained();
501        let retained_critical =
502            gpregret_state.is_some_and(|preferences| preferences.battery_critical);
503        let mut retained_state = storage
504            .load_tracker_preferences()
505            .await
506            .ok()
507            .flatten()
508            .and_then(umsh_ux_tracker::state::UserPreferences::try_decode)
509            .unwrap_or_default();
510        retained_state.battery_critical = retained_critical;
511        umsh_bsp_t1000e::preferences::store(retained_state);
512        if retained_state.battery_critical && !external_power_present {
513            umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
514        }
515        if retained_state.battery_critical && external_power_present {
516            umsh_bsp_t1000e::preferences::set_battery_critical(false);
517        }
518        let charging_sleep_wake_requested =
519            reset_reasons.sreq() && gpregret_state.is_some_and(|preferences| !preferences.asleep);
520        if woke_from_system_off || charging_sleep_wake_requested {
521            let preferences = umsh_bsp_t1000e::preferences::set_asleep(false);
522            let mut durable = preferences;
523            durable.battery_critical = false;
524            let _ = storage.store_tracker_preferences(durable.encode()).await;
525        } else if umsh_bsp_t1000e::preferences::load().asleep {
526            if external_power_present {
527                let mut led_config = SimpleConfig::default();
528                led_config.prescaler = Prescaler::Div16;
529                let led_pwm = SimplePwm::new_1ch(p.PWM1, p.P0_24, &led_config);
530                let sleep_button = Input::new(p.P0_06, Pull::Down);
531                let sleep_external_power = Input::new(p.P0_05, Pull::Down);
532                let sleep_charge_active = Input::new(p.P1_03, Pull::Up);
533                umsh_bsp_t1000e::shutdown::run_charging_sleep(
534                    led_pwm,
535                    sleep_button,
536                    sleep_external_power,
537                    sleep_charge_active,
538                )
539                .await;
540            } else {
541                umsh_bsp_t1000e::shutdown::resume_persisted_sleep().await;
542            }
543        }
544
545        // Seize LR1110 RESET immediately and hold it low. The LR1110 can
546        // outlive nRF soft resets; if a previous image left it in a bad state
547        // (e.g. broken DCDC on this board), holding RESET prevents that state
548        // from destabilizing USB before LoRa::new() runs.
549        let radio_rst = Output::new(p.P1_10, Level::Low, OutputDrive::Standard);
550
551        // User button (active-HIGH, pull-down). DFU entry is the
552        // bootloader's 1200-baud touch, never a button gesture.
553        let button = Input::new(p.P0_06, Pull::Down);
554
555        // WDT: 8 s timeout, petted by heartbeat.
556        let mut wdt_config = WdtConfig::default();
557        wdt_config.timeout_ticks = 32768 * 8;
558        let (_wdt, [wdt_handle]) =
559            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
560
561        // Read any panic message left by the previous boot, then clear it.
562        static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
563        let mut prev_panic_tmp = [0u8; 256];
564        let prev_panic_len = {
565            let mut slot = PanicSlot::new(super::panic::panic_region());
566            if let Some(msg) = slot.read() {
567                let n = msg.len().min(prev_panic_tmp.len());
568                prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
569                slot.clear();
570                n
571            } else {
572                0
573            }
574        };
575        let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
576
577        let mut led_config = SimpleConfig::default();
578        led_config.prescaler = Prescaler::Div16;
579        let led = SimplePwm::new_1ch(p.PWM1, p.P0_24, &led_config);
580
581        // ── Piezo buzzer ─────────────────────────────────────────────────────
582        // P0.25 = PWM, P1.05 = power-enable for the buzzer driver chip.
583        // Div16 prescaler gives a 1 MHz PWM clock — comfortably covers the
584        // 1–2 kHz melody range with max_duty 500–1000.
585        let buzzer_pwm = {
586            let mut cfg = SimpleConfig::default();
587            cfg.prescaler = Prescaler::Div16;
588            SimplePwm::new_1ch(p.PWM0, p.P0_25, &cfg)
589        };
590        let buzzer_enable = Output::new(p.P1_05, Level::Low, OutputDrive::Standard);
591        let initial_preferences = umsh_bsp_t1000e::preferences::load();
592        spawner.spawn(buzzer_task(buzzer_pwm, buzzer_enable, initial_preferences.silent).unwrap());
593        // Boot chirp — independent of USB, so headless boots also signal life.
594        umsh_bsp_t1000e::BUZZER_SIGNAL.signal(&buzzer_melodies::POWER_ON);
595
596        // ── Local identity ────────────────────────────────────────────────────
597        // The hardware-TRNG RNG built here is the single RNG path for this
598        // firmware — used for first-boot identity generation AND passed
599        // ownership-by-value into `Mac::new` below as `Platform::Rng`.
600        //
601        // Load identity from flash on subsequent boots; TRNG-generate on
602        // first boot. We do NOT fall back to any PRNG on failure — a
603        // predictable long-term key is worse than panicking.
604        let mut rng = Nrf52840Rng::new(p.RNG);
605        let sk_bytes: [u8; 32] = match storage.load_sk().await {
606            Ok(Some(sk)) => sk,
607            Ok(None) => {
608                let mut sk = [0u8; 32];
609                rng.fill_bytes(&mut sk);
610                storage
611                    .store_sk(&sk)
612                    .await
613                    .unwrap_or_else(|_| panic!("identity persist"));
614                sk
615            }
616            Err(_) => panic!("storage init failed"),
617        };
618        let identity = SoftwareIdentity::from_secret_bytes(&sk_bytes);
619        let local_key = *identity.public_key();
620
621        // ── LR1110 LoRa radio ─────────────────────────────────────────────────
622        // Pin map (confirmed against MeshCore variants/t1000-e):
623        //   SPI bus: SCK=P0.11, MISO=P1.08, MOSI=P1.09 (TWISPI0)
624        //   CS=P0.12, RST=P1.10, IRQ/DIO1=P1.01, BUSY=P0.07
625        //   DIO3: 1.6 V TCXO control (handled by lora-phy SetDIO3AsTCXOCtrl)
626        //   DIO5-8: internal RF switch (handled by RfSwitchConfig via SetDioAsRfSwitch)
627        let t_frame_ms = umsh_radio_loraphy::airtime_ms(
628            SpreadingFactor::_7,
629            Bandwidth::_62KHz,
630            umsh_radio_loraphy::MAX_PAYLOAD,
631        );
632        {
633            let mut cfg = SpimConfig::default();
634            cfg.frequency = Frequency::M8;
635            let radio_bus = Spim::new(
636                p.TWISPI0, Irqs, p.P0_11, // SCK
637                p.P1_08, // MISO
638                p.P1_09, // MOSI
639                cfg,
640            );
641            let radio_cs = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
642            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
643
644            // Pull::Down on DIO1: the LR1110's IRQ output is push-pull active-high,
645            // so a pull-down prevents floating reads when IRQ is de-asserted.
646            let radio_interrupt = Input::new(p.P1_01, Pull::Down);
647            let radio_busy = Input::new(p.P0_07, Pull::None);
648
649            let iv = GenericLr1110InterfaceVariant::new(
650                radio_rst,
651                radio_interrupt,
652                radio_busy,
653                None, // rf_switch_rx: not external — DIO5-8 handle it internally
654                None, // rf_switch_tx: same
655            )
656            .unwrap_or_else(|_| panic!("lr1110 iv"));
657
658            let lora_config = LoraConfig {
659                // HP PA — SetTx will route through tx_hp (0x0A = DIO6+DIO8)
660                // on our RF-switch table. Combined with TX_POWER_DBM=22 this
661                // is the maximum output the chip + board can produce.
662                chip: Lr1110Chip::with_pa(PaSelection::Hp),
663                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V6),
664                use_dcdc: false, // T1000-E module has no external inductor for BST
665                rx_boost: true,
666                rf_switch: Some(RF_SWITCH),
667            };
668
669            // enable_public_network=false → private sync word 0x1424,
670            // matching MeshCore's RADIOLIB_SX126X_SYNC_WORD_PRIVATE = 0x12.
671            let mut lora = LoRa::new(Lr1110::new(radio_spi, iv, lora_config), false, Delay)
672                .await
673                .unwrap_or_else(|_| panic!("radio init"));
674
675            // MeshCore-US on-air parameters tuned for LR1110.
676            //
677            // Phase 2.5 RX bringup proved that preamble_length=16 (matching
678            // MeshCore's RadioLib TX preamble) reliably triggers
679            // SyncWordHeaderValid → RxDone on the LR1110. The shared
680            // `umsh_radio_loraphy::meshcore_us_params` helper uses 8 for RX,
681            // which is fine for the SX1262 on T-Echo but loses packets on
682            // LR1110. We pin both rx and tx to 16 here to match what worked
683            // on real hardware.
684            let mdltn = lora
685                .create_modulation_params(
686                    SpreadingFactor::_7,
687                    Bandwidth::_62KHz,
688                    CodingRate::_4_5,
689                    910_525_000,
690                )
691                .unwrap_or_else(|_| panic!("modulation params"));
692            let rx_pkt = lora
693                .create_rx_packet_params(
694                    16,    // preamble length: LR1110 needs 16 for MeshCore-US
695                    false, // explicit header
696                    255,   // max payload
697                    true,  // CRC on
698                    false, // IQ normal
699                    &mdltn,
700                )
701                .unwrap_or_else(|_| panic!("rx packet params"));
702            let tx_pkt = lora
703                .create_tx_packet_params(
704                    16,    // preamble length: matches MeshCore RadioLib
705                    false, // explicit header
706                    true,  // CRC on
707                    false, // IQ normal
708                    &mdltn,
709                )
710                .unwrap_or_else(|_| panic!("tx packet params"));
711
712            spawner.spawn(radio_runner_task(lora, mdltn, rx_pkt, tx_pkt).unwrap());
713        }
714
715        // ── MAC coordinator ───────────────────────────────────────────────────
716        let radio_handle = umsh_radio_loraphy::LoraphyRadio::new(&RADIO_CH, t_frame_ms);
717        let crypto = CryptoEngine::new(SoftwareAes, SoftwareSha256);
718        let mut mac = T1000EMac::new(
719            radio_handle,
720            crypto,
721            EmbassyClock,
722            rng,
723            NvmcCounterStore::new(storage),
724            RepeaterConfig::default(),
725            OperatingPolicy::default(),
726        );
727        let identity_id = mac
728            .add_identity(identity)
729            .unwrap_or_else(|_| panic!("identity"));
730        // Restore TX frame-counter boundary so the counter never rewinds.
731        mac.load_persisted_counter(identity_id)
732            .await
733            .unwrap_or_else(|_| panic!("tx counter load"));
734        let mac_cell: &'static AsyncRefCell<T1000EMac> = MAC_CELL.init(AsyncRefCell::new(mac));
735
736        // ── USB stack ─────────────────────────────────────────────────────────
737        let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
738
739        let mut config = Config::new(0x2886, 0x0057);
740        config.manufacturer = Some("Seeed");
741        config.product = Some("T1000-E UMSH CLI");
742        config.serial_number = Some("umsh-t1000e");
743        config.max_power = 100;
744        config.max_packet_size_0 = 64;
745
746        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
747        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
748        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
749        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
750        static STATE: StaticCell<State> = StaticCell::new();
751
752        let mut builder = Builder::new(
753            driver,
754            config,
755            CONFIG_DESC.init([0; 256]),
756            BOS_DESC.init([0; 256]),
757            MSOS_DESC.init([0; 0]),
758            CONTROL_BUF.init([0; 64]),
759        );
760
761        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
762        let mut usb = builder.build();
763
764        let (tx, raw_rx, ctrl) = class.split_with_control();
765        let rx = CdcAcmRescue::new(raw_rx, ctrl);
766
767        // ── Battery + ambient light ADC ───────────────────────────────────────
768        // P0.02 = AIN0 via 2:1 divider; sensor rail P1.06 gates the path.
769        // P0.29 = AIN5 is the light sensor, behind its own enable on P0.04.
770        // The BSP builds a single-channel converter per measurement.
771        let sensor_rail = Output::new(p.P1_06, Level::Low, OutputDrive::Standard);
772        let sensor_enable = Output::new(p.P0_04, Level::Low, OutputDrive::Standard);
773        let external_power = Input::new(p.P0_05, Pull::Down);
774        let charge_active = Input::new(p.P1_03, Pull::Up);
775
776        // ── Host + LocalNode ──────────────────────────────────────────────────
777        // Build the Host and add the local identity's node here in main() so
778        // we can clone the node for the beacon task before moving Host into
779        // mac_task. The Host's internal node store and the cloned LocalNode
780        // share Rc state, so events route correctly regardless of which task
781        // holds which copy.
782        let handle = MacHandle::new(mac_cell);
783        let mut host: T1000EHost = Host::new(handle);
784        let node = host.add_node(identity_id);
785        let beacon_node = node.clone();
786
787        // Register persisted peers and channels into the MAC at boot, before
788        // spawning tasks. The CLI task must not be the first thing that
789        // registers these — it only runs after a host opens the CDC port, and
790        // the MAC needs the keys from the very first packet.
791        {
792            let mut peer_buf: heapless::Vec<([u8; 32], Option<heapless::String<16>>), 8> =
793                heapless::Vec::new();
794            let _ = storage.load_all_peers(&mut peer_buf).await;
795            let mut ch_buf: heapless::Vec<(heapless::String<16>, [u8; 32]), 2> =
796                heapless::Vec::new();
797            let _ = storage.load_all_channels(&mut ch_buf).await;
798            for (pk, _alias) in peer_buf.iter() {
799                let _ = node.peer(PublicKey(*pk)).await;
800            }
801            for (name, key_bytes) in ch_buf.iter() {
802                let channel =
803                    umsh_node::Channel::private(umsh_core::ChannelKey(*key_bytes), name.as_str());
804                let _ = node.join(&channel).await;
805            }
806        }
807
808        // Restore RX counter boundaries before the MAC starts processing
809        // packets so the replay window starts above the last accepted frame.
810        // Runs after the peer registration above so the persisted boundaries
811        // actually land on registered peers.
812        MacHandle::new(mac_cell)
813            .load_all_persisted_rx_counters()
814            .await
815            .ok();
816
817        spawner.spawn(output_task(tx).unwrap());
818        spawner.spawn(button_task(button, storage).unwrap());
819        spawner.spawn(shutdown_task(storage).unwrap());
820        spawner.spawn(
821            power_task(
822                p.SAADC,
823                p.P0_02,
824                p.P0_29,
825                sensor_rail,
826                sensor_enable,
827                external_power,
828                charge_active,
829            )
830            .unwrap(),
831        );
832        spawner.spawn(mac_task(host).unwrap());
833        spawner.spawn(beacon_task(beacon_node).unwrap());
834        spawner
835            .spawn(cli_task(node, local_key, storage, rx, prev_panic_buf, prev_panic_len).unwrap());
836
837        join(usb.run(), heartbeat(led, wdt_handle)).await;
838    }
839}