firmware_wio_tracker_l1_console/
main.rs

1// Seeed Wio Tracker L1 / L1 Pro bringup firmware with an interactive
2// UMSH CLI on USB-CDC.
3//
4// Boot sequence:
5//   1. Initialize the 8 KiB global heap (umsh-cli/umsh-node alloc usage).
6//   2. Arm the watchdog (8 s timeout).
7//   3. Read any panic message left by the previous boot.
8//   4. Initialize the SH1106 OLED and spawn the display task.
9//   5. Initialize the SX1262 LoRa radio and spawn the radio runner task.
10//   6. Build Mac<WioTrackerPlatform>, park it in a 'static AsyncRefCell, and
11//      spawn umsh_task which drives Host::run + CliSession::run concurrently
12//      over the shared MacHandle.
13//   7. Spawn output_task to own the USB Sender and drain OUTPUT_CH.
14//   8. 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 OLED on boot and MAC count signals
19//   - radio_runner_task:   owns lora_phy::LoRa, RX/TX state machine
20//   - umsh_task:           host.run() + cli.run(), shares 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// Radio pin map (Wio Tracker L1):
28//   SPI:  SCK=P0.30, MISO=P0.03, MOSI=P0.28  (TWISPI1)
29//   CS=P1.14, RST=P1.07, BUSY=P1.10, DIO1=P0.07
30//   RXEN=P1.08 → rf_switch_rx (lora-phy drives HIGH in RX, LOW in TX)
31//   DIO2: internal RF switch (lora-phy SetDIO2AsRfSwitchCtrl)
32//   DIO3: 1.8 V TCXO
33
34#![cfg_attr(target_os = "none", no_std)]
35#![cfg_attr(target_os = "none", no_main)]
36
37#[cfg(not(target_os = "none"))]
38fn main() {}
39
40#[cfg(target_os = "none")]
41mod panic;
42
43#[cfg(target_os = "none")]
44use umsh_bsp_wio_tracker_l1::display;
45
46#[cfg(target_os = "none")]
47mod cli_io;
48
49#[cfg(target_os = "none")]
50mod defmt_logger {
51    #[defmt::global_logger]
52    struct Logger;
53    unsafe impl defmt::Logger for Logger {
54        fn acquire() {}
55        unsafe fn flush() {}
56        unsafe fn release() {}
57        unsafe fn write(_: &[u8]) {}
58    }
59    defmt::timestamp!("{=u32}", 0u32);
60}
61
62// Global heap allocator. umsh-mac → umsh-sync → alloc requires this even
63// though runtime allocation is near-zero (we use Mac::run, not MacHandle).
64#[cfg(target_os = "none")]
65#[global_allocator]
66static ALLOCATOR: embedded_alloc::Heap = embedded_alloc::Heap::empty();
67
68#[cfg(target_os = "none")]
69mod firmware {
70    use core::sync::atomic::{AtomicU32, Ordering};
71
72    use super::display;
73
74    use embassy_executor::Spawner;
75    use embassy_futures::join::join;
76    use embassy_nrf::bind_interrupts;
77    use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
78    use embassy_nrf::nvmc::Nvmc;
79    use embassy_nrf::peripherals;
80    use embassy_nrf::spim::{Config as SpimConfig, Frequency, Spim};
81    use embassy_nrf::twim::{self, Config as TwimConfig, Twim};
82    use embassy_nrf::usb::Driver;
83    use embassy_nrf::usb::vbus_detect::HardwareVbusDetect;
84    use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
85    use embassy_sync::blocking_mutex::raw::ThreadModeRawMutex;
86    use embassy_sync::signal::Signal;
87    use embassy_time::{Delay, Instant, Timer};
88    use embassy_usb::class::cdc_acm::{CdcAcmClass, Sender, State};
89    use embassy_usb::{Builder, Config};
90    use embedded_hal_bus::spi::ExclusiveDevice;
91    use lora_phy::LoRa;
92    use lora_phy::iv::GenericSx126xInterfaceVariant;
93    use lora_phy::mod_params::{Bandwidth, ModulationParams, PacketParams, SpreadingFactor};
94    use lora_phy::sx126x::{Config as LoraConfig, Sx126x, Sx1262, TcxoCtrlVoltage};
95    use static_cell::StaticCell;
96    use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
97    use umsh_bsp_nrf52840::flash_store;
98    use umsh_bsp_nrf52840::flash_store::{
99        NvmcChannelStore, NvmcCounterStore, NvmcPeerStore, NvmcStorage,
100    };
101    use umsh_bsp_nrf52840::panic_persist::PanicSlot;
102    use umsh_bsp_nrf52840::{EmbassyClock, Nrf52840Rng};
103    use umsh_bsp_wio_tracker_l1::{PowerSignaler, WioMac, WioTrackerPlatform};
104    use umsh_core::{ChannelKey, PayloadType, PublicKey};
105    use umsh_crypto::{
106        CryptoEngine, NodeIdentity,
107        software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
108    };
109    use umsh_mac::{LocalIdentityId, MacHandle, OperatingPolicy, RepeaterConfig};
110    use umsh_node::{Channel, Host, LocalNode};
111    use umsh_sync::AsyncRefCell;
112
113    use super::cli_io;
114    use umsh_ux_tracker::led::{LedEngine, LedTimings};
115
116    bind_interrupts!(struct Irqs {
117        USBD        => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
118        CLOCK_POWER => embassy_nrf::usb::vbus_detect::InterruptHandler;
119        TWISPI0     => embassy_nrf::twim::InterruptHandler<peripherals::TWISPI0>;
120        TWISPI1     => embassy_nrf::spim::InterruptHandler<peripherals::TWISPI1>;
121    });
122
123    // ─── Configuration ───────────────────────────────────────────────────────
124
125    const TX_POWER_DBM: i32 = 14;
126
127    // ─── Concrete types ───────────────────────────────────────────────────────
128
129    type RadioSpiBus = ExclusiveDevice<Spim<'static>, Output<'static>, Delay>;
130    type RadioIv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
131    type RadioKind = Sx126x<RadioSpiBus, RadioIv, Sx1262>;
132    type LoraRadio = LoRa<RadioKind, Delay>;
133
134    // Host/node aliases (need `umsh-node`, which the BSP doesn't pull in, so the
135    // firmware owns them). Const params match `WioMac`'s capacities.
136    /// Host bound to the `'static` mac_cell. Owned by `mac_task`.
137    type WioHost = Host<MacHandle<'static, WioTrackerPlatform, 2, 8, 4, 4, 8, 255, 32>>;
138    /// LocalNode handle. Cheap to clone — passed to `cli_task`.
139    type WioNode = LocalNode<MacHandle<'static, WioTrackerPlatform, 2, 8, 4, 4, 8, 255, 32>>;
140
141    // ─── Platform types ───────────────────────────────────────────────────────
142    //
143    // `WioTrackerPlatform`, `WioMac`, the embassy-backed clock, and the
144    // hardware-TRNG RNG live in `umsh-bsp-wio-tracker-l1` (which composes
145    // the chip-level pieces from `umsh-bsp-nrf52840`).
146
147    // ─── Concrete USB driver type aliases ────────────────────────────────────
148    // ('static lifetime, VbusDetect = HardwareVbusDetect.) Used by `umsh_task`
149    // and `output_task`.
150    type WioUsbDriver = Driver<'static, HardwareVbusDetect>;
151    type WioSender = Sender<'static, WioUsbDriver>;
152    type WioRescue = CdcAcmRescue<'static, WioUsbDriver>;
153
154    // ─── Shared state ────────────────────────────────────────────────────────
155
156    type RadioCh = umsh_radio_loraphy::Channels<ThreadModeRawMutex, 4, 2>;
157    static RADIO_CH: RadioCh = RadioCh::new();
158
159    static PACKET_COUNT: AtomicU32 = AtomicU32::new(0);
160    static DISPLAY_SIGNAL: Signal<ThreadModeRawMutex, ()> = Signal::new();
161
162    /// Shared MAC coordinator cell. Stored in a `StaticCell` so a `'static`
163    /// reference can be handed to the spawned `umsh_task` (which builds
164    /// `MacHandle` / `Host` / `CliSession` off of it). The cell itself is
165    /// `Send` (since `WioMac: Send`); `MacHandle` and `CliSession` are `!Send`
166    /// but that's fine — Embassy's local `Spawner::spawn` accepts `!Send`
167    /// tasks (only `SendSpawner` requires `Send`).
168    static MAC_CELL: StaticCell<AsyncRefCell<WioMac>> = StaticCell::new();
169    static STORAGE: StaticCell<NvmcStorage> = StaticCell::new();
170
171    /// Relay from the sync `on_receive` callback to the async
172    /// `identity_persist_task`. Carries (pk, payload_body, len).
173    static IDENTITY_SIGNAL: Signal<ThreadModeRawMutex, ([u8; 32], [u8; 256], usize)> =
174        Signal::new();
175
176    // ─── Tasks ───────────────────────────────────────────────────────────────
177
178    #[embassy_executor::task]
179    async fn display_task(i2c: Twim<'static>) {
180        use embedded_graphics::Drawable;
181        use embedded_graphics::geometry::Point;
182        use embedded_graphics::mono_font::MonoTextStyle;
183        use embedded_graphics::mono_font::ascii::FONT_6X10;
184        use embedded_graphics::pixelcolor::BinaryColor;
185        use embedded_graphics::text::{Baseline, Text};
186        use heapless::String;
187
188        let mut oled = display::Sh1106::new(i2c);
189        oled.init().await;
190
191        let sha = env!("GIT_SHORT_SHA");
192        let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
193
194        let render = |fb: &mut display::Sh1106Fb, count: u32| {
195            fb.clear();
196            let _ = Text::with_baseline("UMSH bringup", Point::new(0, 0), style, Baseline::Top)
197                .draw(fb);
198            let _ = Text::with_baseline(sha, Point::new(0, 16), style, Baseline::Top).draw(fb);
199            let mut s: String<16> = String::new();
200            let _ = core::fmt::write(&mut s, format_args!("MAC: {}", count));
201            let _ = Text::with_baseline(&s, Point::new(0, 32), style, Baseline::Top).draw(fb);
202        };
203
204        let mut fb = display::Sh1106Fb::new();
205        render(&mut fb, 0);
206        oled.flush(&fb).await;
207
208        loop {
209            DISPLAY_SIGNAL.wait().await;
210            let count = PACKET_COUNT.load(Ordering::Relaxed);
211            render(&mut fb, count);
212            oled.flush(&fb).await;
213        }
214    }
215
216    #[embassy_executor::task]
217    async fn radio_runner_task(
218        lora: LoraRadio,
219        mdltn: ModulationParams,
220        rx_pkt: PacketParams,
221        tx_pkt: PacketParams,
222    ) {
223        umsh_radio_loraphy::runner(lora, &RADIO_CH, mdltn, rx_pkt, tx_pkt, TX_POWER_DBM).await;
224    }
225
226    // `PowerSignaler` lives in `umsh-bsp-wio-tracker-l1::power`. The L1's
227    // `request_power_off` is currently a no-op (mechanical power switch);
228    // see the BSP module's TODO if/when soft poweroff is needed.
229
230    // ─── umsh_task (CliSession-backed CLI + MAC driver) ─────────────────────
231
232    /// Drains `IDENTITY_SIGNAL` and persists received `NodeIdentityPayload`
233    /// bytes for known peers.
234    #[embassy_executor::task]
235    async fn identity_persist_task(storage: &'static NvmcStorage) {
236        loop {
237            let (pk, payload, len) = IDENTITY_SIGNAL.wait().await;
238            if storage.peer_exists(&pk).await.unwrap_or(false) {
239                let _ = storage.update_peer_identity(&pk, &payload[..len]).await;
240            }
241        }
242    }
243
244    /// Owns the USB `Sender` and serves the static `OUTPUT_CH`. Decoupling
245    /// the sender from `umsh_task` lets RX keep flowing while TX awaits host
246    /// IN polls, so USB OUT NAKs handle backpressure from the host correctly
247    /// during long pastes.
248    #[embassy_executor::task]
249    async fn output_task(mut tx: WioSender) {
250        cli_io::drain_to_sender(&mut tx).await;
251    }
252
253    /// Drives the MAC coordinator and owns the identity-relay subscription.
254    /// Independent of USB so radio RX/TX and the MAC pump (including ping
255    /// auto-replies) keep running whether or not a host terminal is attached.
256    #[embassy_executor::task]
257    async fn mac_task(mut host: WioHost, identity_id: LocalIdentityId) {
258        // Subscribe to raw packets so NodeIdentity payloads from known peers
259        // can be relayed to identity_persist_task for durable storage.
260        let sub_node = host.node(identity_id).expect("node just added");
261        let _identity_sub = sub_node.on_receive(|pkt| {
262            if pkt.payload_type() != PayloadType::NodeIdentity {
263                return false;
264            }
265            let Some(from) = pkt.from_key() else {
266                return false;
267            };
268            let raw = pkt.payload();
269            let len = raw.len().min(256);
270            let mut buf = [0u8; 256];
271            buf[..len].copy_from_slice(&raw[..len]);
272            IDENTITY_SIGNAL.signal((from.0, buf, len));
273            false
274        });
275
276        let _ = host.run().await;
277        panic!("host exited");
278    }
279
280    /// Runs the `CliSession` over USB-CDC. The only task that blocks on a host
281    /// terminal connection — the radio, MAC pump, and identity relay all run
282    /// without it.
283    #[embassy_executor::task]
284    async fn cli_task(
285        node: WioNode,
286        local_key: PublicKey,
287        storage: &'static NvmcStorage,
288        rx: WioRescue,
289        prev_panic_buf: &'static [u8; 256],
290        prev_panic_len: usize,
291    ) {
292        use umsh_cli::CliSession;
293        use umsh_cli::io::CliOutput;
294        use umsh_cli::logger::NullLogger;
295
296        let mut input = cli_io::CdcInput::new(rx);
297        let mut out = cli_io::CdcOutput::new();
298
299        // Wait for the host to open the CDC port before writing the banner —
300        // otherwise the writes silently disappear into a closed IN endpoint.
301        input.wait_connection().await;
302
303        let _ = out.write_line("").await;
304        let _ = out.write_line("UMSH CLI (Wio Tracker L1)").await;
305        let _ = out.write_line("type /help for commands").await;
306        if prev_panic_len > 0 {
307            let _ = out.write_line("[PREV PANIC]:").await;
308            if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
309                let _ = out.write_line(s).await;
310            }
311        }
312
313        let peer_store = NvmcPeerStore::new(storage);
314        let channel_store = NvmcChannelStore::new(storage);
315        let mut cli: CliSession<_, _, _, _, _, _, 4, 4, 2, 8, 128> = CliSession::new(
316            node,
317            local_key,
318            out,
319            NullLogger::new(),
320            peer_store,
321            channel_store,
322            PowerSignaler,
323        );
324
325        // `run` loads peers/channels from storage and registers them with the
326        // MAC (idempotent) and the CLI display tables before entering the loop.
327        let _ = cli.run(&mut input).await;
328        panic!("cli exited");
329    }
330
331    // ─── Main ────────────────────────────────────────────────────────────────
332
333    #[embassy_executor::main]
334    async fn main(spawner: Spawner) {
335        // Heap must be initialized before any alloc-using code runs.
336        // Bumped from 4 KiB to 8 KiB to accommodate umsh-cli alloc (command parse errors,
337        // subscription vecs, etc.) without embedded-alloc OOM.
338        {
339            use core::mem::MaybeUninit;
340            const HEAP_SIZE: usize = 8192;
341            static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
342            unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
343        }
344
345        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
346
347        let mut wdt_config = WdtConfig::default();
348        wdt_config.timeout_ticks = 32768 * 8;
349        let (_wdt, [wdt_handle]) =
350            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
351
352        // Panic message from previous boot — stored in a StaticCell so cli_task
353        // can hold a 'static reference to it without lifetime issues.
354        static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
355        let mut prev_panic_tmp = [0u8; 256];
356        let prev_panic_len = {
357            let mut slot = PanicSlot::new(super::panic::panic_region());
358            if let Some(msg) = slot.read() {
359                let n = msg.len().min(prev_panic_tmp.len());
360                prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
361                slot.clear();
362                n
363            } else {
364                0
365            }
366        };
367        let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
368
369        // ── SH1106 OLED (TWIM0, SDA=P0.06, SCL=P0.05) ───────────────────────
370        {
371            static TWIM0_BUF: StaticCell<[u8; 256]> = StaticCell::new();
372            let mut twim_cfg = TwimConfig::default();
373            twim_cfg.frequency = twim::Frequency::K400;
374            let i2c = Twim::new(
375                p.TWISPI0,
376                Irqs,
377                p.P0_06,
378                p.P0_05,
379                twim_cfg,
380                TWIM0_BUF.init([0; 256]),
381            );
382            spawner.spawn(display_task(i2c).unwrap());
383        }
384
385        // ── SX1262 LoRa radio (TWISPI1) ──────────────────────────────────────
386        let t_frame_ms = umsh_radio_loraphy::airtime_ms(
387            SpreadingFactor::_7,
388            Bandwidth::_62KHz,
389            umsh_radio_loraphy::MAX_PAYLOAD,
390        );
391        {
392            let mut spi_cfg = SpimConfig::default();
393            spi_cfg.frequency = Frequency::M16;
394            let radio_bus = Spim::new(
395                p.TWISPI1, Irqs, p.P0_30, // SCK
396                p.P0_03, // MISO
397                p.P0_28, // MOSI
398                spi_cfg,
399            );
400            let radio_cs = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
401            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
402
403            let radio_rst = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
404            let radio_dio1 = Input::new(p.P0_07, Pull::None);
405            let radio_busy = Input::new(p.P1_10, Pull::None);
406            let radio_rxen = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
407
408            let iv = GenericSx126xInterfaceVariant::new(
409                radio_rst,
410                radio_dio1,
411                radio_busy,
412                Some(radio_rxen), // rf_switch_rx: lora-phy drives HIGH in RX, LOW in TX
413                None,             // rf_switch_tx: no separate TX enable
414            )
415            .unwrap();
416
417            let lora_config = LoraConfig {
418                chip: Sx1262,
419                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8),
420                use_dcdc: true,
421                rx_boost: true,
422            };
423
424            let mut lora = LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay)
425                .await
426                .unwrap_or_else(|_| panic!("radio init"));
427
428            let (mdltn, rx_pkt, tx_pkt) = umsh_radio_loraphy::meshcore_us_params(&mut lora)
429                .unwrap_or_else(|_| panic!("radio params"));
430
431            spawner.spawn(radio_runner_task(lora, mdltn, rx_pkt, tx_pkt).unwrap());
432        }
433
434        // ── NV storage ────────────────────────────────────────────────────────
435        let storage: &'static NvmcStorage =
436            STORAGE.init(flash_store::new_storage(Nvmc::new(p.NVMC)));
437
438        // ── MAC coordinator ───────────────────────────────────────────────────
439        // The hardware-TRNG RNG built here is the single RNG path for this
440        // firmware — used for first-boot identity generation AND passed
441        // ownership-by-value into `Mac::new` below as `Platform::Rng`.
442        //
443        // Load identity from flash on subsequent boots; TRNG-generate on
444        // first boot. We do NOT fall back to any PRNG on failure — a
445        // predictable long-term key is worse than refusing to start.
446        let mut rng = Nrf52840Rng::new(p.RNG);
447        let sk_bytes: [u8; 32] = match storage.load_sk().await {
448            Ok(Some(sk)) => sk,
449            Ok(None) => {
450                let mut sk = [0u8; 32];
451                rng.fill_bytes(&mut sk);
452                storage
453                    .store_sk(&sk)
454                    .await
455                    .unwrap_or_else(|_| panic!("identity persist"));
456                sk
457            }
458            Err(_) => panic!("storage init failed"),
459        };
460        let identity = SoftwareIdentity::from_secret_bytes(&sk_bytes);
461        let local_key = *identity.public_key();
462
463        let radio_handle = umsh_radio_loraphy::LoraphyRadio::new(&RADIO_CH, t_frame_ms);
464        let crypto = CryptoEngine::new(SoftwareAes, SoftwareSha256);
465        let mut mac = WioMac::new(
466            radio_handle,
467            crypto,
468            EmbassyClock,
469            rng,
470            NvmcCounterStore::new(storage),
471            RepeaterConfig::default(),
472            OperatingPolicy::default(),
473        );
474        let identity_id = mac
475            .add_identity(identity)
476            .unwrap_or_else(|_| panic!("identity"));
477        mac.load_persisted_counter(identity_id)
478            .await
479            .unwrap_or_else(|_| panic!("tx counter load"));
480
481        // Hand ownership of the MAC to a 'static AsyncRefCell so `umsh_task`
482        // can build MacHandle/Host/CliSession off of it.
483        let mac_cell: &'static AsyncRefCell<WioMac> = MAC_CELL.init(AsyncRefCell::new(mac));
484
485        // ── Host + node + boot-time peer/channel registration ─────────────────
486        // Build the Host/node here so the MAC pump (`mac_task`) is independent
487        // of USB, and register persisted peer/channel keys into the MAC now —
488        // not from the CLI task, which only runs after a host opens the CDC
489        // port. Without this the coordinator had no keys until a serial client
490        // attached, so it couldn't authenticate inbound secure frames and
491        // silently dropped every ping.
492        let handle = MacHandle::new(mac_cell);
493        let mut host: WioHost = Host::new(handle);
494        let node = host.add_node(identity_id);
495
496        {
497            let mut peer_buf: heapless::Vec<([u8; 32], Option<heapless::String<16>>), 8> =
498                heapless::Vec::new();
499            let _ = storage.load_all_peers(&mut peer_buf).await;
500            let mut ch_buf: heapless::Vec<(heapless::String<16>, [u8; 32]), 2> =
501                heapless::Vec::new();
502            let _ = storage.load_all_channels(&mut ch_buf).await;
503            for (pk, _alias) in peer_buf.iter() {
504                let _ = node.peer(PublicKey(*pk)).await;
505            }
506            for (name, key_bytes) in ch_buf.iter() {
507                let channel = Channel::private(ChannelKey(*key_bytes), name.as_str());
508                let _ = node.join(&channel).await;
509            }
510        }
511        // Restore RX counter boundaries after peer registration so the persisted
512        // boundaries land on registered peers.
513        MacHandle::new(mac_cell)
514            .load_all_persisted_rx_counters()
515            .await
516            .ok();
517
518        // ── USB stack + steady-state services ────────────────────────────────
519        let led = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
520        let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
521
522        let mut config = Config::new(0x2886, 0x1667);
523        config.manufacturer = Some("UMSH");
524        config.product = Some("Seeed Wio Tracker L1 Bringup");
525        config.serial_number = Some("wio-tracker-l1-console");
526        config.max_power = 100;
527        config.max_packet_size_0 = 64;
528
529        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
530        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
531        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
532        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
533        static STATE: StaticCell<State> = StaticCell::new();
534
535        let mut builder = Builder::new(
536            driver,
537            config,
538            CONFIG_DESC.init([0; 256]),
539            BOS_DESC.init([0; 256]),
540            MSOS_DESC.init([0; 0]),
541            CONTROL_BUF.init([0; 64]),
542        );
543
544        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
545        let mut usb = builder.build();
546
547        let (tx, raw_rx, ctrl) = class.split_with_control();
548        let rx = CdcAcmRescue::new(raw_rx, ctrl);
549
550        spawner.spawn(output_task(tx).unwrap());
551        spawner.spawn(identity_persist_task(storage).unwrap());
552        spawner.spawn(mac_task(host, identity_id).unwrap());
553        spawner
554            .spawn(cli_task(node, local_key, storage, rx, prev_panic_buf, prev_panic_len).unwrap());
555
556        join(usb.run(), heartbeat(led, wdt_handle)).await;
557    }
558
559    // ─── Heartbeat ────────────────────────────────────────────────────────────
560
561    async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
562        let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
563        loop {
564            wdt.pet();
565            let decision = engine.tick(Instant::now().as_millis());
566            if decision.on {
567                led.set_high()
568            } else {
569                led.set_low()
570            }
571            Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
572        }
573    }
574}