firmware_techo_console/
main.rs

1// LilyGO T-Echo bringup firmware with an interactive UMSH CLI on USB-CDC.
2//
3// Boot sequence:
4//   1. Bring up the peripheral rail (P0.12 HIGH).
5//   2. Arm the watchdog (8 s timeout, petted by the heartbeat task).
6//   3. Spawn the display task — initial boot screen ("UMSH bringup" + git
7//      short SHA + "MAC: 0") plus subsequent count-update refreshes.
8//   4. Initialize the SX1262 LoRa radio (MeshCore US settings) and spawn
9//      the radio runner task.
10//   5. Build a `Mac<TechoPlatform>`, park it in a `'static AsyncRefCell`,
11//      and spawn `umsh_task` which drives `Host::run` + `CliSession::run`
12//      concurrently over the shared `MacHandle`.
13//   6. Spawn `output_task` to own the USB `Sender` and drain `OUTPUT_CH`.
14//   7. Join usb.run / heartbeat in main; the CLI runs in spawned tasks.
15//
16// Task layout (steady state):
17//   - main():               joins usb.run / heartbeat
18//   - display_task:         renders the e-paper on count changes
19//   - radio_runner_task:    owns lora_phy::LoRa, RX/TX state machine
20//   - umsh_task:            host.run() + cli.run(), shares the MAC via MacHandle
21//   - output_task:          owns the USB Sender, drains OUTPUT_CH
22//
23// USB CDC flow control is preserved by the output_task / OUTPUT_CH split:
24// nothing blocks CdcInput::read_packet on TX progress, so the host's bulk
25// OUT NAK / retry mechanism handles backpressure correctly during pastes.
26//
27// Safety primitives inherited from the BSP (see umsh-bsp-nrf52840):
28//   * Panic capture into reserved RAM, dumped over USB on the next boot.
29//   * 1200-baud touchless reset and Ctrl-C × 3 + "dfu" escape to bootloader
30//     (baked into CdcAcmRescue).
31//   * Watchdog.
32
33#![cfg_attr(target_os = "none", no_std)]
34#![cfg_attr(target_os = "none", no_main)]
35
36#[cfg(not(target_os = "none"))]
37fn main() {
38    // Host placeholder. This binary only runs on the embedded target.
39}
40
41// The #[panic_handler] must live in the binary crate.
42#[cfg(target_os = "none")]
43mod panic;
44
45#[cfg(target_os = "none")]
46mod cli_io;
47
48// lora-phy 3.x unconditionally depends on defmt. Provide a zero-overhead
49// no-op global logger so this binary links without any debug transport.
50// All defmt log calls compile out in release mode; this just provides the
51// required linker symbols.
52#[cfg(target_os = "none")]
53mod defmt_logger {
54    #[defmt::global_logger]
55    struct Logger;
56    unsafe impl defmt::Logger for Logger {
57        fn acquire() {}
58        unsafe fn flush() {}
59        unsafe fn release() {}
60        unsafe fn write(_: &[u8]) {}
61    }
62    defmt::timestamp!("{=u32}", 0u32);
63}
64
65// Global heap allocator. umsh-mac → umsh-sync → alloc; a tiny static heap
66// satisfies the linker. Actual runtime alloc usage is near-zero since we drive
67// the MAC with `Mac::run` directly rather than through MacHandle.
68#[cfg(target_os = "none")]
69#[global_allocator]
70static ALLOCATOR: embedded_alloc::Heap = embedded_alloc::Heap::empty();
71
72#[cfg(target_os = "none")]
73mod firmware {
74    use core::sync::atomic::{AtomicU32, Ordering};
75
76    use embassy_executor::Spawner;
77    use embassy_futures::join::join;
78    use embassy_futures::select::{Either, select};
79    use embassy_nrf::bind_interrupts;
80    use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
81    use embassy_nrf::nvmc::Nvmc;
82    use embassy_nrf::peripherals;
83    use embassy_nrf::spim::{Config as SpimConfig, Frequency, Spim};
84    use embassy_nrf::usb::Driver;
85    use embassy_nrf::usb::vbus_detect::HardwareVbusDetect;
86    use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
87    use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
88    use embassy_sync::signal::Signal;
89    use embassy_time::{Delay, Duration, Instant, Timer};
90    use embassy_usb::class::cdc_acm::{CdcAcmClass, State};
91    use embassy_usb::{Builder, Config};
92    use embedded_hal_bus::spi::ExclusiveDevice;
93    use lora_phy::LoRa;
94    use lora_phy::iv::GenericSx126xInterfaceVariant;
95    use lora_phy::mod_params::{Bandwidth, ModulationParams, PacketParams, SpreadingFactor};
96    use lora_phy::sx126x::{Config as LoraConfig, Sx126x, Sx1262, TcxoCtrlVoltage};
97    use static_cell::StaticCell;
98    use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
99    use umsh_bsp_nrf52840::flash_store;
100    use umsh_bsp_nrf52840::flash_store::{
101        NvmcChannelStore, NvmcCounterStore, NvmcPeerStore, NvmcStorage,
102    };
103    use umsh_bsp_nrf52840::panic_persist::PanicSlot;
104    use umsh_bsp_nrf52840::system_off::{
105        Port, WakePin, WakeSense, drive_pin_high, drive_pin_low, power_off, tristate_pin,
106    };
107    use umsh_bsp_nrf52840::{EmbassyClock, Nrf52840Rng};
108    use umsh_bsp_techo::{PowerSignaler, SHUTDOWN_SIGNAL, TechoMac, TechoPlatform, display};
109    use umsh_core::{ChannelKey, PayloadType, PublicKey};
110    use umsh_crypto::{
111        CryptoEngine, NodeIdentity,
112        software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
113    };
114    use umsh_mac::{LocalIdentityId, MacHandle, OperatingPolicy, RepeaterConfig};
115    use umsh_node::{Channel, Host, LocalNode};
116    use umsh_sync::AsyncRefCell;
117
118    use super::cli_io;
119    use umsh_ux_tracker::led::{LedEngine, LedTimings};
120
121    bind_interrupts!(struct Irqs {
122        USBD        => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
123        CLOCK_POWER => embassy_nrf::usb::vbus_detect::InterruptHandler;
124        // SPIM2 → e-paper SPI bus. embassy-nrf names this interrupt SPI2.
125        SPI2        => embassy_nrf::spim::InterruptHandler<peripherals::SPI2>;
126        // SPIM1 → SX1262 LoRa SPI bus. embassy-nrf names this peripheral
127        // TWISPI1 (it's the shared TWIM1/SPIM1 block on nRF52840).
128        TWISPI1     => embassy_nrf::spim::InterruptHandler<peripherals::TWISPI1>;
129    });
130
131    // ─── Configuration constants ─────────────────────────────────────────────
132
133    /// Display refresh throttle: do not refresh more than once per this
134    /// interval. Each full refresh is ~2 s of panel flashing, so spamming
135    /// updates would be both ugly and bad for the panel.
136    const DISPLAY_THROTTLE: Duration = Duration::from_secs(5);
137
138    /// FONT_10X20 character width in pixels — used for centering text.
139    const FONT_W: i32 = 10;
140
141    /// Vertical positions of the three boot-screen text lines, in pixels.
142    const TITLE_Y: i32 = 70;
143    const SHA_Y: i32 = 100;
144    const COUNT_Y: i32 = 130;
145
146    /// Per-frame TX power in dBm. SX1262 PA range is roughly -9..+22.
147    /// 14 dBm is the conservative bringup default.
148    const TX_POWER_DBM: i32 = 14;
149
150    // ─── Concrete types for the radio task ───────────────────────────────────
151    //
152    // `#[embassy_executor::task]` requires concrete types in the task
153    // signature, so we name them once here.
154
155    type RadioSpiBus = ExclusiveDevice<Spim<'static>, Output<'static>, Delay>;
156    type RadioIv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
157    type RadioKind = Sx126x<RadioSpiBus, RadioIv, Sx1262>;
158    type LoraRadio = LoRa<RadioKind, Delay>;
159
160    // Host/node aliases (need `umsh-node`, which the BSP doesn't pull in, so the
161    // firmware owns them). The const params match `TechoMac`'s capacities.
162    /// Host bound to the `'static` mac_cell. Owned by `mac_task`.
163    type TechoHost = Host<MacHandle<'static, TechoPlatform, 2, 8, 4, 4, 8, 255, 32>>;
164    /// LocalNode handle. Cheap to clone — passed to `cli_task`.
165    type TechoNode = LocalNode<MacHandle<'static, TechoPlatform, 2, 8, 4, 4, 8, 255, 32>>;
166
167    // ─── Static shared state ─────────────────────────────────────────────────
168
169    /// Channels shared between the radio runner and LoraphyRadio / MAC.
170    /// Capacity: 4 inbound frames, 2 pending TX requests.
171    type RadioCh = umsh_radio_loraphy::Channels<ThreadModeRawMutex, 4, 2>;
172    static RADIO_CH: RadioCh = RadioCh::new();
173
174    /// Count of UMSH-authenticated packets received by the MAC coordinator.
175    /// Incremented in the mac_task on_event callback; read by display_task.
176    static PACKET_COUNT: AtomicU32 = AtomicU32::new(0);
177
178    /// Fires whenever the MAC delivers a new authenticated packet. The display
179    /// task wakes on this signal and reads PACKET_COUNT to render. Coalesces:
180    /// rapid bursts produce one refresh per throttle window, not one per packet.
181    static DISPLAY_COUNT_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
182
183    /// Shared MAC coordinator cell. Stored in a `StaticCell` so a `'static`
184    /// reference can be handed to the spawned `umsh_task` (which builds
185    /// `MacHandle` / `Host` / `CliSession` off of it).
186    static MAC_CELL: StaticCell<AsyncRefCell<TechoMac>> = StaticCell::new();
187    static STORAGE: StaticCell<NvmcStorage> = StaticCell::new();
188
189    /// Relay from the sync `on_receive` callback to the async
190    /// `identity_persist_task`. Carries (pk, payload_body, len).
191    /// Only the most-recent identity per sender is retained; a newer
192    /// over-the-air update simply overwrites an earlier un-drained one.
193    static IDENTITY_SIGNAL: Signal<ThreadModeRawMutex, ([u8; 32], [u8; 256], usize)> =
194        Signal::new();
195
196    // ─── Shutdown signalling ─────────────────────────────────────────────────
197    //
198    // Three signals, each single-consumer:
199    //   SHUTDOWN_SIGNAL          → shutdown_task   (fired by button_task and the
200    //                                              `/poweroff` CLI command)
201    //   DISPLAY_SHUTDOWN_SIGNAL  → display_task    (fired by shutdown_task,
202    //                                              tells the display to render the
203    //                                              final frame and sleep)
204    //   DISPLAY_SHUTDOWN_DONE    → shutdown_task   (fired by display_task once
205    //                                              the panel is asleep)
206
207    // SHUTDOWN_SIGNAL and PowerSignaler live in `umsh-bsp-techo::power`;
208    // the display-shutdown handshake stays firmware-local because it's
209    // specific to this firmware's task layout.
210    static DISPLAY_SHUTDOWN_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
211    static DISPLAY_SHUTDOWN_DONE: Signal<ThreadModeRawMutex, ()> = Signal::new();
212
213    // ─── Platform types ───────────────────────────────────────────────────────
214    //
215    // `TechoPlatform`, `TechoMac`, the embassy-backed clock, and the
216    // hardware-TRNG RNG live in `umsh-bsp-techo` (which composes the
217    // chip-level pieces from `umsh-bsp-nrf52840`).
218
219    // ─── Tasks ───────────────────────────────────────────────────────────────
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).await;
231    }
232
233    // ─── Concrete USB driver types ────────────────────────────────────────────
234    // ('static lifetime, VbusDetect = HardwareVbusDetect.) Used by `umsh_task`
235    // and `output_task`.
236    type TechoUsbDriver = Driver<'static, HardwareVbusDetect>;
237    type TechoSender = embassy_usb::class::cdc_acm::Sender<'static, TechoUsbDriver>;
238    type TechoRescue = umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue<'static, TechoUsbDriver>;
239
240    // ─── CliSession-backed combined task ─────────────────────────────────────
241
242    /// Owns the USB `Sender` and drains `cli_io::OUTPUT_CH`. Decoupling the
243    /// sender from `umsh_task` lets RX keep flowing while TX awaits host IN
244    /// polls, so USB OUT NAKs handle backpressure correctly during pastes.
245    #[embassy_executor::task]
246    async fn output_task(mut tx: TechoSender) {
247        cli_io::drain_to_sender(&mut tx).await;
248    }
249
250    /// Drains `IDENTITY_SIGNAL` and persists received `NodeIdentityPayload`
251    /// bytes for known peers. Runs independently of the MAC/CLI task so that
252    /// NVMC writes (which stall the CPU for ~85 ms) don't affect radio timing.
253    #[embassy_executor::task]
254    async fn identity_persist_task(storage: &'static NvmcStorage) {
255        loop {
256            let (pk, payload, len) = IDENTITY_SIGNAL.wait().await;
257            if storage.peer_exists(&pk).await.unwrap_or(false) {
258                let _ = storage.update_peer_identity(&pk, &payload[..len]).await;
259            }
260        }
261    }
262
263    /// Drives the MAC coordinator and owns the identity-relay subscription.
264    /// Independent of USB so radio RX/TX and the MAC pump (including ping
265    /// auto-replies) keep running whether or not a host terminal is attached.
266    #[embassy_executor::task]
267    async fn mac_task(mut host: TechoHost, identity_id: LocalIdentityId) {
268        // Subscribe to raw packets so NodeIdentity payloads from known peers
269        // can be relayed to identity_persist_task for durable storage.
270        // The guard must remain live for the duration of the task.
271        let sub_node = host.node(identity_id).expect("node just added");
272        let _identity_sub = sub_node.on_receive(|pkt| {
273            if pkt.payload_type() != PayloadType::NodeIdentity {
274                return false;
275            }
276            let Some(from) = pkt.from_key() else {
277                return false;
278            };
279            let raw = pkt.payload();
280            let len = raw.len().min(256);
281            let mut buf = [0u8; 256];
282            buf[..len].copy_from_slice(&raw[..len]);
283            IDENTITY_SIGNAL.signal((from.0, buf, len));
284            false // don't consume — let other handlers see it too
285        });
286
287        let _ = host.run().await;
288        panic!("host exited");
289    }
290
291    /// Runs the `CliSession` over USB-CDC. The only task that blocks on a host
292    /// terminal connection — the radio, MAC pump, and identity relay all run
293    /// without it.
294    #[embassy_executor::task]
295    async fn cli_task(
296        node: TechoNode,
297        local_key: PublicKey,
298        storage: &'static NvmcStorage,
299        rx: TechoRescue,
300        prev_panic_buf: &'static [u8; 256],
301        prev_panic_len: usize,
302    ) {
303        use umsh_cli::CliSession;
304        use umsh_cli::io::CliOutput;
305        use umsh_cli::logger::NullLogger;
306
307        let mut input = cli_io::CdcInput::new(rx);
308        let mut out = cli_io::CdcOutput::new();
309
310        // Wait for the host to open the CDC port before writing the banner.
311        input.wait_connection().await;
312
313        let _ = out.write_line("").await;
314        let _ = out.write_line("UMSH CLI (T-Echo)").await;
315        let _ = out.write_line("type /help for commands").await;
316        if prev_panic_len > 0 {
317            let _ = out.write_line("[PREV PANIC]:").await;
318            if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
319                let _ = out.write_line(s).await;
320            }
321        }
322
323        let peer_store = NvmcPeerStore::new(storage);
324        let channel_store = NvmcChannelStore::new(storage);
325        let mut cli: CliSession<_, _, _, _, _, _, 4, 4, 2, 8, 128> = CliSession::new(
326            node,
327            local_key,
328            out,
329            NullLogger::new(),
330            peer_store,
331            channel_store,
332            PowerSignaler,
333        );
334
335        // `run` loads peers/channels from storage and registers them with the
336        // MAC (idempotent) and the CLI display tables before entering the loop.
337        let _ = cli.run(&mut input).await;
338        panic!("cli exited");
339    }
340
341    /// Owns the e-paper SPI bus and pins. Renders the boot screen on
342    /// startup, then waits for `DISPLAY_COUNT_SIGNAL` and re-renders with
343    /// the latest count.
344    ///
345    /// Full refresh (with flashing) per update; partial refresh on this
346    /// panel requires RED-RAM previous-frame tracking which is a separate
347    /// change. `DISPLAY_THROTTLE` caps the visible refresh rate.
348    #[embassy_executor::task]
349    async fn display_task(
350        mut spi: Spim<'static>,
351        mut cs: Output<'static>,
352        mut dc: Output<'static>,
353        mut rst: Output<'static>,
354        mut busy: Input<'static>,
355    ) {
356        use core::fmt::Write as _;
357        use embedded_graphics::Drawable;
358        use embedded_graphics::geometry::Point;
359        use embedded_graphics::mono_font::MonoTextStyle;
360        use embedded_graphics::mono_font::ascii::FONT_10X20;
361        use embedded_graphics::pixelcolor::BinaryColor;
362        use embedded_graphics::text::{Baseline, Text};
363        use heapless::String;
364
365        let sha = env!("GIT_SHORT_SHA");
366        let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
367
368        // Fill `buf` with a frame containing the boot text and the supplied count.
369        let mut buf = [0xFFu8; display::BUF_SIZE];
370        let render = |buf: &mut [u8; display::BUF_SIZE], count: u32| {
371            buf.fill(0xFF); // all-white background
372            let mut fb = display::EpdFb(buf);
373
374            // Center each line by its glyph count.
375            let center_x = |text: &str| (display::WIDTH as i32 - text.len() as i32 * FONT_W) / 2;
376
377            let title = "UMSH bringup";
378            let _ = Text::with_baseline(
379                title,
380                Point::new(center_x(title), TITLE_Y),
381                style,
382                Baseline::Top,
383            )
384            .draw(&mut fb);
385            let _ =
386                Text::with_baseline(sha, Point::new(center_x(sha), SHA_Y), style, Baseline::Top)
387                    .draw(&mut fb);
388
389            let mut count_str: String<16> = String::new();
390            let _ = write!(count_str, "MAC: {}", count);
391            let _ = Text::with_baseline(
392                &count_str,
393                Point::new(center_x(&count_str), COUNT_Y),
394                style,
395                Baseline::Top,
396            )
397            .draw(&mut fb);
398        };
399
400        // Renders centred lines (one per slice element) onto an all-white frame.
401        let render_lines = |buf: &mut [u8; display::BUF_SIZE], lines: &[(&str, i32)]| {
402            buf.fill(0xFF);
403            let mut fb = display::EpdFb(buf);
404            for (text, y) in lines {
405                let cx = (display::WIDTH as i32 - text.len() as i32 * FONT_W) / 2;
406                let _ = Text::with_baseline(text, Point::new(cx, *y), style, Baseline::Top)
407                    .draw(&mut fb);
408            }
409        };
410
411        // Initial boot screen (count = 0).
412        render(&mut buf, 0);
413        display::init(&mut spi, &mut cs, &mut dc, &mut rst, &mut busy).await;
414        display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
415
416        // Update loop. Races count updates against the shutdown signal.
417        // We deliberately do NOT reset DISPLAY_COUNT_SIGNAL after the
418        // throttle: any packet that fired during render+throttle stays
419        // pending, so the next iteration starts immediately with the
420        // newest count. Throttle still caps the refresh rate.
421        loop {
422            match select(DISPLAY_COUNT_SIGNAL.wait(), DISPLAY_SHUTDOWN_SIGNAL.wait()).await {
423                Either::First(()) => {
424                    let count = PACKET_COUNT.load(Ordering::Relaxed);
425                    render(&mut buf, count);
426                    display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
427                    Timer::after(DISPLAY_THROTTLE).await;
428                }
429                Either::Second(()) => {
430                    // Final frame, then deep sleep (RAM-retaining; the panel
431                    // wakes via hardware reset on the next boot).
432                    render_lines(&mut buf, &[("Powered off", 100)]);
433                    display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
434                    display::sleep(&mut spi, &mut cs, &mut dc).await;
435                    DISPLAY_SHUTDOWN_DONE.signal(());
436                    // Park forever; the shutdown task will System OFF shortly.
437                    core::future::pending::<()>().await;
438                }
439            }
440        }
441    }
442
443    /// Long-press watcher for the user button on P1.10 (active-low, pull-up).
444    /// Two-second hold fires [`SHUTDOWN_SIGNAL`]. Releases before 2 s are
445    /// ignored — there's no short-press action defined yet.
446    #[embassy_executor::task]
447    async fn button_task(mut button: Input<'static>) {
448        const HOLD: Duration = Duration::from_secs(2);
449        loop {
450            button.wait_for_low().await;
451            match select(button.wait_for_high(), Timer::after(HOLD)).await {
452                Either::First(()) => {
453                    // Released before HOLD — no-op.
454                }
455                Either::Second(()) => {
456                    SHUTDOWN_SIGNAL.signal(());
457                    // Wait for release so we don't keep re-triggering.
458                    button.wait_for_high().await;
459                }
460            }
461        }
462    }
463
464    /// Orchestrates the controlled power-off:
465    ///   1. tell the display to render the final frame and sleep,
466    ///   2. flush any pending TX frame-counter reservations (RX counters are
467    ///      drained on every `next_event` in the parallel host task),
468    ///   3. wait for the display task to acknowledge (cap at 5 s),
469    ///   4. drop the peripheral power rail (P0.12) so LoRa / sensors / GNSS
470    ///      lose power before the chip parks,
471    ///   5. configure user-button DETECT-low and enter System OFF.
472    ///
473    /// Diverges via [`power_off`].
474    #[embassy_executor::task]
475    async fn shutdown_task(
476        mac_cell: &'static AsyncRefCell<TechoMac>,
477        peripheral_power: Output<'static>,
478    ) -> ! {
479        SHUTDOWN_SIGNAL.wait().await;
480
481        DISPLAY_SHUTDOWN_SIGNAL.signal(());
482
483        let handle = MacHandle::new(mac_cell);
484        let _ = handle.service_counter_persistence().await;
485
486        let _ = select(
487            DISPLAY_SHUTDOWN_DONE.wait(),
488            Timer::after(Duration::from_secs(5)),
489        )
490        .await;
491
492        // Tri-state all peripheral signal pins before cutting power.
493        //
494        // Two reasons:
495        //   1. Output pins driving into an unpowered peripheral leak current
496        //      through ESD diodes back onto its unpowered VCC rail.
497        //   2. Input pins with PIN_CNF SENSE configured by embassy's async
498        //      GPIO layer (e.g. radio DIO1 / BUSY mid-wait) will fire DETECT
499        //      and immediately wake the chip from System OFF.
500        //
501        // tristate_pin() writes PIN_CNF = 0x02 (DIR=input, INPUT=disconnect,
502        // PULL=none, DRIVE=0, SENSE=disabled) — clearing any SENSE bits.
503        //
504        // The status LED is active-low and still owned by the blink task;
505        // a driven level is retained through System OFF, and the RGB LED
506        // hangs off the always-on rail rather than the switched one. Nothing
507        // below this point awaits, so the blink task cannot take it back.
508        drive_pin_high(Port::P0, 14);
509
510        // E-paper SPI bus (SPIM2): SCK=P0.31, MISO=P1.07, MOSI=P0.29
511        // E-paper control:         CS=P0.30, DC=P0.28, RST=P0.02, BUSY=P0.03
512        // Radio SPI bus (TWISPI1): SCK=P0.19, MOSI=P0.22, MISO=P0.23
513        // Radio control:           CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
514        for (port, pin) in [
515            (Port::P0, 31u8), // e-paper SCK
516            (Port::P1, 7u8),  // e-paper MOSI
517            (Port::P0, 29u8), // e-paper MISO
518            (Port::P0, 30u8), // e-paper CS
519            (Port::P0, 28u8), // e-paper DC
520            (Port::P0, 2u8),  // e-paper RST
521            (Port::P0, 3u8),  // e-paper BUSY
522            (Port::P0, 19u8), // radio SCK
523            (Port::P0, 22u8), // radio MOSI
524            (Port::P0, 23u8), // radio MISO
525            (Port::P0, 24u8), // radio CS
526            (Port::P0, 25u8), // radio RST
527            (Port::P0, 17u8), // radio BUSY
528            (Port::P0, 20u8), // radio DIO1  ← has SENSE set by async radio wait
529        ] {
530            tristate_pin(port, pin);
531        }
532
533        // Drop the peripheral rail so the LoRa module, GNSS, sensors, and
534        // e-paper bias generator all lose power before we enter System OFF.
535        //
536        // Dropping the `Output` alone is not enough: embassy writes
537        // PIN_CNF = INPUT:Disconnect with no pull, which leaves the
538        // active-high rail enable floating rather than off. Pin it low.
539        drop(peripheral_power);
540        drive_pin_low(Port::P0, 12);
541
542        // P1.10 is the side user button. Active-low, pull-up → DETECT-low wakes.
543        power_off(&[WakePin {
544            port: Port::P1,
545            pin: 10,
546            sense: WakeSense::Low,
547        }])
548    }
549
550    // ─── Main ────────────────────────────────────────────────────────────────
551
552    #[embassy_executor::main]
553    async fn main(spawner: Spawner) {
554        // Initialize the heap allocator before any alloc-using code runs.
555        // 4 KiB is negligible on nRF52840 (256 KiB RAM); actual runtime
556        // alloc usage is near-zero since we don't create a MacHandle.
557        {
558            use core::mem::MaybeUninit;
559            const HEAP_SIZE: usize = 8192;
560            static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
561            unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
562        }
563
564        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
565
566        // Peripheral power enable (P0.12). Must be high before display, LoRa,
567        // or GNSS is addressed, including on battery power. Ownership is later
568        // transferred to `shutdown_task` so it can drop the rail before entering
569        // System OFF.
570        let peripheral_power = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
571
572        // WDT: 8 s timeout, petted by the heartbeat task every ~2 s.
573        let mut wdt_config = WdtConfig::default();
574        wdt_config.timeout_ticks = 32768 * 8;
575        let (_wdt, [wdt_handle]) =
576            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
577
578        // Pick up any panic message left by the previous boot.
579        static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
580        let mut prev_panic_tmp = [0u8; 256];
581        let prev_panic_len = {
582            let mut slot = PanicSlot::new(super::panic::panic_region());
583            if let Some(msg) = slot.read() {
584                let n = msg.len().min(prev_panic_tmp.len());
585                prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
586                slot.clear();
587                n
588            } else {
589                0
590            }
591        };
592        let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
593
594        // ── E-paper display task ──────────────────────────────────────────────
595        // P1.11 is the e-paper backlight on this module; drive it LOW
596        // explicitly so leakage / external pullups can't turn it on.
597        let _backlight = Output::new(p.P1_11, Level::Low, OutputDrive::Standard);
598        {
599            let mut cfg = SpimConfig::default();
600            cfg.frequency = Frequency::M4;
601            let disp_spi = Spim::new(p.SPI2, Irqs, p.P0_31, p.P1_07, p.P0_29, cfg);
602            let disp_cs = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
603            let disp_dc = Output::new(p.P0_28, Level::Low, OutputDrive::Standard);
604            let disp_rst = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
605            let disp_busy = Input::new(p.P0_03, Pull::None);
606            spawner.spawn(display_task(disp_spi, disp_cs, disp_dc, disp_rst, disp_busy).unwrap());
607        }
608
609        // ── SX1262 LoRa radio ────────────────────────────────────────────────
610        // Pin assignment (T-Echo hardware, firmware-confirmed):
611        //   SPI bus: SCK=P0.19, MOSI=P0.22, MISO=P0.23 (TWISPI1)
612        //   CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
613        //   DIO2: internal RF switch (lora-phy sends SetDIO2AsRfSwitchCtrl).
614        //   DIO3: 1.8 V TCXO (lora-phy sends SetDIO3AsTcxoCtrl).
615        let t_frame_ms = umsh_radio_loraphy::airtime_ms(
616            SpreadingFactor::_7,
617            Bandwidth::_62KHz,
618            umsh_radio_loraphy::MAX_PAYLOAD,
619        );
620        {
621            let mut cfg = SpimConfig::default();
622            // SX1262 datasheet §8.2: max SCK = 16 MHz, Mode 0 (CPOL=0, CPHA=0).
623            cfg.frequency = Frequency::M16;
624            let radio_bus = Spim::new(
625                p.TWISPI1, Irqs, p.P0_19, // SCK
626                p.P0_23, // MISO
627                p.P0_22, // MOSI
628                cfg,
629            );
630            let radio_cs = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
631            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
632
633            let radio_rst = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
634            let radio_dio1 = Input::new(p.P0_20, Pull::None);
635            let radio_busy = Input::new(p.P0_17, Pull::None);
636
637            let iv = GenericSx126xInterfaceVariant::new(
638                radio_rst, radio_dio1, radio_busy,
639                None, // rf_switch_rx: DIO2 wired internally on the T-Echo module
640                None, // rf_switch_tx: same
641            )
642            .unwrap();
643
644            let lora_config = LoraConfig {
645                chip: Sx1262,
646                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
647                use_dcdc: true, // T-Echo SX1262 module has DC-DC converter
648                rx_boost: true, // boosted LNA gain per MeshCore SX126X_RX_BOOSTED_GAIN=1
649            };
650
651            // enable_public_network=false → sync word 0x1424 (private),
652            // matching MeshCore's RADIOLIB_SX126X_SYNC_WORD_PRIVATE = 0x12.
653            let mut lora = LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay)
654                .await
655                .unwrap_or_else(|_| panic!("radio init"));
656
657            let (mdltn, rx_pkt, tx_pkt) = umsh_radio_loraphy::meshcore_us_params(&mut lora)
658                .unwrap_or_else(|_| panic!("radio params"));
659
660            spawner.spawn(radio_runner_task(lora, mdltn, rx_pkt, tx_pkt).unwrap());
661        }
662
663        // ── NV storage ────────────────────────────────────────────────────────
664        let storage: &'static NvmcStorage =
665            STORAGE.init(flash_store::new_storage(Nvmc::new(p.NVMC)));
666
667        // ── MAC coordinator ───────────────────────────────────────────────────
668        // The hardware-TRNG RNG built here is the single RNG path for this
669        // firmware — used for first-boot identity generation AND passed
670        // ownership-by-value into `Mac::new` below as `Platform::Rng`.
671        //
672        // Load identity from flash on subsequent boots; TRNG-generate on
673        // first boot. We do NOT fall back to any PRNG on failure — a
674        // predictable long-term key is worse than refusing to start.
675        let mut rng = Nrf52840Rng::new(p.RNG);
676        let sk_bytes: [u8; 32] = match storage.load_sk().await {
677            Ok(Some(sk)) => sk,
678            Ok(None) => {
679                let mut sk = [0u8; 32];
680                rng.fill_bytes(&mut sk);
681                storage
682                    .store_sk(&sk)
683                    .await
684                    .unwrap_or_else(|_| panic!("identity persist"));
685                sk
686            }
687            Err(_) => panic!("storage init failed"),
688        };
689        let identity = SoftwareIdentity::from_secret_bytes(&sk_bytes);
690        let local_key = *identity.public_key();
691
692        let radio_handle = umsh_radio_loraphy::LoraphyRadio::new(&RADIO_CH, t_frame_ms);
693        let crypto = CryptoEngine::new(SoftwareAes, SoftwareSha256);
694        let mut mac = TechoMac::new(
695            radio_handle,
696            crypto,
697            EmbassyClock,
698            rng,
699            NvmcCounterStore::new(storage),
700            RepeaterConfig::default(),
701            OperatingPolicy::default(),
702        );
703        let identity_id = mac
704            .add_identity(identity)
705            .unwrap_or_else(|_| panic!("identity"));
706        // Restore the TX frame-counter boundary so the counter never rewinds.
707        mac.load_persisted_counter(identity_id)
708            .await
709            .unwrap_or_else(|_| panic!("tx counter load"));
710        let mac_cell: &'static AsyncRefCell<TechoMac> = MAC_CELL.init(AsyncRefCell::new(mac));
711
712        // ── Host + node + boot-time peer/channel registration ─────────────────
713        // Build the Host/node here so the MAC pump (`mac_task`) is independent
714        // of USB, and register persisted peer/channel keys into the MAC now —
715        // not from the CLI task, which only runs after a host opens the CDC
716        // port. Without this the coordinator had no keys until a serial client
717        // attached, so it couldn't authenticate inbound secure frames and
718        // silently dropped every ping.
719        let handle = MacHandle::new(mac_cell);
720        let mut host: TechoHost = Host::new(handle);
721        let node = host.add_node(identity_id);
722
723        {
724            let mut peer_buf: heapless::Vec<([u8; 32], Option<heapless::String<16>>), 8> =
725                heapless::Vec::new();
726            let _ = storage.load_all_peers(&mut peer_buf).await;
727            let mut ch_buf: heapless::Vec<(heapless::String<16>, [u8; 32]), 2> =
728                heapless::Vec::new();
729            let _ = storage.load_all_channels(&mut ch_buf).await;
730            for (pk, _alias) in peer_buf.iter() {
731                let _ = node.peer(PublicKey(*pk)).await;
732            }
733            for (name, key_bytes) in ch_buf.iter() {
734                let channel = Channel::private(ChannelKey(*key_bytes), name.as_str());
735                let _ = node.join(&channel).await;
736            }
737        }
738        // Restore RX counter boundaries after peer registration so the persisted
739        // boundaries land on registered peers.
740        MacHandle::new(mac_cell)
741            .load_all_persisted_rx_counters()
742            .await
743            .ok();
744
745        // ── USB stack + steady-state services ────────────────────────────────
746        let led = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
747        let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
748
749        let mut config = Config::new(0x16c0, 0x27dd);
750        config.manufacturer = Some("UMSH");
751        config.product = Some("T-Echo Bringup");
752        config.serial_number = Some("techo-console");
753        config.max_power = 100;
754        config.max_packet_size_0 = 64;
755
756        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
757        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
758        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
759        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
760        static STATE: StaticCell<State> = StaticCell::new();
761
762        let mut builder = Builder::new(
763            driver,
764            config,
765            CONFIG_DESC.init([0; 256]),
766            BOS_DESC.init([0; 256]),
767            MSOS_DESC.init([0; 0]),
768            CONTROL_BUF.init([0; 64]),
769        );
770
771        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
772        let mut usb = builder.build();
773
774        let (tx, raw_rx, ctrl) = class.split_with_control();
775        let rx = CdcAcmRescue::new(raw_rx, ctrl);
776
777        spawner.spawn(output_task(tx).unwrap());
778        spawner.spawn(identity_persist_task(storage).unwrap());
779        spawner.spawn(mac_task(host, identity_id).unwrap());
780        spawner
781            .spawn(cli_task(node, local_key, storage, rx, prev_panic_buf, prev_panic_len).unwrap());
782
783        // User button (P1.10, active-low). Pull-up so DETECT can wake from
784        // System OFF on the falling edge.
785        let button = Input::new(p.P1_10, Pull::Up);
786        spawner.spawn(button_task(button).unwrap());
787        spawner.spawn(shutdown_task(mac_cell, peripheral_power).unwrap());
788
789        join(usb.run(), heartbeat(led, wdt_handle)).await;
790    }
791
792    // ─── Heartbeat + WDT pet ─────────────────────────────────────────────────
793
794    async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
795        let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
796        loop {
797            wdt.pet();
798            let decision = engine.tick(Instant::now().as_millis());
799            // P0.14 is active-low: set_low() = LED on.
800            if decision.on {
801                led.set_low()
802            } else {
803                led.set_high()
804            }
805            Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
806        }
807    }
808}