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}