dmx_interface/main.rs
1//! DMX interface firmware for ESP32-S2 (Lolin S2 Mini).
2//!
3//! Current scope: status LED on GPIO7 (LEDC PWM, own embassy task), user
4//! button on GPIO5 (power-up factory-reset hold plus click actions),
5//! USB-CDC with esptool auto-reset, ring-buffer logging over the CDC port,
6//! panic reports persisted across resets, and a `WiFi` network stack (AP
7//! with DHCP server or station mode, AP fallback).
8
9#![no_std]
10#![no_main]
11// In this binary crate every module is private, so `missing_docs` only
12// gates crate-level items (the `//!` header above). Module and API doc
13// coverage is enforced separately by scripts/check-rust-docs.sh plus the
14// warning-denied rustdoc build (`inv docs:rustdoc`).
15#![deny(missing_docs)]
16#![allow(dead_code)]
17
18extern crate alloc;
19
20// Module docs live as `//!` at the top of each module file. Do NOT also put
21// `///` docs on these declarations: rustdoc then resolves the module's
22// intra-doc links in this (parent) module's scope and every unqualified link
23// breaks — see AGENTS.md "//!" only.
24mod boards;
25mod config;
26mod hardware;
27mod logging;
28mod net;
29mod panic_report;
30mod storage;
31
32use embassy_executor::Spawner;
33use embassy_time::{Duration, Timer};
34use esp_alloc::HeapRegion;
35use esp_hal::{
36 clock::CpuClock, interrupt::software::SoftwareInterruptControl, timer::timg::TimerGroup,
37};
38
39use hardware::led::LedStatus;
40
41esp_bootloader_esp_idf::esp_app_desc!();
42
43/// Clears the panic boot-loop counter once the firmware ran stably for 30 s.
44#[embassy_executor::task]
45async fn stable_marker() {
46 Timer::after(Duration::from_secs(30)).await;
47 panic_report::mark_stable();
48}
49
50/// Entry point.
51#[esp_rtos::main]
52async fn main(spawner: Spawner) -> ! {
53 let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
54
55 // ── Heap allocator ─────────────────────────────────────────────────────
56 {
57 /// Heap size in bytes (internal RAM — the `WiFi` driver and smoltcp
58 /// need ~100 KiB per esp-radio's examples, plus panic-report replay
59 /// and NVS reads).
60 const HEAP_SIZE: usize = 128 * 1024;
61 /// Backing memory for the heap allocator, placed in `.dram2_uninit`
62 /// (the 136 KiB region above the stack top that nothing else uses —
63 /// see esp-hal's `dram2.x`), keeping the main `dram_seg` free for
64 /// the `WiFi` driver's statics and the stack.
65 #[unsafe(link_section = ".dram2_uninit")]
66 static mut HEAP_MEM: [u8; HEAP_SIZE] = [0u8; HEAP_SIZE];
67 let heap_ptr = core::ptr::addr_of_mut!(HEAP_MEM).cast::<u8>();
68 // SAFETY: `heap_ptr` points to the whole `HEAP_MEM` array, which is
69 // valid for `'static`, exclusively owned by the allocator afterwards,
70 // handed over exactly once, and `size > 0`.
71 let region = unsafe {
72 HeapRegion::new(
73 heap_ptr,
74 HEAP_SIZE,
75 esp_alloc::MemoryCapability::Internal.into(),
76 )
77 };
78 // SAFETY: the heap region is registered exactly once at init time,
79 // before anything allocates.
80 unsafe {
81 esp_alloc::HEAP.add_region(region);
82 }
83 }
84
85 // ── Embassy scheduler ──────────────────────────────────────────────────
86 let timg0 = TimerGroup::new(peripherals.TIMG0);
87 let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
88 esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
89
90 // ── Logging + last-panic report ────────────────────────────────────────
91 // Must follow esp_rtos::start (uptime timestamps need the time driver)
92 // and precede the USB spawn, so a stored panic report is already in the
93 // ring when the first host connects.
94 logging::init();
95 storage::init(peripherals.FLASH);
96 // Load the persisted config and push it into every consumer (LED
97 // brightness today, the network stack below).
98 let config = storage::load();
99 config.apply();
100 panic_report::report_last_panic();
101
102 // ── USB-CDC (esptool auto-reset + log drain) ───────────────────────────
103 spawner.spawn(
104 hardware::usb::task(peripherals.USB0, peripherals.GPIO20, peripherals.GPIO19)
105 .expect("USB task spawn failed"),
106 );
107 spawner.spawn(stable_marker().expect("stable marker spawn failed"));
108
109 // ── Status LED (LEDC PWM, own task) ─────────────────────────────────────
110 hardware::led::set(LedStatus::Startup);
111 spawner.spawn(
112 hardware::led::task(peripherals.LEDC, peripherals.GPIO7).expect("LED task spawn failed"),
113 );
114
115 // ── User button (GPIO5): power-up factory reset, then click actions ────
116 // The hold check must finish before the network starts, so a triggered
117 // reset never brings the radio up just to reboot again.
118 let button = hardware::button::input(peripherals.GPIO5);
119 hardware::button::boot_factory_reset(&button).await;
120 spawner.spawn(hardware::button::task(button).expect("button task spawn failed"));
121
122 // ── WiFi (AP/STA bring-up + embassy-net stack) ─────────────────────────
123 spawner.spawn(net::task(peripherals.WIFI, config, spawner).expect("network task spawn failed"));
124
125 log::info!("Init complete");
126
127 // TODO(phase 6): switch to Ok only once the network is up — the old
128 // firmware waited for NETWORK_READY + 2 s before LED_MODE_NORMAL.
129 // Until then, keep the Startup breathing visible for 3 s (three full
130 // breaths at the 1 s period) before settling into the steady Ok state.
131 Timer::after(Duration::from_secs(3)).await;
132 hardware::led::set(LedStatus::Ok);
133
134 // No main-loop duties yet — Phase 8 moves system work into tasks.
135 loop {
136 core::future::pending::<()>().await;
137 }
138}