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, None)
224            .await;
225    }
226
227    // `PowerSignaler` lives in `umsh-bsp-wio-tracker-l1::power`. The L1's
228    // `request_power_off` is currently a no-op (mechanical power switch);
229    // see the BSP module's TODO if/when soft poweroff is needed.
230
231    // ─── umsh_task (CliSession-backed CLI + MAC driver) ─────────────────────
232
233    /// Drains `IDENTITY_SIGNAL` and persists received `NodeIdentityPayload`
234    /// bytes for known peers.
235    #[embassy_executor::task]
236    async fn identity_persist_task(storage: &'static NvmcStorage) {
237        loop {
238            let (pk, payload, len) = IDENTITY_SIGNAL.wait().await;
239            if storage.peer_exists(&pk).await.unwrap_or(false) {
240                let _ = storage.update_peer_identity(&pk, &payload[..len]).await;
241            }
242        }
243    }
244
245    /// Owns the USB `Sender` and serves the static `OUTPUT_CH`. Decoupling
246    /// the sender from `umsh_task` lets RX keep flowing while TX awaits host
247    /// IN polls, so USB OUT NAKs handle backpressure from the host correctly
248    /// during long pastes.
249    #[embassy_executor::task]
250    async fn output_task(mut tx: WioSender) {
251        cli_io::drain_to_sender(&mut tx).await;
252    }
253
254    /// Drives the MAC coordinator and owns the identity-relay subscription.
255    /// Independent of USB so radio RX/TX and the MAC pump (including ping
256    /// auto-replies) keep running whether or not a host terminal is attached.
257    #[embassy_executor::task]
258    async fn mac_task(mut host: WioHost, identity_id: LocalIdentityId) {
259        // Subscribe to raw packets so NodeIdentity payloads from known peers
260        // can be relayed to identity_persist_task for durable storage.
261        let sub_node = host.node(identity_id).expect("node just added");
262        let _identity_sub = sub_node.on_receive(|pkt| {
263            if pkt.payload_type() != PayloadType::NodeIdentity {
264                return false;
265            }
266            let Some(from) = pkt.from_key() else {
267                return false;
268            };
269            let raw = pkt.payload();
270            let len = raw.len().min(256);
271            let mut buf = [0u8; 256];
272            buf[..len].copy_from_slice(&raw[..len]);
273            IDENTITY_SIGNAL.signal((from.0, buf, len));
274            false
275        });
276
277        let _ = host.run().await;
278        panic!("host exited");
279    }
280
281    /// Runs the `CliSession` over USB-CDC. The only task that blocks on a host
282    /// terminal connection — the radio, MAC pump, and identity relay all run
283    /// without it.
284    #[embassy_executor::task]
285    async fn cli_task(
286        node: WioNode,
287        local_key: PublicKey,
288        storage: &'static NvmcStorage,
289        rx: WioRescue,
290        prev_panic_buf: &'static [u8; 256],
291        prev_panic_len: usize,
292    ) {
293        use umsh_cli::CliSession;
294        use umsh_cli::io::CliOutput;
295        use umsh_cli::logger::NullLogger;
296
297        let mut input = cli_io::CdcInput::new(rx);
298        let mut out = cli_io::CdcOutput::new();
299
300        // Wait for the host to open the CDC port before writing the banner —
301        // otherwise the writes silently disappear into a closed IN endpoint.
302        input.wait_connection().await;
303
304        let _ = out.write_line("").await;
305        let _ = out.write_line("UMSH CLI (Wio Tracker L1)").await;
306        let _ = out.write_line("type /help for commands").await;
307        if prev_panic_len > 0 {
308            let _ = out.write_line("[PREV PANIC]:").await;
309            if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
310                let _ = out.write_line(s).await;
311            }
312        }
313
314        let peer_store = NvmcPeerStore::new(storage);
315        let channel_store = NvmcChannelStore::new(storage);
316        let mut cli: CliSession<_, _, _, _, _, _, 4, 4, 2, 8, 128> = CliSession::new(
317            node,
318            local_key,
319            out,
320            NullLogger::new(),
321            peer_store,
322            channel_store,
323            PowerSignaler,
324        );
325
326        // `run` loads peers/channels from storage and registers them with the
327        // MAC (idempotent) and the CLI display tables before entering the loop.
328        let _ = cli.run(&mut input).await;
329        panic!("cli exited");
330    }
331
332    // ─── Main ────────────────────────────────────────────────────────────────
333
334    #[embassy_executor::main]
335    async fn main(spawner: Spawner) {
336        // Heap must be initialized before any alloc-using code runs.
337        // Bumped from 4 KiB to 8 KiB to accommodate umsh-cli alloc (command parse errors,
338        // subscription vecs, etc.) without embedded-alloc OOM.
339        {
340            use core::mem::MaybeUninit;
341            const HEAP_SIZE: usize = 8192;
342            static mut HEAP: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
343            unsafe { crate::ALLOCATOR.init(core::ptr::addr_of!(HEAP) as usize, HEAP_SIZE) }
344        }
345
346        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
347
348        let mut wdt_config = WdtConfig::default();
349        wdt_config.timeout_ticks = 32768 * 8;
350        let (_wdt, [wdt_handle]) =
351            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
352
353        // Panic message from previous boot — stored in a StaticCell so cli_task
354        // can hold a 'static reference to it without lifetime issues.
355        static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
356        let mut prev_panic_tmp = [0u8; 256];
357        let prev_panic_len = {
358            let mut slot = PanicSlot::new(super::panic::panic_region());
359            if let Some(msg) = slot.read() {
360                let n = msg.len().min(prev_panic_tmp.len());
361                prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
362                slot.clear();
363                n
364            } else {
365                0
366            }
367        };
368        let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
369
370        // ── SH1106 OLED (TWIM0, SDA=P0.06, SCL=P0.05) ───────────────────────
371        {
372            static TWIM0_BUF: StaticCell<[u8; 256]> = StaticCell::new();
373            let mut twim_cfg = TwimConfig::default();
374            twim_cfg.frequency = twim::Frequency::K400;
375            let i2c = Twim::new(
376                p.TWISPI0,
377                Irqs,
378                p.P0_06,
379                p.P0_05,
380                twim_cfg,
381                TWIM0_BUF.init([0; 256]),
382            );
383            spawner.spawn(display_task(i2c).unwrap());
384        }
385
386        // ── SX1262 LoRa radio (TWISPI1) ──────────────────────────────────────
387        let t_frame_ms = umsh_radio_loraphy::airtime_ms(
388            SpreadingFactor::_7,
389            Bandwidth::_62KHz,
390            umsh_radio_loraphy::MAX_PAYLOAD,
391        );
392        {
393            let mut spi_cfg = SpimConfig::default();
394            spi_cfg.frequency = Frequency::M16;
395            let radio_bus = Spim::new(
396                p.TWISPI1, Irqs, p.P0_30, // SCK
397                p.P0_03, // MISO
398                p.P0_28, // MOSI
399                spi_cfg,
400            );
401            let radio_cs = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
402            let radio_spi = ExclusiveDevice::new(radio_bus, radio_cs, Delay).unwrap();
403
404            let radio_rst = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
405            let radio_dio1 = Input::new(p.P0_07, Pull::None);
406            let radio_busy = Input::new(p.P1_10, Pull::None);
407            let radio_rxen = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
408
409            let iv = GenericSx126xInterfaceVariant::new(
410                radio_rst,
411                radio_dio1,
412                radio_busy,
413                Some(radio_rxen), // rf_switch_rx: lora-phy drives HIGH in RX, LOW in TX
414                None,             // rf_switch_tx: no separate TX enable
415            )
416            .unwrap();
417
418            let lora_config = LoraConfig {
419                chip: Sx1262,
420                tcxo_ctrl: Some(TcxoCtrlVoltage::Ctrl1V8),
421                use_dcdc: true,
422                rx_boost: true,
423            };
424
425            let mut lora = LoRa::new(Sx126x::new(radio_spi, iv, lora_config), false, Delay)
426                .await
427                .unwrap_or_else(|_| panic!("radio init"));
428
429            let (mdltn, rx_pkt, tx_pkt) = umsh_radio_loraphy::profile_params(
430                &mut lora,
431                umsh_radio_loraphy::profiles::DEFAULT,
432                8,
433            )
434            .unwrap_or_else(|_| panic!("radio params"));
435
436            spawner.spawn(radio_runner_task(lora, mdltn, rx_pkt, tx_pkt).unwrap());
437        }
438
439        // ── NV storage ────────────────────────────────────────────────────────
440        let storage: &'static NvmcStorage =
441            STORAGE.init(flash_store::new_storage(Nvmc::new(p.NVMC)));
442
443        // ── MAC coordinator ───────────────────────────────────────────────────
444        // The hardware-TRNG RNG built here is the single RNG path for this
445        // firmware — used for first-boot identity generation AND passed
446        // ownership-by-value into `Mac::new` below as `Platform::Rng`.
447        //
448        // Load identity from flash on subsequent boots; TRNG-generate on
449        // first boot. We do NOT fall back to any PRNG on failure — a
450        // predictable long-term key is worse than refusing to start.
451        let mut rng = Nrf52840Rng::new(p.RNG);
452        let sk_bytes: [u8; 32] = match storage.load_sk().await {
453            Ok(Some(sk)) => sk,
454            Ok(None) => {
455                let mut sk = [0u8; 32];
456                rng.fill_bytes(&mut sk);
457                storage
458                    .store_sk(&sk)
459                    .await
460                    .unwrap_or_else(|_| panic!("identity persist"));
461                sk
462            }
463            Err(_) => panic!("storage init failed"),
464        };
465        let identity = SoftwareIdentity::from_secret_bytes(&sk_bytes);
466        let local_key = *identity.public_key();
467
468        let radio_handle = umsh_radio_loraphy::LoraphyRadio::new(&RADIO_CH, t_frame_ms);
469        let crypto = CryptoEngine::new(SoftwareAes, SoftwareSha256);
470        let mut mac = WioMac::new(
471            radio_handle,
472            crypto,
473            EmbassyClock,
474            rng,
475            NvmcCounterStore::new(storage),
476            RepeaterConfig::default(),
477            OperatingPolicy::default(),
478        );
479        let identity_id = mac
480            .add_identity(identity)
481            .unwrap_or_else(|_| panic!("identity"));
482        mac.load_persisted_counter(identity_id)
483            .await
484            .unwrap_or_else(|_| panic!("tx counter load"));
485
486        // Hand ownership of the MAC to a 'static AsyncRefCell so `umsh_task`
487        // can build MacHandle/Host/CliSession off of it.
488        let mac_cell: &'static AsyncRefCell<WioMac> = MAC_CELL.init(AsyncRefCell::new(mac));
489
490        // ── Host + node + boot-time peer/channel registration ─────────────────
491        // Build the Host/node here so the MAC pump (`mac_task`) is independent
492        // of USB, and register persisted peer/channel keys into the MAC now —
493        // not from the CLI task, which only runs after a host opens the CDC
494        // port. Without this the coordinator had no keys until a serial client
495        // attached, so it couldn't authenticate inbound secure frames and
496        // silently dropped every ping.
497        let handle = MacHandle::new(mac_cell);
498        let mut host: WioHost = Host::new(handle);
499        let node = host.add_node(identity_id);
500
501        {
502            let mut peer_buf: heapless::Vec<([u8; 32], Option<heapless::String<16>>), 8> =
503                heapless::Vec::new();
504            let _ = storage.load_all_peers(&mut peer_buf).await;
505            let mut ch_buf: heapless::Vec<(heapless::String<16>, [u8; 32]), 2> =
506                heapless::Vec::new();
507            let _ = storage.load_all_channels(&mut ch_buf).await;
508            for (pk, _alias) in peer_buf.iter() {
509                let _ = node.peer(PublicKey(*pk)).await;
510            }
511            for (name, key_bytes) in ch_buf.iter() {
512                let channel = Channel::private(ChannelKey(*key_bytes), name.as_str());
513                let _ = node.join(&channel).await;
514            }
515        }
516        // Restore RX counter boundaries after peer registration so the persisted
517        // boundaries land on registered peers.
518        MacHandle::new(mac_cell)
519            .load_all_persisted_rx_counters()
520            .await
521            .ok();
522
523        // ── USB stack + steady-state services ────────────────────────────────
524        let led = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
525        let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
526
527        let mut config = Config::new(0x2886, 0x1667);
528        config.manufacturer = Some("UMSH");
529        config.product = Some("Seeed Wio Tracker L1 Bringup");
530        config.serial_number = Some("wio-tracker-l1-console");
531        config.max_power = 100;
532        config.max_packet_size_0 = 64;
533
534        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
535        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
536        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
537        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
538        static STATE: StaticCell<State> = StaticCell::new();
539
540        let mut builder = Builder::new(
541            driver,
542            config,
543            CONFIG_DESC.init([0; 256]),
544            BOS_DESC.init([0; 256]),
545            MSOS_DESC.init([0; 0]),
546            CONTROL_BUF.init([0; 64]),
547        );
548
549        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
550        let mut usb = builder.build();
551
552        let (tx, raw_rx, ctrl) = class.split_with_control();
553        let rx = CdcAcmRescue::new(raw_rx, ctrl);
554
555        spawner.spawn(output_task(tx).unwrap());
556        spawner.spawn(identity_persist_task(storage).unwrap());
557        spawner.spawn(mac_task(host, identity_id).unwrap());
558        spawner
559            .spawn(cli_task(node, local_key, storage, rx, prev_panic_buf, prev_panic_len).unwrap());
560
561        join(usb.run(), heartbeat(led, wdt_handle)).await;
562    }
563
564    // ─── Heartbeat ────────────────────────────────────────────────────────────
565
566    async fn heartbeat(mut led: Output<'static>, mut wdt: WatchdogHandle) -> ! {
567        let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
568        loop {
569            wdt.pet();
570            let decision = engine.tick(Instant::now().as_millis());
571            if decision.on {
572                led.set_high()
573            } else {
574                led.set_low()
575            }
576            Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
577        }
578    }
579}