lilkaoxide/lib.rs
1#![no_std]
2//! # lilkaoxide — Rust HAL SDK for the [Lilka](https://docs.lilka.dev/) game console
3//!
4//! `no_std` HAL built on top of [`esp-hal`] and [`embedded-hal`].
5//! Supports both **blocking** and **async** (embassy) execution models via feature flags.
6//!
7//! - **Lilka console** — <https://docs.lilka.dev/>
8//! - **LilkaOxide docs** — <https://rust.lilka.dev/>
9//! - **Source code** — <https://gitlab.com/imbiruss/lilkaoxide>
10//! - **crates.io** — <https://crates.io/crates/lilkaoxide>
11//!
12//! ## Hardware (Lilka v2 — ESP32-S3-WROOM-N16R8)
13//!
14//! | Peripheral | Controller / Interface | GPIOs |
15//! |---|---|---|
16//! | IPS TFT display 240×280 | ST7789 via SPI2 | SCK=18, MOSI=17, MISO=8, DC=15, CS=7, PWR=46 |
17//! | SD card | SPI2 (shared) | CS=16 |
18//! | Buttons (10) | GPIO pull-up, active-low | 0, 4, 5, 6, 9, 10, 38, 39, 40, 41 |
19//! | Battery ADC | ADC1 CH2 | GPIO3 (100 kΩ / 33 kΩ divider) |
20//! | Audio DAC (I2S) | I2S0 + DMA | BCLK=42, DOUT=2, LRCK=1 |
21//! | Piezo buzzer | LEDC PWM | GPIO11 |
22//! | Wi-Fi / BT | ESP32-S3 built-in | (feature `radio`) |
23//!
24//! ## Quick start
25//!
26//! ```toml
27//! # Cargo.toml
28//! [dependencies]
29//! lilkaoxide = { version = "0.1", features = ["blocking", "RefCellBus", "display_buffer_8192"] }
30//! ```
31//!
32//! **Blocking:**
33//!
34//! ```rust,no_run
35//! #![no_std]
36//! #![no_main]
37//!
38//! use lilkaoxide::prelude::*;
39//!
40//! #[esp_hal::entry]
41//! fn main() -> ! {
42//! let mut lilka = Lilka::init(LilkaConfiguration::default())
43//! .expect("init failed");
44//!
45//! loop {
46//! if lilka.controller.a.is_low() {
47//! defmt::info!("Button A pressed");
48//! }
49//! let mv = lilka.adc_battery.read_voltage_mv();
50//! if mv < 3_300 {
51//! defmt::warn!("Low battery: {} mV", mv);
52//! }
53//! }
54//! }
55//! ```
56//!
57//! **Async (embassy):**
58//!
59//! ```rust,no_run
60//! #![no_std]
61//! #![no_main]
62//!
63//! use lilkaoxide::prelude::*;
64//!
65//! #[embassy_executor::main]
66//! async fn main(_spawner: embassy_executor::Spawner) {
67//! let lilka = Lilka::init(LilkaConfiguration::default())
68//! .await
69//! .expect("init failed");
70//!
71//! assert!(lilka.rtos.is_started());
72//! }
73//! ```
74//!
75//! ## Feature flags
76//!
77//! | Feature | Description |
78//! |---|---|
79//! | `blocking` | Blocking drivers. **Mutually exclusive with `async`**. |
80//! | `async` | Async drivers (embassy). **Mutually exclusive with `blocking`**. |
81//! | `RefCellBus` | Share SPI via `RefCell`. Single-core, no ISRs. **`blocking` only**. |
82//! | `CriticalSectionBus` | Share SPI via critical section. ISR-safe. **`blocking` only**. |
83//! | `AtomicBus` | Share SPI via atomic cell. **`blocking` only**. |
84//! | `AsyncBus` | Share SPI via async `Mutex`. **Required with `async`**. |
85//! | `alloc` | Global allocator (`esp-alloc`). |
86//! | `radio` | Wi-Fi / BLE (requires `alloc`). |
87//! | `display_buffer_512` | SPI display DMA buffer: 512 B. |
88//! | `display_buffer_1024` | SPI display DMA buffer: 1 024 B. |
89//! | `display_buffer_8192` | SPI display DMA buffer: 8 192 B (default). |
90//!
91//! > Incompatible feature combinations are caught at compile time via [`compile_error!`].
92//!
93//! ## Memory and lifetime rules
94//!
95//! 1. Peripherals are **single-owner resources** — move each pin/peripheral exactly once into its driver.
96//! 2. The shared SPI bus lives in a [`static_cell::StaticCell`] — all bus references are `'static`.
97//! 3. DMA descriptors and buffers come from static storage (`dma_buffers!`) — sizes must be `const`.
98//! 4. Keep long-lived drivers (display, SD, I2S TX) **inside [`Lilka`]** to preserve ownership and lifetimes.
99//! 5. In `async` builds, never hold a borrowed stack reference across `.await` — keep data owned or `'static`.
100//! 6. Feature pairs are mutually exclusive: `blocking` ↔ `async`, `RefCellBus` ↔ `CriticalSectionBus` ↔ `AtomicBus` ↔ `AsyncBus`.
101
102use crate::cfg::Configuration;
103use esp_hal::Config;
104#[cfg(feature = "async")]
105use esp_hal::interrupt::software::SoftwareInterruptControl;
106use esp_hal::peripherals::Peripherals;
107#[cfg(feature = "async")]
108use esp_hal::timer::timg::TimerGroup;
109use esp_println as _;
110mod adcbattery;
111mod buzzer;
112mod cfg;
113mod controller;
114mod display;
115mod i2s;
116mod sd;
117mod spi;
118
119pub use adcbattery::{AdcCfg, Adcbattery};
120pub use buzzer::{Buzzer, BuzzerInitError};
121pub use cfg::{BuzzerConfig, Configuration as LilkaConfiguration};
122pub use controller::ControllerState;
123pub use display::DisplayInitError;
124pub use display::LilkaDisplay;
125pub use i2s::{I2sInitError, I2sTx, I2sTxConfig};
126pub use sd::{SdInitError, SdVolMgr};
127pub use spi::{
128 InnerDelay as LilkaDelay, SpiBusInner as LilkaSpiBus, SpiBusWrapper, SpiDev, SpiInitError,
129 init_bus, init_spi_master,
130};
131
132pub use defmt;
133#[cfg(feature = "async")]
134pub use lcd_async;
135#[cfg(feature = "async")]
136pub use embassy_time;
137pub use esp_hal as hal;
138#[cfg(feature = "async")]
139pub use esp_rtos;
140pub use mipidsi;
141use crate::sd::SDC;
142
143pub mod prelude {
144 #[cfg(feature = "async")]
145 pub use crate::RtosRuntime;
146 pub use crate::cfg::{BuzzerConfig, Configuration};
147 pub use crate::{Lilka, LilkaConfiguration};
148 #[cfg(feature = "async")]
149 pub use embassy_executor;
150 #[cfg(feature = "async")]
151 pub use embassy_time;
152 pub use esp_hal;
153 #[cfg(feature = "async")]
154 pub use esp_rtos;
155 pub use mipidsi;
156}
157/// The top-level handle to all Lilka console hardware subsystems.
158///
159/// Created by calling [`Lilka::init`] with a [`LilkaConfiguration`]. All fields are
160/// `pub` so you can borrow them independently in your game loop.
161///
162/// # Field overview
163///
164/// | Field | Type | Purpose |
165/// |---|---|---|
166/// | `peripherals` | [`esp_hal::peripherals::Peripherals`] | Raw peripheral singleton (already consumed during init; kept for future use). |
167/// | `delay` | [`LilkaDelay`] | `embedded-hal` delay implementation — `Delay` for blocking, `embassy_time::Delay` for async. |
168/// | `controller` | [`ControllerState`] | D-pad + action buttons (A/B/C/D), Select, Start. |
169/// | `adc_battery` | [`Adcbattery`] | Battery voltage ADC on GPIO3 via ADC1. |
170/// | `display` | [`LilkaDisplay`] | 240×280 ST7789 TFT display via SPI2. |
171/// | `sd` | `Option<`[`SdVolMgr`]`>` | SD card volume manager; `None` if no card was detected. |
172/// | `i2s_tx` | [`I2sTx`] | I2S transmitter for audio output (default 44 100 Hz stereo). |
173/// | `buzzer` | [`Buzzer`] | PWM buzzer driven by the LEDC peripheral (GPIO11). |
174///
175/// # Example — reading a button
176///
177/// ```rust,no_run
178/// use lilkaoxide::prelude::*;
179///
180/// # fn example(mut lilka: Lilka) {
181/// // Buttons use pull-up resistors: low = pressed
182/// if lilka.controller.a.is_low() {
183/// defmt::info!("A pressed!");
184/// }
185/// # }
186/// ```
187///
188/// # Example — reading battery voltage
189///
190/// ```rust,no_run
191/// use lilkaoxide::prelude::*;
192///
193/// # fn example(mut lilka: Lilka) {
194/// let mv = lilka.adc_battery.read_voltage_mv();
195/// defmt::info!("Battery: {} mV", mv);
196/// # }
197/// ```
198pub struct Lilka {
199 pub peripherals: Peripherals,
200 /// Available only with the `async` feature; holds the embassy/esp-rtos runtime handle.
201 #[cfg(feature = "async")]
202 pub rtos: RtosRuntime,
203 pub delay: LilkaDelay,
204 pub controller: ControllerState,
205 pub adc_battery: Adcbattery<'static>,
206 pub display: LilkaDisplay,
207 pub sd: Option<SDC<'static>>,
208 pub i2s_tx: I2sTx<'static>,
209 pub buzzer: Buzzer,
210}
211
212#[cfg(feature = "async")]
213/// Handle to the embedded async runtime (embassy + esp-rtos).
214///
215/// Automatically created by [`Lilka::init`] when the `async` feature is enabled.
216/// The runtime is started exactly once via [`esp_rtos::start`] and drives the embassy
217/// executor. You normally do not need to interact with this type directly — use
218/// `embassy_executor` tasks and `embassy_time` futures instead.
219pub struct RtosRuntime {
220 started: bool,
221}
222
223#[cfg(feature = "async")]
224impl RtosRuntime {
225 fn start(
226 timg0: esp_hal::peripherals::TIMG0<'static>,
227 sw_interrupt: esp_hal::peripherals::SW_INTERRUPT<'static>,
228 ) -> Self {
229 let timg0 = TimerGroup::new(timg0);
230 let sw_interrupt = SoftwareInterruptControl::new(sw_interrupt);
231 esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
232
233 Self { started: true }
234 }
235
236 /// Returns `true` if the async runtime has been successfully started.
237 ///
238 /// This will always be `true` after a successful [`Lilka::init`] call.
239 /// The flag is provided as a cheap sanity check before spawning embassy tasks.
240 ///
241 /// # Example
242 ///
243 /// ```rust,no_run
244 /// # #[cfg(feature = "async")]
245 /// # fn example(lilka: lilkaoxide::Lilka) {
246 /// assert!(lilka.rtos.is_started(), "runtime must be running");
247 /// # }
248 /// ```
249 pub const fn is_started(&self) -> bool {
250 self.started
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq)]
255/// Error returned by [`Lilka::init`] if any subsystem fails to initialise.
256///
257/// Initialisation is sequential; the first fatal failure stops the process
258/// and is returned as this error. The SD card is **non-fatal**: a missing or
259/// unreadable card results in `Lilka::sd == None`, not an error.
260///
261/// # Variants
262///
263/// * [`InitError::Spi`] — SPI2 bus could not be created (clock/pin config issue).
264/// * [`InitError::Display`] — ST7789 display did not respond or tearing config failed.
265/// * [`InitError::Sd`] — SD card SPI bus device init failed (card absent is silent).
266/// * [`InitError::I2s`] — I2S0 peripheral init failed (DMA or pin conflict).
267/// * [`InitError::Buzzer`] — LEDC timer or channel configuration failed.
268///
269/// # Example
270///
271/// ```rust,no_run
272/// use lilkaoxide::{Lilka, InitError, prelude::*};
273///
274/// # fn example() {
275/// match Lilka::init(LilkaConfiguration::default()) {
276/// Ok(lilka) => { /* use lilka */ }
277/// Err(InitError::Spi(e)) => defmt::error!("SPI failed: {}", e.as_str()),
278/// Err(InitError::Display(e)) => defmt::error!("Display failed: {}", e.as_str()),
279/// Err(InitError::I2s(e)) => defmt::error!("I2S failed: {}", e.as_str()),
280/// Err(InitError::Buzzer(e)) => defmt::error!("Buzzer failed: {}", e.as_str()),
281/// Err(InitError::Sd(e)) => defmt::error!("SD bus failed: {}", e.as_str()),
282/// }
283/// # }
284/// ```
285pub enum InitError {
286 /// SPI2 master bus initialisation failed.
287 Spi(SpiInitError),
288 /// ST7789 display initialisation or tearing-effect configuration failed.
289 Display(DisplayInitError),
290 /// SD card SPI device (bus sharing layer) could not be created.
291 Sd(SdInitError),
292 /// I2S0 transmitter initialisation failed.
293 I2s(I2sInitError),
294 /// LEDC buzzer timer or channel configuration failed.
295 Buzzer(BuzzerInitError),
296}
297
298#[derive(Clone, Copy)]
299enum StepStatus {
300 Ok,
301 Error(&'static str),
302 Skipped,
303}
304
305impl StepStatus {
306 fn log(self, step: &'static str) {
307 match self {
308 Self::Ok => defmt::info!("[OK] {=str}", step),
309 Self::Error(reason) => defmt::error!("[ERR] {=str}: {=str}", step, reason),
310 Self::Skipped => defmt::warn!("[SKIP] {=str}", step),
311 }
312 }
313}
314
315#[derive(Clone, Copy)]
316struct InitStatusLog {
317 spi: StepStatus,
318 display: StepStatus,
319 sd: StepStatus,
320 controller: StepStatus,
321 adc_battery: StepStatus,
322 i2s_tx: StepStatus,
323 buzzer: StepStatus,
324}
325
326impl Default for InitStatusLog {
327 fn default() -> Self {
328 Self {
329 spi: StepStatus::Skipped,
330 display: StepStatus::Skipped,
331 sd: StepStatus::Skipped,
332 controller: StepStatus::Skipped,
333 adc_battery: StepStatus::Skipped,
334 i2s_tx: StepStatus::Skipped,
335 buzzer: StepStatus::Skipped,
336 }
337 }
338}
339
340fn log_init_status(status: &InitStatusLog) {
341 defmt::info!("Init status summary:");
342 status.spi.log("SPI");
343 status.display.log("Display");
344 status.sd.log("SD");
345 status.controller.log("Controller");
346 status.adc_battery.log("ADC battery");
347 status.i2s_tx.log("I2S TX");
348 status.buzzer.log("Buzzer");
349}
350
351#[cfg(feature = "blocking")]
352fn init_delay() -> LilkaDelay {
353 spi::InnerDelay::new()
354}
355
356#[cfg(feature = "async")]
357fn init_delay() -> LilkaDelay {
358 embassy_time::Delay
359}
360
361impl Lilka {
362 /// Initialises all Lilka console hardware subsystems in a fixed order.
363 ///
364 /// This is the **only** correct entry-point to the SDK. Call it once at the
365 /// beginning of `main` (or your embassy task) with a [`Configuration`] and
366 /// receive a fully-initialised [`Lilka`] handle.
367 ///
368 /// # Initialisation order
369 ///
370 /// 1. `esp_hal::init` — set CPU clock from `config.cpu_clock`.
371 /// 2. `RtosRuntime::start` — start embassy executor (**`async` only**).
372 /// 3. `init_spi_master` — SPI2 at `config.spi_frequency` (GPIO18 SCK, GPIO17 MOSI, GPIO8 MISO).
373 /// 4. `init_bus` — wrap the raw SPI bus in the selected sharing wrapper (`RefCell` / `CriticalSection` / …).
374 /// 5. `display::init_display` — ST7789 240×280 on GPIO46 (power), GPIO15 (DC), GPIO7 (CS).
375 /// 6. `sd::init_sd` — SD card on GPIO16 (CS), shared SPI bus.
376 /// 7. `ControllerState::init_controller` — GPIO5/6/10/9/38/41/39/40/0/4 pulled-up inputs.
377 /// 8. `adcbattery::init_adcbattery` — ADC1, GPIO3, configured attenuation.
378 /// 9. `i2s::init_i2s_tx` — I2S0 DMA TX on GPIO42 (BCLK), GPIO2 (DOUT), GPIO1 (LRCK).
379 /// 10. `buzzer::init_buzzer` — LEDC on GPIO11.
380 ///
381 /// Steps 3–5, 9, and 10 are **fatal**: failure returns immediately via [`InitError`].
382 /// Step 6 (SD) is **non-fatal**: an absent or unreadable card sets `Lilka::sd` to `None`.
383 ///
384 /// # Parameters
385 ///
386 /// * `config` — a [`Configuration`] (use [`Configuration::default`] for sensible defaults).
387 ///
388 /// # Returns
389 ///
390 /// `Ok(Lilka)` with all subsystems initialised, or `Err(`[`InitError`]`)` on the first
391 /// fatal failure. Regardless of the outcome the init status is printed via `defmt`.
392 ///
393 /// # Note on `async` vs `blocking`
394 ///
395 /// The function signature is `async fn` but the `deasync` proc-macro generates a
396 /// synchronous wrapper when the `blocking` feature is active. You call `Lilka::init`
397 /// the same way in both modes — just omit `.await` in blocking code.
398 ///
399 /// # Examples
400 ///
401 /// **Blocking** (`features = ["blocking", "RefCellBus", "display_buffer_8192"]`):
402 ///
403 /// ```rust,no_run
404 /// #![no_std]
405 /// #![no_main]
406 ///
407 /// use lilkaoxide::prelude::*;
408 ///
409 /// #[esp_hal::entry]
410 /// fn main() -> ! {
411 /// let lilka = Lilka::init(LilkaConfiguration::default())
412 /// .expect("hardware init failed");
413 ///
414 /// defmt::info!("Display ready, battery: {} mV",
415 /// // Note: need mut borrow for ADC reads
416 /// // lilka.adc_battery.read_voltage_mv()
417 /// );
418 /// loop {}
419 /// }
420 /// ```
421 ///
422 /// **Async** (`features = ["async", "AsyncBus", "display_buffer_8192"]`):
423 ///
424 /// ```rust,no_run
425 /// #![no_std]
426 /// #![no_main]
427 ///
428 /// use lilkaoxide::prelude::*;
429 ///
430 /// #[embassy_executor::main]
431 /// async fn main(_spawner: embassy_executor::Spawner) {
432 /// let lilka = Lilka::init(LilkaConfiguration::default())
433 /// .await
434 /// .expect("hardware init failed");
435 ///
436 /// assert!(lilka.rtos.is_started());
437 /// }
438 /// ```
439 #[cfg_attr(not(feature = "async"), deasync::deasync)]
440 pub async fn init(config: Configuration) -> Result<Self, InitError> {
441 let mut status = InitStatusLog::default();
442 let Configuration {
443 cpu_clock,
444 spi_frequency,
445 display_refresh_order,
446 display_rotation,
447 display_tearing,
448 adc_cfg,
449 i2s_tx_config,
450 buzzer_config,
451 } = config;
452 let peripherals = esp_hal::init(Config::default().with_cpu_clock(cpu_clock));
453 #[cfg(feature = "async")]
454 let rtos = RtosRuntime::start(peripherals.TIMG0, peripherals.SW_INTERRUPT);
455
456 let spi = match init_spi_master(
457 peripherals.SPI2,
458 peripherals.GPIO18,
459 peripherals.GPIO17,
460 peripherals.GPIO8,
461 spi_frequency,
462 ) {
463 Ok(spi) => {
464 status.spi = StepStatus::Ok;
465 spi
466 }
467 Err(err) => {
468 status.spi = StepStatus::Error(err.as_str());
469 log_init_status(&status);
470 return Err(InitError::Spi(err));
471 }
472 };
473 let delay = init_delay();
474 let spi_bus = init_bus(spi);
475 let display = match display::init_display(
476 peripherals.GPIO46,
477 peripherals.GPIO15,
478 peripherals.GPIO7,
479 spi_bus,
480 delay.clone(),
481 display_refresh_order,
482 display_rotation,
483 display_tearing,
484 )
485 .await
486 {
487 Ok(display) => {
488 status.display = StepStatus::Ok;
489 display
490 }
491 Err(err) => {
492 status.display = StepStatus::Error(err.as_str());
493 log_init_status(&status);
494 return Err(InitError::Display(err));
495 }
496 };
497 let sd = match sd::init_sd(spi_bus, peripherals.GPIO16, sd::SdDelay(delay.clone())).await {
498 Ok(sd) => {
499 status.sd = StepStatus::Ok;
500 Some(sd)
501 }
502 Err(err) => {
503 status.sd = StepStatus::Error(err.as_str());
504 None
505 }
506 };
507 let controller = ControllerState::init_controller(
508 peripherals.GPIO5,
509 peripherals.GPIO6,
510 peripherals.GPIO10,
511 peripherals.GPIO9,
512 peripherals.GPIO38,
513 peripherals.GPIO41,
514 peripherals.GPIO39,
515 peripherals.GPIO40,
516 peripherals.GPIO0,
517 peripherals.GPIO4,
518 );
519 status.controller = StepStatus::Ok;
520 let adc_battery =
521 adcbattery::init_adcbattery(peripherals.GPIO3, adc_cfg, peripherals.ADC1);
522 status.adc_battery = StepStatus::Ok;
523 let i2s_tx = match i2s::init_i2s_tx(
524 i2s_tx_config,
525 peripherals.GPIO42,
526 peripherals.GPIO2,
527 peripherals.GPIO1,
528 peripherals.I2S0,
529 peripherals.DMA_CH0,
530 ) {
531 Ok(i2s_tx) => {
532 status.i2s_tx = StepStatus::Ok;
533 i2s_tx
534 }
535 Err(err) => {
536 status.i2s_tx = StepStatus::Error(err.as_str());
537 log_init_status(&status);
538 return Err(InitError::I2s(err));
539 }
540 };
541 let buzzer = match buzzer::init_buzzer(buzzer_config, peripherals.LEDC, peripherals.GPIO11) {
542 Ok(buzzer) => {
543 status.buzzer = StepStatus::Ok;
544 buzzer
545 }
546 Err(err) => {
547 status.buzzer = StepStatus::Error(err.as_str());
548 log_init_status(&status);
549 return Err(InitError::Buzzer(err));
550 }
551 };
552
553 log_init_status(&status);
554
555 Ok(Self {
556 peripherals: unsafe { Peripherals::steal() },
557 #[cfg(feature = "async")]
558 rtos,
559 delay,
560 controller,
561 adc_battery,
562 display,
563 sd,
564 i2s_tx,
565 buzzer,
566 })
567 }
568}