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