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, None)
231 .await;
232 }
233
234 // ─── Concrete USB driver types ────────────────────────────────────────────
235 // ('static lifetime, VbusDetect = HardwareVbusDetect.) Used by `umsh_task`
236 // and `output_task`.
237 type TechoUsbDriver = Driver<'static, HardwareVbusDetect>;
238 type TechoSender = embassy_usb::class::cdc_acm::Sender<'static, TechoUsbDriver>;
239 type TechoRescue = umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue<'static, TechoUsbDriver>;
240
241 // ─── CliSession-backed combined task ─────────────────────────────────────
242
243 /// Owns the USB `Sender` and drains `cli_io::OUTPUT_CH`. Decoupling the
244 /// sender from `umsh_task` lets RX keep flowing while TX awaits host IN
245 /// polls, so USB OUT NAKs handle backpressure correctly during pastes.
246 #[embassy_executor::task]
247 async fn output_task(mut tx: TechoSender) {
248 cli_io::drain_to_sender(&mut tx).await;
249 }
250
251 /// Drains `IDENTITY_SIGNAL` and persists received `NodeIdentityPayload`
252 /// bytes for known peers. Runs independently of the MAC/CLI task so that
253 /// NVMC writes (which stall the CPU for ~85 ms) don't affect radio timing.
254 #[embassy_executor::task]
255 async fn identity_persist_task(storage: &'static NvmcStorage) {
256 loop {
257 let (pk, payload, len) = IDENTITY_SIGNAL.wait().await;
258 if storage.peer_exists(&pk).await.unwrap_or(false) {
259 let _ = storage.update_peer_identity(&pk, &payload[..len]).await;
260 }
261 }
262 }
263
264 /// Drives the MAC coordinator and owns the identity-relay subscription.
265 /// Independent of USB so radio RX/TX and the MAC pump (including ping
266 /// auto-replies) keep running whether or not a host terminal is attached.
267 #[embassy_executor::task]
268 async fn mac_task(mut host: TechoHost, identity_id: LocalIdentityId) {
269 // Subscribe to raw packets so NodeIdentity payloads from known peers
270 // can be relayed to identity_persist_task for durable storage.
271 // The guard must remain live for the duration of the task.
272 let sub_node = host.node(identity_id).expect("node just added");
273 let _identity_sub = sub_node.on_receive(|pkt| {
274 if pkt.payload_type() != PayloadType::NodeIdentity {
275 return false;
276 }
277 let Some(from) = pkt.from_key() else {
278 return false;
279 };
280 let raw = pkt.payload();
281 let len = raw.len().min(256);
282 let mut buf = [0u8; 256];
283 buf[..len].copy_from_slice(&raw[..len]);
284 IDENTITY_SIGNAL.signal((from.0, buf, len));
285 false // don't consume — let other handlers see it too
286 });
287
288 let _ = host.run().await;
289 panic!("host exited");
290 }
291
292 /// Runs the `CliSession` over USB-CDC. The only task that blocks on a host
293 /// terminal connection — the radio, MAC pump, and identity relay all run
294 /// without it.
295 #[embassy_executor::task]
296 async fn cli_task(
297 node: TechoNode,
298 local_key: PublicKey,
299 storage: &'static NvmcStorage,
300 rx: TechoRescue,
301 prev_panic_buf: &'static [u8; 256],
302 prev_panic_len: usize,
303 ) {
304 use umsh_cli::CliSession;
305 use umsh_cli::io::CliOutput;
306 use umsh_cli::logger::NullLogger;
307
308 let mut input = cli_io::CdcInput::new(rx);
309 let mut out = cli_io::CdcOutput::new();
310
311 // Wait for the host to open the CDC port before writing the banner.
312 input.wait_connection().await;
313
314 let _ = out.write_line("").await;
315 let _ = out.write_line("UMSH CLI (T-Echo)").await;
316 let _ = out.write_line("type /help for commands").await;
317 if prev_panic_len > 0 {
318 let _ = out.write_line("[PREV PANIC]:").await;
319 if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
320 let _ = out.write_line(s).await;
321 }
322 }
323
324 let peer_store = NvmcPeerStore::new(storage);
325 let channel_store = NvmcChannelStore::new(storage);
326 let mut cli: CliSession<_, _, _, _, _, _, 4, 4, 2, 8, 128> = CliSession::new(
327 node,
328 local_key,
329 out,
330 NullLogger::new(),
331 peer_store,
332 channel_store,
333 PowerSignaler,
334 );
335
336 // `run` loads peers/channels from storage and registers them with the
337 // MAC (idempotent) and the CLI display tables before entering the loop.
338 let _ = cli.run(&mut input).await;
339 panic!("cli exited");
340 }
341
342 /// Owns the e-paper SPI bus and pins. Renders the boot screen on
343 /// startup, then waits for `DISPLAY_COUNT_SIGNAL` and re-renders with
344 /// the latest count.
345 ///
346 /// Full refresh (with flashing) per update; partial refresh on this
347 /// panel requires RED-RAM previous-frame tracking which is a separate
348 /// change. `DISPLAY_THROTTLE` caps the visible refresh rate.
349 #[embassy_executor::task]
350 async fn display_task(
351 mut spi: Spim<'static>,
352 mut cs: Output<'static>,
353 mut dc: Output<'static>,
354 mut rst: Output<'static>,
355 mut busy: Input<'static>,
356 ) {
357 use core::fmt::Write as _;
358 use embedded_graphics::Drawable;
359 use embedded_graphics::geometry::Point;
360 use embedded_graphics::mono_font::MonoTextStyle;
361 use embedded_graphics::mono_font::ascii::FONT_10X20;
362 use embedded_graphics::pixelcolor::BinaryColor;
363 use embedded_graphics::text::{Baseline, Text};
364 use heapless::String;
365
366 let sha = env!("GIT_SHORT_SHA");
367 let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On);
368
369 // Fill `buf` with a frame containing the boot text and the supplied count.
370 let mut buf = [0xFFu8; display::BUF_SIZE];
371 let render = |buf: &mut [u8; display::BUF_SIZE], count: u32| {
372 buf.fill(0xFF); // all-white background
373 let mut fb = display::EpdFb(buf);
374
375 // Center each line by its glyph count.
376 let center_x = |text: &str| (display::WIDTH as i32 - text.len() as i32 * FONT_W) / 2;
377
378 let title = "UMSH bringup";
379 let _ = Text::with_baseline(
380 title,
381 Point::new(center_x(title), TITLE_Y),
382 style,
383 Baseline::Top,
384 )
385 .draw(&mut fb);
386 let _ =
387 Text::with_baseline(sha, Point::new(center_x(sha), SHA_Y), style, Baseline::Top)
388 .draw(&mut fb);
389
390 let mut count_str: String<16> = String::new();
391 let _ = write!(count_str, "MAC: {}", count);
392 let _ = Text::with_baseline(
393 &count_str,
394 Point::new(center_x(&count_str), COUNT_Y),
395 style,
396 Baseline::Top,
397 )
398 .draw(&mut fb);
399 };
400
401 // Renders centered lines (one per slice element) onto an all-white frame.
402 let render_lines = |buf: &mut [u8; display::BUF_SIZE], lines: &[(&str, i32)]| {
403 buf.fill(0xFF);
404 let mut fb = display::EpdFb(buf);
405 for (text, y) in lines {
406 let cx = (display::WIDTH as i32 - text.len() as i32 * FONT_W) / 2;
407 let _ = Text::with_baseline(text, Point::new(cx, *y), style, Baseline::Top)
408 .draw(&mut fb);
409 }
410 };
411
412 // Initial boot screen (count = 0).
413 render(&mut buf, 0);
414 display::init(&mut spi, &mut cs, &mut dc, &mut rst, &mut busy).await;
415 display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
416
417 // Update loop. Races count updates against the shutdown signal.
418 // We deliberately do NOT reset DISPLAY_COUNT_SIGNAL after the
419 // throttle: any packet that fired during render+throttle stays
420 // pending, so the next iteration starts immediately with the
421 // newest count. Throttle still caps the refresh rate.
422 loop {
423 match select(DISPLAY_COUNT_SIGNAL.wait(), DISPLAY_SHUTDOWN_SIGNAL.wait()).await {
424 Either::First(()) => {
425 let count = PACKET_COUNT.load(Ordering::Relaxed);
426 render(&mut buf, count);
427 display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
428 Timer::after(DISPLAY_THROTTLE).await;
429 }
430 Either::Second(()) => {
431 // Final frame, then deep sleep (RAM-retaining; the panel
432 // wakes via hardware reset on the next boot).
433 render_lines(&mut buf, &[("Powered off", 100)]);
434 display::render(&mut spi, &mut cs, &mut dc, &mut busy, &buf).await;
435 display::sleep(&mut spi, &mut cs, &mut dc).await;
436 DISPLAY_SHUTDOWN_DONE.signal(());
437 // Park forever; the shutdown task will System OFF shortly.
438 core::future::pending::<()>().await;
439 }
440 }
441 }
442 }
443
444 /// Long-press watcher for the user button on P1.10 (active-low, pull-up).
445 /// Two-second hold fires [`SHUTDOWN_SIGNAL`]. Releases before 2 s are
446 /// ignored — there's no short-press action defined yet.
447 #[embassy_executor::task]
448 async fn button_task(mut button: Input<'static>) {
449 const HOLD: Duration = Duration::from_secs(2);
450 loop {
451 button.wait_for_low().await;
452 match select(button.wait_for_high(), Timer::after(HOLD)).await {
453 Either::First(()) => {
454 // Released before HOLD — no-op.
455 }
456 Either::Second(()) => {
457 SHUTDOWN_SIGNAL.signal(());
458 // Wait for release so we don't keep re-triggering.
459 button.wait_for_high().await;
460 }
461 }
462 }
463 }
464
465 /// Orchestrates the controlled power-off:
466 /// 1. tell the display to render the final frame and sleep,
467 /// 2. flush any pending TX frame-counter reservations (RX counters are
468 /// drained on every `next_event` in the parallel host task),
469 /// 3. wait for the display task to acknowledge (cap at 5 s),
470 /// 4. drop the peripheral power rail (P0.12) so LoRa / sensors / GNSS
471 /// lose power before the chip parks,
472 /// 5. configure user-button DETECT-low and enter System OFF.
473 ///
474 /// Diverges via [`power_off`].
475 #[embassy_executor::task]
476 async fn shutdown_task(
477 mac_cell: &'static AsyncRefCell<TechoMac>,
478 peripheral_power: Output<'static>,
479 ) -> ! {
480 SHUTDOWN_SIGNAL.wait().await;
481
482 DISPLAY_SHUTDOWN_SIGNAL.signal(());
483
484 let handle = MacHandle::new(mac_cell);
485 let _ = handle.service_counter_persistence().await;
486
487 let _ = select(
488 DISPLAY_SHUTDOWN_DONE.wait(),
489 Timer::after(Duration::from_secs(5)),
490 )
491 .await;
492
493 // Tri-state all peripheral signal pins before cutting power.
494 //
495 // Two reasons:
496 // 1. Output pins driving into an unpowered peripheral leak current
497 // through ESD diodes back onto its unpowered VCC rail.
498 // 2. Input pins with PIN_CNF SENSE configured by embassy's async
499 // GPIO layer (e.g. radio DIO1 / BUSY mid-wait) will fire DETECT
500 // and immediately wake the chip from System OFF.
501 //
502 // tristate_pin() writes PIN_CNF = 0x02 (DIR=input, INPUT=disconnect,
503 // PULL=none, DRIVE=0, SENSE=disabled) — clearing any SENSE bits.
504 //
505 // The status LED is active-low and still owned by the blink task;
506 // a driven level is retained through System OFF, and the RGB LED
507 // hangs off the always-on rail rather than the switched one. Nothing
508 // below this point awaits, so the blink task cannot take it back.
509 drive_pin_high(Port::P0, 14);
510
511 // E-paper SPI bus (SPIM2): SCK=P0.31, MISO=P1.07, MOSI=P0.29
512 // E-paper control: CS=P0.30, DC=P0.28, RST=P0.02, BUSY=P0.03
513 // Radio SPI bus (TWISPI1): SCK=P0.19, MOSI=P0.22, MISO=P0.23
514 // Radio control: CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
515 for (port, pin) in [
516 (Port::P0, 31u8), // e-paper SCK
517 (Port::P1, 7u8), // e-paper MOSI
518 (Port::P0, 29u8), // e-paper MISO
519 (Port::P0, 30u8), // e-paper CS
520 (Port::P0, 28u8), // e-paper DC
521 (Port::P0, 2u8), // e-paper RST
522 (Port::P0, 3u8), // e-paper BUSY
523 (Port::P0, 19u8), // radio SCK
524 (Port::P0, 22u8), // radio MOSI
525 (Port::P0, 23u8), // radio MISO
526 (Port::P0, 24u8), // radio CS
527 (Port::P0, 25u8), // radio RST
528 (Port::P0, 17u8), // radio BUSY
529 (Port::P0, 20u8), // radio DIO1 ← has SENSE set by async radio wait
530 ] {
531 tristate_pin(port, pin);
532 }
533
534 // Drop the peripheral rail so the LoRa module, GNSS, sensors, and
535 // e-paper bias generator all lose power before we enter System OFF.
536 //
537 // Dropping the `Output` alone is not enough: embassy writes
538 // PIN_CNF = INPUT:Disconnect with no pull, which leaves the
539 // active-high rail enable floating rather than off. Pin it low.
540 drop(peripheral_power);
541 drive_pin_low(Port::P0, 12);
542
543 // P1.10 is the side user button. Active-low, pull-up → DETECT-low wakes.
544 power_off(&[WakePin {
545 port: Port::P1,
546 pin: 10,
547 sense: WakeSense::Low,
548 }])
549 }
550
551 // ─── Main ────────────────────────────────────────────────────────────────
552
553 #[embassy_executor::main]
554 async fn main(spawner: Spawner) {
555 // Initialize the heap allocator before any alloc-using code runs.
556 // 4 KiB is negligible on nRF52840 (256 KiB RAM); actual runtime
557 // alloc usage is near-zero since we don't create a MacHandle.
558 {
559 use core::mem::MaybeUninit;
560 const HEAP_SIZE: usize = 8192;
561 static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
562 unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
563 }
564
565 let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
566
567 // Peripheral power enable (P0.12). Must be high before display, LoRa,
568 // or GNSS is addressed, including on battery power. Ownership is later
569 // transferred to `shutdown_task` so it can drop the rail before entering
570 // System OFF.
571 let peripheral_power = Output::new(p.P0_12, Level::High, OutputDrive::Standard);
572
573 // WDT: 8 s timeout, petted by the heartbeat task every ~2 s.
574 let mut wdt_config = WdtConfig::default();
575 wdt_config.timeout_ticks = 32768 * 8;
576 let (_wdt, [wdt_handle]) =
577 Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
578
579 // Pick up any panic message left by the previous boot.
580 static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
581 let mut prev_panic_tmp = [0u8; 256];
582 let prev_panic_len = {
583 let mut slot = PanicSlot::new(super::panic::panic_region());
584 if let Some(msg) = slot.read() {
585 let n = msg.len().min(prev_panic_tmp.len());
586 prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
587 slot.clear();
588 n
589 } else {
590 0
591 }
592 };
593 let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
594
595 // ── E-paper display task ──────────────────────────────────────────────
596 // P1.11 is the e-paper backlight on this module; drive it LOW
597 // explicitly so leakage / external pullups can't turn it on.
598 let _backlight = Output::new(p.P1_11, Level::Low, OutputDrive::Standard);
599 {
600 let mut cfg = SpimConfig::default();
601 cfg.frequency = Frequency::M4;
602 let disp_spi = Spim::new(p.SPI2, Irqs, p.P0_31, p.P1_07, p.P0_29, cfg);
603 let disp_cs = Output::new(p.P0_30, Level::High, OutputDrive::Standard);
604 let disp_dc = Output::new(p.P0_28, Level::Low, OutputDrive::Standard);
605 let disp_rst = Output::new(p.P0_02, Level::High, OutputDrive::Standard);
606 let disp_busy = Input::new(p.P0_03, Pull::None);
607 spawner.spawn(display_task(disp_spi, disp_cs, disp_dc, disp_rst, disp_busy).unwrap());
608 }
609
610 // ── SX1262 LoRa radio ────────────────────────────────────────────────
611 // Pin assignment (T-Echo hardware, firmware-confirmed):
612 // SPI bus: SCK=P0.19, MOSI=P0.22, MISO=P0.23 (TWISPI1)
613 // CS=P0.24, RST=P0.25, BUSY=P0.17, DIO1=P0.20
614 // DIO2: internal RF switch (lora-phy sends SetDIO2AsRfSwitchCtrl).
615 // DIO3: 1.8 V TCXO (lora-phy sends SetDIO3AsTcxoCtrl).
616 let t_frame_ms = umsh_radio_loraphy::airtime_ms(
617 SpreadingFactor::_7,
618 Bandwidth::_62KHz,
619 umsh_radio_loraphy::MAX_PAYLOAD,
620 );
621 {
622 let mut cfg = SpimConfig::default();
623 // SX1262 datasheet §8.2: max SCK = 16 MHz, Mode 0 (CPOL=0, CPHA=0).
624 cfg.frequency = Frequency::M16;
625 let radio_bus = Spim::new(
626 p.TWISPI1, Irqs, p.P0_19, // SCK
627 p.P0_23, // MISO
628 p.P0_22, // MOSI
629 cfg,
630 );
631 let radio_cs = Output::new(p.P0_24, Level::High, OutputDrive::Standard);
632 let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
633
634 let radio_rst = Output::new(p.P0_25, Level::High, OutputDrive::Standard);
635 let radio_dio1 = Input::new(p.P0_20, Pull::None);
636 let radio_busy = Input::new(p.P0_17, Pull::None);
637
638 let iv = GenericSx126xInterfaceVariant::new(
639 radio_rst, radio_dio1, radio_busy,
640 None, // rf_switch_rx: DIO2 wired internally on the T-Echo module
641 None, // rf_switch_tx: same
642 )
643 .unwrap();
644
645 let lora_config = LoraConfig {
646 chip: Sx1262,
647 tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8), // DIO3 → 1.8 V TCXO
648 use_dcdc: true, // T-Echo SX1262 module has DC-DC converter
649 rx_boost: true, // boosted LNA gain per MeshCore SX126X_RX_BOOSTED_GAIN=1
650 };
651
652 // enable_public_network=false → sync word 0x1424 (private),
653 // matching MeshCore's RADIOLIB_SX126X_SYNC_WORD_PRIVATE = 0x12.
654 let mut lora = LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay)
655 .await
656 .unwrap_or_else(|_| panic!("radio init"));
657
658 let (mdltn, rx_pkt, tx_pkt) = umsh_radio_loraphy::profile_params(
659 &mut lora,
660 umsh_radio_loraphy::profiles::DEFAULT,
661 8,
662 )
663 .unwrap_or_else(|_| panic!("radio params"));
664
665 spawner.spawn(radio_runner_task(lora, mdltn, rx_pkt, tx_pkt).unwrap());
666 }
667
668 // ── NV storage ────────────────────────────────────────────────────────
669 let storage: &'static NvmcStorage =
670 STORAGE.init(flash_store::new_storage(Nvmc::new(p.NVMC)));
671
672 // ── MAC coordinator ───────────────────────────────────────────────────
673 // The hardware-TRNG RNG built here is the single RNG path for this
674 // firmware — used for first-boot identity generation AND passed
675 // ownership-by-value into `Mac::new` below as `Platform::Rng`.
676 //
677 // Load identity from flash on subsequent boots; TRNG-generate on
678 // first boot. We do NOT fall back to any PRNG on failure — a
679 // predictable long-term key is worse than refusing to start.
680 let mut rng = Nrf52840Rng::new(p.RNG);
681 let sk_bytes: [u8; 32] = match storage.load_sk().await {
682 Ok(Some(sk)) => sk,
683 Ok(None) => {
684 let mut sk = [0u8; 32];
685 rng.fill_bytes(&mut sk);
686 storage
687 .store_sk(&sk)
688 .await
689 .unwrap_or_else(|_| panic!("identity persist"));
690 sk
691 }
692 Err(_) => panic!("storage init failed"),
693 };
694 let identity = SoftwareIdentity::from_secret_bytes(&sk_bytes);
695 let local_key = *identity.public_key();
696
697 let radio_handle = umsh_radio_loraphy::LoraphyRadio::new(&RADIO_CH, t_frame_ms);
698 let crypto = CryptoEngine::new(SoftwareAes, SoftwareSha256);
699 let mut mac = TechoMac::new(
700 radio_handle,
701 crypto,
702 EmbassyClock,
703 rng,
704 NvmcCounterStore::new(storage),
705 RepeaterConfig::default(),
706 OperatingPolicy::default(),
707 );
708 let identity_id = mac
709 .add_identity(identity)
710 .unwrap_or_else(|_| panic!("identity"));
711 // Restore the TX frame-counter boundary so the counter never rewinds.
712 mac.load_persisted_counter(identity_id)
713 .await
714 .unwrap_or_else(|_| panic!("tx counter load"));
715 let mac_cell: &'static AsyncRefCell<TechoMac> = MAC_CELL.init(AsyncRefCell::new(mac));
716
717 // ── Host + node + boot-time peer/channel registration ─────────────────
718 // Build the Host/node here so the MAC pump (`mac_task`) is independent
719 // of USB, and register persisted peer/channel keys into the MAC now —
720 // not from the CLI task, which only runs after a host opens the CDC
721 // port. Without this the coordinator had no keys until a serial client
722 // attached, so it couldn't authenticate inbound secure frames and
723 // silently dropped every ping.
724 let handle = MacHandle::new(mac_cell);
725 let mut host: TechoHost = Host::new(handle);
726 let node = host.add_node(identity_id);
727
728 {
729 let mut peer_buf: heapless::Vec<([u8; 32], Option<heapless::String<16>>), 8> =
730 heapless::Vec::new();
731 let _ = storage.load_all_peers(&mut peer_buf).await;
732 let mut ch_buf: heapless::Vec<(heapless::String<16>, [u8; 32]), 2> =
733 heapless::Vec::new();
734 let _ = storage.load_all_channels(&mut ch_buf).await;
735 for (pk, _alias) in peer_buf.iter() {
736 let _ = node.peer(PublicKey(*pk)).await;
737 }
738 for (name, key_bytes) in ch_buf.iter() {
739 let channel = Channel::private(ChannelKey(*key_bytes), name.as_str());
740 let _ = node.join(&channel).await;
741 }
742 }
743 // Restore RX counter boundaries after peer registration so the persisted
744 // boundaries land on registered peers.
745 MacHandle::new(mac_cell)
746 .load_all_persisted_rx_counters()
747 .await
748 .ok();
749
750 // ── USB stack + steady-state services ────────────────────────────────
751 let led = Output::new(p.P0_14, Level::High, OutputDrive::Standard);
752 let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
753
754 let mut config = Config::new(0x16c0, 0x27dd);
755 config.manufacturer = Some("UMSH");
756 config.product = Some("T-Echo Bringup");
757 config.serial_number = Some("techo-console");
758 config.max_power = 100;
759 config.max_packet_size_0 = 64;
760
761 static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
762 static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
763 static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
764 static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
765 static STATE: StaticCell<State> = StaticCell::new();
766
767 let mut builder = Builder::new(
768 driver,
769 config,
770 CONFIG_DESC.init([0; 256]),
771 BOS_DESC.init([0; 256]),
772 MSOS_DESC.init([0; 0]),
773 CONTROL_BUF.init([0; 64]),
774 );
775
776 let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
777 let mut usb = builder.build();
778
779 let (tx, raw_rx, ctrl) = class.split_with_control();
780 let rx = CdcAcmRescue::new(raw_rx, ctrl);
781
782 spawner.spawn(output_task(tx).unwrap());
783 spawner.spawn(identity_persist_task(storage).unwrap());
784 spawner.spawn(mac_task(host, identity_id).unwrap());
785 spawner
786 .spawn(cli_task(node, local_key, storage, rx, prev_panic_buf, prev_panic_len).unwrap());
787
788 // User button (P1.10, active-low). Pull-up so DETECT can wake from
789 // System OFF on the falling edge.
790 let button = Input::new(p.P1_10, Pull::Up);
791 spawner.spawn(button_task(button).unwrap());
792 spawner.spawn(shutdown_task(mac_cell, peripheral_power).unwrap());
793
794 join(usb.run(), heartbeat(led, wdt_handle)).await;
795 }
796
797 // ─── Heartbeat + WDT pet ─────────────────────────────────────────────────
798
799 async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
800 let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
801 loop {
802 wdt.pet();
803 let decision = engine.tick(Instant::now().as_millis());
804 // P0.14 is active-low: set_low() = LED on.
805 if decision.on {
806 led.set_low()
807 } else {
808 led.set_high()
809 }
810 Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
811 }
812 }
813}