firmware_sensecap_solar_console/
main.rs

1// SenseCAP Solar Node P1 / P1-Pro bringup firmware — Phase 1.
2//
3// This is the *stripped* first-boot binary. Its job is
4// to prove the board comes up safely and to identify the LEDs / buttons
5// whose colors and polarities the hardware reconstruction left open. It
6// deliberately does NOT bring up the OLED (there is none), the SX1262
7// radio (Phase 3), or the MAC / CLI (Phase 4 parity).
8//
9// What it does:
10//   1. Initialize embassy-nrf, arm the 8 s watchdog.
11//   2. Read + clear any panic message left by the previous boot.
12//   3. Drive the board into a safe state:
13//        - RADIO_RXEN (P0.05) LOW  — safety contract: RXEN low before any
14//          radio work and until lora-phy owns it (no radio here yet).
15//        - GNSS_ENABLE (P1.05) LOW — hold the L76K powered down.
16//        - GNSS_RESET candidate (P1.03) is NEVER driven — left untouched.
17//        - Battery-divider gate (P0.14) is NOT touched in Phase 1; the
18//          gate polarity is verified in Phase 2 before we drive it.
19//   4. Bring up USB-CDC with the CdcAcmRescue escape hatch + panic replay.
20//   5. Blink LED_B (P0.19, the blue "breathing" LED) as a heartbeat, and
21//      run an interactive GPIO/button identification session over CDC.
22//
23// Confirmed on hardware 2026-07-23:
24//   - LED_A (P0.15) = white, active-high; LED_B (P0.19) = blue, active-high.
25//   - USER_BUTTON (P1.01) and the "PWR" button (P1.07) are both active-low
26//     on internal pull-ups (LOW pressed, HIGH released). PWR is a soft
27//     momentary button — pressing it does not cut the MCU rail.
28//
29// Identification session (single-char commands, echoed back as reports):
30//   '1'  toggle LED_A (P0.15) — the white user LED
31//   '?'  help + current LED_A / button states
32//   USER_BUTTON (P1.01) and PWR (P1.07) edges auto-report.
33//
34// The LED_B heartbeat runs in its own task (umsh_ux_tracker::led::LedEngine,
35// a 20 ms pulse every 4 s), decoupled from the CDC RX loop so terminal
36// activity can never affect its cadence. The watchdog is petted from that
37// same heartbeat loop, exactly as on the other nRF52840 boards.
38
39#![cfg_attr(target_os = "none", no_std)]
40#![cfg_attr(target_os = "none", no_main)]
41
42#[cfg(not(target_os = "none"))]
43fn main() {}
44
45#[cfg(target_os = "none")]
46mod panic;
47
48#[cfg(target_os = "none")]
49mod defmt_logger {
50    // The BSP's Platform bundle transitively links lora-phy, which pulls
51    // in defmt 0.3 and requires a global logger to link. This firmware
52    // has no debug transport, so the logger is a no-op.
53    #[defmt::global_logger]
54    struct Logger;
55    unsafe impl defmt::Logger for Logger {
56        fn acquire() {}
57        unsafe fn flush() {}
58        unsafe fn release() {}
59        unsafe fn write(_: &[u8]) {}
60    }
61    defmt::timestamp!("{=u32}", 0u32);
62}
63
64#[cfg(target_os = "none")]
65mod firmware {
66    use embassy_executor::Spawner;
67    use embassy_futures::join::join;
68    use embassy_futures::select::{Either3, select3};
69    use embassy_nrf::bind_interrupts;
70    use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
71    use embassy_nrf::peripherals;
72    use embassy_nrf::usb::Driver;
73    use embassy_nrf::usb::vbus_detect::HardwareVbusDetect;
74    use embassy_nrf::wdt::{Config as WdtConfig, Watchdog, WatchdogHandle};
75    use embassy_time::{Instant, Timer};
76    use embassy_usb::class::cdc_acm::{CdcAcmClass, Sender, State};
77    use embassy_usb::{Builder, Config};
78    use static_cell::StaticCell;
79    use umsh_bsp_nrf52840::cdc_rescue::CdcAcmRescue;
80    use umsh_bsp_nrf52840::panic_persist::PanicSlot;
81    use umsh_ux_tracker::led::{LedEngine, LedTimings};
82
83    bind_interrupts!(struct Irqs {
84        USBD        => embassy_nrf::usb::InterruptHandler<peripherals::USBD>;
85        CLOCK_POWER => embassy_nrf::usb::vbus_detect::InterruptHandler;
86    });
87
88    // ─── Concrete USB driver type aliases ────────────────────────────────────
89    type SolarUsbDriver = Driver<'static, HardwareVbusDetect>;
90    type SolarSender = Sender<'static, SolarUsbDriver>;
91    type SolarRescue = CdcAcmRescue<'static, SolarUsbDriver>;
92
93    // ─── CDC output helper ───────────────────────────────────────────────────
94
95    /// Write a line (with trailing CRLF) to the CDC sender in <=64-byte
96    /// USB packets. Best-effort — drops on a closed endpoint.
97    async fn write_line(tx: &mut SolarSender, s: &str) {
98        for chunk in s.as_bytes().chunks(64) {
99            let _ = tx.write_packet(chunk).await;
100        }
101        let _ = tx.write_packet(b"\r\n").await;
102    }
103
104    const HELP: &str = "\
105SenseCAP Solar Node bringup (Phase 1) — GPIO identification\r
106  1  toggle LED_A (P0.15, white user LED)\r
107  ?  this help + current pin states\r
108LED_B (P0.19, blue) blinks as the heartbeat.\r
109USER_BUTTON (P1.01) and PWR (P1.07) edges auto-report.";
110
111    /// Interactive GPIO/button identification over CDC. Owns LED_A, both
112    /// buttons, and the CDC RX/TX endpoints. LED_B belongs to the
113    /// independent heartbeat task.
114    #[embassy_executor::task]
115    async fn ident_task(
116        mut tx: SolarSender,
117        mut rx: SolarRescue,
118        mut led_a: Output<'static>,
119        mut btn1: Input<'static>,
120        mut btn2: Input<'static>,
121        prev_panic_buf: &'static [u8; 256],
122        prev_panic_len: usize,
123    ) {
124        // Wait for the host to open the CDC port before writing the banner —
125        // otherwise the writes silently vanish into a closed IN endpoint.
126        rx.wait_connection().await;
127
128        let sha = env!("GIT_SHORT_SHA");
129        write_line(&mut tx, "").await;
130        write_line(&mut tx, "UMSH SenseCAP Solar Node bringup (Phase 1)").await;
131        write_line(&mut tx, sha).await;
132        if prev_panic_len > 0 {
133            write_line(&mut tx, "[PREV PANIC]:").await;
134            if let Ok(s) = core::str::from_utf8(&prev_panic_buf[..prev_panic_len]) {
135                write_line(&mut tx, s).await;
136            }
137        }
138        write_line(&mut tx, HELP).await;
139
140        let mut a_level = false; // LED_A assumed active-high (confirmed)
141        led_a.set_low();
142
143        let mut pkt = [0u8; 64];
144        loop {
145            // Re-arm on every iteration so a host re-attach is transparent and
146            // a disconnected port doesn't busy-loop on read_packet returning 0.
147            rx.wait_connection().await;
148
149            match select3(
150                rx.read_packet(&mut pkt),
151                btn1.wait_for_any_edge(),
152                btn2.wait_for_any_edge(),
153            )
154            .await
155            {
156                // CDC command bytes.
157                Either3::First(res) => {
158                    let n = match res {
159                        Ok(0) | Err(_) => continue, // disconnect → re-arm at top
160                        Ok(n) => n,
161                    };
162                    for &b in &pkt[..n] {
163                        match b {
164                            b'1' => {
165                                a_level = !a_level;
166                                if a_level {
167                                    led_a.set_high();
168                                } else {
169                                    led_a.set_low();
170                                }
171                                report(&mut tx, "LED_A (P0.15)", a_level).await;
172                            }
173                            b'?' => {
174                                write_line(&mut tx, HELP).await;
175                                report(&mut tx, "LED_A (P0.15)", a_level).await;
176                                report_button(&mut tx, "USER_BUTTON (P1.01)", &btn1).await;
177                                report_button(&mut tx, "PWR         (P1.07)", &btn2).await;
178                            }
179                            b'\r' | b'\n' => {}
180                            _ => {} // ignore other bytes
181                        }
182                    }
183                }
184
185                // Button edges: report the settled level. Pull-up inputs, so
186                // LOW == pressed (active-low, confirmed).
187                Either3::Second(()) => {
188                    report_button(&mut tx, "USER_BUTTON (P1.01)", &btn1).await;
189                }
190                Either3::Third(()) => {
191                    report_button(&mut tx, "PWR         (P1.07)", &btn2).await;
192                }
193            }
194        }
195    }
196
197    async fn report(tx: &mut SolarSender, name: &str, level: bool) {
198        write_line(tx, name).await;
199        write_line(tx, if level { "  -> HIGH" } else { "  -> LOW" }).await;
200    }
201
202    async fn report_button(tx: &mut SolarSender, name: &str, btn: &Input<'static>) {
203        write_line(tx, name).await;
204        // Pull-up input: released == HIGH, pressed == LOW (active-low, confirmed).
205        write_line(
206            tx,
207            if btn.is_low() {
208                "  = LOW  (pressed)"
209            } else {
210                "  = HIGH (released)"
211            },
212        )
213        .await;
214    }
215
216    // ─── Heartbeat (LED_B) + watchdog ────────────────────────────────────────
217
218    /// Blinks LED_B (P0.19, blue "breathing" LED) via the shared LedEngine
219    /// and pets the watchdog on the same schedule. Isolated from the CDC RX
220    /// loop so terminal traffic can't perturb its cadence. Matches the
221    /// heartbeat structure used on the other nRF52840 boards.
222    async fn heartbeat(mut led_b: Output<'static>, mut wdt: WatchdogHandle) -> ! {
223        let mut engine = LedEngine::new(LedTimings::default(), Instant::now().as_millis());
224        loop {
225            wdt.pet();
226            let decision = engine.tick(Instant::now().as_millis());
227            if decision.on {
228                led_b.set_high();
229            } else {
230                led_b.set_low();
231            }
232            Timer::at(Instant::from_millis(decision.next_deadline_ms)).await;
233        }
234    }
235
236    // ─── Main ────────────────────────────────────────────────────────────────
237
238    #[embassy_executor::main]
239    async fn main(spawner: Spawner) {
240        let p = embassy_nrf::init(umsh_bsp_nrf52840::clocks::default_config());
241
242        let mut wdt_config = WdtConfig::default();
243        wdt_config.timeout_ticks = 32768 * 8; // 8 s
244        let (_wdt, [wdt_handle]) =
245            Watchdog::try_new::<_, 1>(p.WDT, wdt_config).unwrap_or_else(|_| panic!("wdt"));
246
247        // ── Board safe state ──────────────────────────────────────────────────
248        // Held live for the lifetime of `main` (which never returns). RXEN low
249        // per the safety contract; GNSS held powered down. The GNSS reset
250        // candidate (P1.03) and battery-divider gate (P0.14) are deliberately
251        // left untouched in Phase 1.
252        let _radio_rxen = Output::new(p.P0_05, Level::Low, OutputDrive::Standard);
253        let _gnss_enable = Output::new(p.P1_05, Level::Low, OutputDrive::Standard);
254
255        // ── Previous-boot panic message ───────────────────────────────────────
256        static PREV_PANIC_BUF: StaticCell<[u8; 256]> = StaticCell::new();
257        let mut prev_panic_tmp = [0u8; 256];
258        let prev_panic_len = {
259            let mut slot = PanicSlot::new(super::panic::panic_region());
260            if let Some(msg) = slot.read() {
261                let n = msg.len().min(prev_panic_tmp.len());
262                prev_panic_tmp[..n].copy_from_slice(&msg[..n]);
263                slot.clear();
264                n
265            } else {
266                0
267            }
268        };
269        let prev_panic_buf: &'static [u8; 256] = PREV_PANIC_BUF.init(prev_panic_tmp);
270
271        // ── User LEDs + buttons ───────────────────────────────────────────────
272        let led_a = Output::new(p.P0_15, Level::Low, OutputDrive::Standard);
273        let led_b = Output::new(p.P0_19, Level::Low, OutputDrive::Standard);
274        let btn1 = Input::new(p.P1_01, Pull::Up);
275        let btn2 = Input::new(p.P1_07, Pull::Up);
276
277        // ── USB-CDC stack ─────────────────────────────────────────────────────
278        let driver = Driver::new(p.USBD, Irqs, HardwareVbusDetect::new(Irqs));
279
280        // USB IDs per the plan: VID 0x2886 / PID 0x0059 (Seeed XIAO family).
281        let mut config = Config::new(0x2886, 0x0059);
282        config.manufacturer = Some("UMSH");
283        config.product = Some("SenseCAP Solar Node Bringup");
284        config.serial_number = Some("sensecap-solar-console");
285        config.max_power = 100;
286        config.max_packet_size_0 = 64;
287
288        static CONFIG_DESC: StaticCell<[u8; 256]> = StaticCell::new();
289        static BOS_DESC: StaticCell<[u8; 256]> = StaticCell::new();
290        static MSOS_DESC: StaticCell<[u8; 0]> = StaticCell::new();
291        static CONTROL_BUF: StaticCell<[u8; 64]> = StaticCell::new();
292        static STATE: StaticCell<State> = StaticCell::new();
293
294        let mut builder = Builder::new(
295            driver,
296            config,
297            CONFIG_DESC.init([0; 256]),
298            BOS_DESC.init([0; 256]),
299            MSOS_DESC.init([0; 0]),
300            CONTROL_BUF.init([0; 64]),
301        );
302
303        let class = CdcAcmClass::new(&mut builder, STATE.init(State::new()), 64);
304        let mut usb = builder.build();
305
306        let (tx, raw_rx, ctrl) = class.split_with_control();
307        let rx = CdcAcmRescue::new(raw_rx, ctrl);
308
309        spawner
310            .spawn(ident_task(tx, rx, led_a, btn1, btn2, prev_panic_buf, prev_panic_len).unwrap());
311
312        join(usb.run(), heartbeat(led_b, wdt_handle)).await;
313    }
314}