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`](esp_hal) and [`embedded-hal`](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;
116/// Optional wall-clock inheritance after a KeiraOS MultiBoot software reset.
117#[cfg(feature = "keira-rtc")]
118pub mod keira_rtc;
119mod sd;
120mod sd_wire;
121mod spi;
122
123pub use adcbattery::{AdcCfg, Adcbattery};
124pub use buzzer::{Buzzer, BuzzerInitError};
125pub use cfg::{BuzzerConfig, Configuration as LilkaConfiguration};
126pub use controller::ControllerState;
127pub use display::DisplayInitError;
128pub use display::LilkaDisplay;
129pub use i2s::{I2sInitError, I2sTx, I2sTxConfig};
130pub use sd::{SdInitError, SdVolMgr};
131pub use spi::{
132 InnerDelay as LilkaDelay, SpiBusInner as LilkaSpiBus, SpiBusWrapper, SpiDev, SpiInitError,
133 init_bus, init_spi_master,
134};
135
136use crate::sd::SDC;
137pub use defmt;
138#[cfg(feature = "async")]
139pub use embassy_time;
140pub use esp_hal as hal;
141#[cfg(feature = "async")]
142pub use esp_rtos;
143#[cfg(feature = "async")]
144pub use lcd_async;
145pub use mipidsi;
146
147pub mod prelude {
148 #[cfg(feature = "async")]
149 pub use crate::RtosRuntime;
150 pub use crate::cfg::{BuzzerConfig, Configuration};
151 pub use crate::{Lilka, LilkaConfiguration};
152 #[cfg(feature = "async")]
153 pub use embassy_executor;
154 #[cfg(feature = "async")]
155 pub use embassy_time;
156 pub use esp_hal;
157 #[cfg(feature = "async")]
158 pub use esp_rtos;
159 pub use mipidsi;
160}
161/// The top-level handle to all Lilka console hardware subsystems.
162///
163/// Created by calling [`Lilka::init`] with a [`LilkaConfiguration`]. All fields are
164/// `pub` so you can borrow them independently in your game loop.
165///
166/// # Field overview
167///
168/// | Field | Type | Purpose |
169/// |---|---|---|
170/// | `peripherals` | [`esp_hal::peripherals::Peripherals`] | Raw peripheral singleton (already consumed during init; kept for future use). |
171/// | `delay` | [`LilkaDelay`] | `embedded-hal` delay implementation — `Delay` for blocking, `embassy_time::Delay` for async. |
172/// | `controller` | [`ControllerState`] | D-pad + action buttons (A/B/C/D), Select, Start. |
173/// | `adc_battery` | [`Adcbattery`] | Battery voltage ADC on GPIO3 via ADC1. |
174/// | `display` | [`LilkaDisplay`] | 240×280 ST7789 TFT display via SPI2. |
175/// | `sd` | `Option<`[`SdVolMgr`]`>` | SD card volume manager; `None` if no card was detected. |
176/// | `i2s_tx` | [`I2sTx`] | I2S transmitter for audio output (default 44 100 Hz stereo). |
177/// | `buzzer` | [`Buzzer`] | PWM buzzer driven by the LEDC peripheral (GPIO11). |
178///
179/// # Example — reading a button
180///
181/// ```rust,no_run
182/// use lilkaoxide::prelude::*;
183///
184/// # fn example(mut lilka: Lilka) {
185/// // Buttons use pull-up resistors: low = pressed
186/// if lilka.controller.a.is_low() {
187/// defmt::info!("A pressed!");
188/// }
189/// # }
190/// ```
191///
192/// # Example — reading battery voltage
193///
194/// ```rust,no_run
195/// use lilkaoxide::prelude::*;
196///
197/// # fn example(mut lilka: Lilka) {
198/// let mv = lilka.adc_battery.read_voltage_mv();
199/// defmt::info!("Battery: {} mV", mv);
200/// # }
201/// ```
202pub struct Lilka {
203 pub peripherals: Peripherals,
204 /// Available only with the `async` feature; holds the embassy/esp-rtos runtime handle.
205 #[cfg(feature = "async")]
206 pub rtos: RtosRuntime,
207 pub delay: LilkaDelay,
208 pub controller: ControllerState,
209 pub adc_battery: Adcbattery<'static>,
210 pub display: LilkaDisplay,
211 pub sd: Option<SDC<'static>>,
212 pub i2s_tx: I2sTx<'static>,
213 pub buzzer: Buzzer,
214}
215
216#[cfg(feature = "async")]
217/// Handle to the embedded async runtime (embassy + esp-rtos).
218///
219/// Automatically created by [`Lilka::init`] when the `async` feature is enabled.
220/// The runtime is started exactly once via [`esp_rtos::start`] and drives the embassy
221/// executor. You normally do not need to interact with this type directly — use
222/// `embassy_executor` tasks and `embassy_time` futures instead.
223pub struct RtosRuntime {
224 started: bool,
225}
226
227#[cfg(feature = "async")]
228impl RtosRuntime {
229 fn start(
230 timg0: esp_hal::peripherals::TIMG0<'static>,
231 sw_interrupt: esp_hal::peripherals::SW_INTERRUPT<'static>,
232 ) -> Self {
233 let timg0 = TimerGroup::new(timg0);
234 let sw_interrupt = SoftwareInterruptControl::new(sw_interrupt);
235 esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
236
237 Self { started: true }
238 }
239
240 /// Returns `true` if the async runtime has been successfully started.
241 ///
242 /// This will always be `true` after a successful [`Lilka::init`] call.
243 /// The flag is provided as a cheap sanity check before spawning embassy tasks.
244 ///
245 /// # Example
246 ///
247 /// ```rust,no_run
248 /// # #[cfg(feature = "async")]
249 /// # fn example(lilka: lilkaoxide::Lilka) {
250 /// assert!(lilka.rtos.is_started(), "runtime must be running");
251 /// # }
252 /// ```
253 pub const fn is_started(&self) -> bool {
254 self.started
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq)]
259/// Error returned by [`Lilka::init`] if any subsystem fails to initialise.
260///
261/// Initialisation is sequential; the first fatal failure stops the process
262/// and is returned as this error. The SD card is **non-fatal**: a missing or
263/// unreadable card results in `Lilka::sd == None`, not an error.
264///
265/// # Variants
266///
267/// * [`InitError::Spi`] — SPI2 bus could not be created (clock/pin config issue).
268/// * [`InitError::Display`] — ST7789 display did not respond or tearing config failed.
269/// * [`InitError::Sd`] — SD card SPI bus device init failed (card absent is silent).
270/// * [`InitError::I2s`] — I2S0 peripheral init failed (DMA or pin conflict).
271/// * [`InitError::Buzzer`] — LEDC timer or channel configuration failed.
272///
273/// # Example
274///
275/// ```rust,no_run
276/// use lilkaoxide::{Lilka, InitError, prelude::*};
277///
278/// # fn example() {
279/// match Lilka::init(LilkaConfiguration::default()) {
280/// Ok(lilka) => { /* use lilka */ }
281/// Err(InitError::Spi(e)) => defmt::error!("SPI failed: {}", e.as_str()),
282/// Err(InitError::Display(e)) => defmt::error!("Display failed: {}", e.as_str()),
283/// Err(InitError::I2s(e)) => defmt::error!("I2S failed: {}", e.as_str()),
284/// Err(InitError::Buzzer(e)) => defmt::error!("Buzzer failed: {}", e.as_str()),
285/// Err(InitError::Sd(e)) => defmt::error!("SD bus failed: {}", e.as_str()),
286/// }
287/// # }
288/// ```
289pub enum InitError {
290 /// SPI2 master bus initialisation failed.
291 Spi(SpiInitError),
292 /// ST7789 display initialisation or tearing-effect configuration failed.
293 Display(DisplayInitError),
294 /// SD card SPI device (bus sharing layer) could not be created.
295 Sd(SdInitError),
296 /// I2S0 transmitter initialisation failed.
297 I2s(I2sInitError),
298 /// LEDC buzzer timer or channel configuration failed.
299 Buzzer(BuzzerInitError),
300}
301
302#[derive(Clone, Copy)]
303enum StepStatus {
304 Ok,
305 Error(&'static str),
306 Skipped,
307}
308
309impl StepStatus {
310 fn log(self, step: &'static str) {
311 match self {
312 Self::Ok => defmt::info!("[OK] {=str}", step),
313 Self::Error(reason) => defmt::error!("[ERR] {=str}: {=str}", step, reason),
314 Self::Skipped => defmt::warn!("[SKIP] {=str}", step),
315 }
316 }
317}
318
319#[derive(Clone, Copy)]
320struct InitStatusLog {
321 spi: StepStatus,
322 display: StepStatus,
323 sd: StepStatus,
324 controller: StepStatus,
325 adc_battery: StepStatus,
326 i2s_tx: StepStatus,
327 buzzer: StepStatus,
328}
329
330impl Default for InitStatusLog {
331 fn default() -> Self {
332 Self {
333 spi: StepStatus::Skipped,
334 display: StepStatus::Skipped,
335 sd: StepStatus::Skipped,
336 controller: StepStatus::Skipped,
337 adc_battery: StepStatus::Skipped,
338 i2s_tx: StepStatus::Skipped,
339 buzzer: StepStatus::Skipped,
340 }
341 }
342}
343
344fn log_init_status(status: &InitStatusLog) {
345 defmt::info!("Init status summary:");
346 status.spi.log("SPI");
347 status.display.log("Display");
348 status.sd.log("SD");
349 status.controller.log("Controller");
350 status.adc_battery.log("ADC battery");
351 status.i2s_tx.log("I2S TX");
352 status.buzzer.log("Buzzer");
353}
354
355#[cfg(feature = "blocking")]
356fn init_delay() -> LilkaDelay {
357 spi::InnerDelay::new()
358}
359
360#[cfg(feature = "async")]
361fn init_delay() -> LilkaDelay {
362 embassy_time::Delay
363}
364
365impl Lilka {
366 /// Initialises all Lilka console hardware subsystems in a fixed order.
367 ///
368 /// This is the **only** correct entry-point to the SDK. Call it once at the
369 /// beginning of `main` (or your embassy task) with a [`Configuration`] and
370 /// receive a fully-initialised [`Lilka`] handle.
371 ///
372 /// # Initialisation order
373 ///
374 /// 1. `esp_hal::init` — set CPU clock from `config.cpu_clock`.
375 /// 2. `RtosRuntime::start` — start embassy executor (**`async` only**).
376 /// 3. `init_spi_master` — SPI2 at `config.spi_frequency` (GPIO18 SCK, GPIO17 MOSI, GPIO8 MISO).
377 /// 4. `init_bus` — wrap the raw SPI bus in the selected sharing wrapper (`RefCell` / `CriticalSection` / …).
378 /// 5. `display::init_display` — ST7789 240×280 on GPIO46 (power), GPIO15 (DC), GPIO7 (CS).
379 /// 6. `sd::init_sd` — SD card on GPIO16 (CS), shared SPI bus.
380 /// 7. `ControllerState::init_controller` — GPIO5/6/10/9/38/41/39/40/0/4 pulled-up inputs.
381 /// 8. `adcbattery::init_adcbattery` — ADC1, GPIO3, configured attenuation.
382 /// 9. `i2s::init_i2s_tx` — I2S0 DMA TX on GPIO42 (BCLK), GPIO2 (DOUT), GPIO1 (LRCK).
383 /// 10. `buzzer::init_buzzer` — LEDC on GPIO11.
384 ///
385 /// Steps 3–5, 9, and 10 are **fatal**: failure returns immediately via [`InitError`].
386 /// Step 6 (SD) is **non-fatal**: an absent or unreadable card sets `Lilka::sd` to `None`.
387 ///
388 /// # Parameters
389 ///
390 /// * `config` — a [`Configuration`] (use [`Configuration::default`] for sensible defaults).
391 ///
392 /// # Returns
393 ///
394 /// `Ok(Lilka)` with all subsystems initialised, or `Err(`[`InitError`]`)` on the first
395 /// fatal failure. Regardless of the outcome the init status is printed via `defmt`.
396 ///
397 /// # Note on `async` vs `blocking`
398 ///
399 /// The function signature is `async fn` but the `deasync` proc-macro generates a
400 /// synchronous wrapper when the `blocking` feature is active. You call `Lilka::init`
401 /// the same way in both modes — just omit `.await` in blocking code.
402 ///
403 /// # Examples
404 ///
405 /// **Blocking** (`features = ["blocking", "RefCellBus", "display_buffer_8192"]`):
406 ///
407 /// ```rust,no_run
408 /// #![no_std]
409 /// #![no_main]
410 ///
411 /// use lilkaoxide::prelude::*;
412 ///
413 /// #[esp_hal::entry]
414 /// fn main() -> ! {
415 /// let lilka = Lilka::init(LilkaConfiguration::default())
416 /// .expect("hardware init failed");
417 ///
418 /// defmt::info!("Display ready, battery: {} mV",
419 /// // Note: need mut borrow for ADC reads
420 /// // lilka.adc_battery.read_voltage_mv()
421 /// );
422 /// loop {}
423 /// }
424 /// ```
425 ///
426 /// **Async** (`features = ["async", "AsyncBus", "display_buffer_8192"]`):
427 ///
428 /// ```rust,no_run
429 /// #![no_std]
430 /// #![no_main]
431 ///
432 /// use lilkaoxide::prelude::*;
433 ///
434 /// #[embassy_executor::main]
435 /// async fn main(_spawner: embassy_executor::Spawner) {
436 /// let lilka = Lilka::init(LilkaConfiguration::default())
437 /// .await
438 /// .expect("hardware init failed");
439 ///
440 /// assert!(lilka.rtos.is_started());
441 /// }
442 /// ```
443 #[cfg_attr(not(feature = "async"), deasync::deasync)]
444 pub async fn init(config: Configuration) -> Result<Self, InitError> {
445 let mut status = InitStatusLog::default();
446 let Configuration {
447 cpu_clock,
448 spi_frequency,
449 display_refresh_order,
450 display_rotation,
451 display_tearing,
452 adc_cfg,
453 i2s_tx_config,
454 buzzer_config,
455 } = config;
456 let peripherals = esp_hal::init(Config::default().with_cpu_clock(cpu_clock));
457 #[cfg(feature = "async")]
458 let rtos = RtosRuntime::start(peripherals.TIMG0, peripherals.SW_INTERRUPT);
459
460 // Deselect SD before the display sends anything on their shared bus.
461 let sd_cs = sd::SdChipSelect::new(peripherals.GPIO16);
462 let spi = match init_spi_master(
463 peripherals.SPI2,
464 peripherals.GPIO18,
465 peripherals.GPIO17,
466 peripherals.GPIO8,
467 spi_frequency,
468 ) {
469 Ok(spi) => {
470 status.spi = StepStatus::Ok;
471 spi
472 }
473 Err(err) => {
474 status.spi = StepStatus::Error(err.as_str());
475 log_init_status(&status);
476 return Err(InitError::Spi(err));
477 }
478 };
479 let delay = init_delay();
480 let spi_bus = init_bus(spi);
481 let display = match display::init_display(
482 peripherals.GPIO46,
483 peripherals.GPIO15,
484 peripherals.GPIO7,
485 spi_bus,
486 delay.clone(),
487 display_refresh_order,
488 display_rotation,
489 display_tearing,
490 )
491 .await
492 {
493 Ok(display) => {
494 status.display = StepStatus::Ok;
495 display
496 }
497 Err(err) => {
498 status.display = StepStatus::Error(err.as_str());
499 log_init_status(&status);
500 return Err(InitError::Display(err));
501 }
502 };
503 let sd = match sd::init_sd(spi_bus, sd_cs, sd::SdDelay(delay.clone()), spi_frequency).await
504 {
505 Ok(sd) => {
506 status.sd = StepStatus::Ok;
507 Some(sd)
508 }
509 Err(err) => {
510 status.sd = StepStatus::Error(err.as_str());
511 None
512 }
513 };
514 let controller = ControllerState::init_controller(
515 peripherals.GPIO5,
516 peripherals.GPIO6,
517 peripherals.GPIO10,
518 peripherals.GPIO9,
519 peripherals.GPIO38,
520 peripherals.GPIO41,
521 peripherals.GPIO39,
522 peripherals.GPIO40,
523 peripherals.GPIO0,
524 peripherals.GPIO4,
525 );
526 status.controller = StepStatus::Ok;
527 let adc_battery = adcbattery::init_adcbattery(peripherals.GPIO3, adc_cfg, peripherals.ADC1);
528 status.adc_battery = StepStatus::Ok;
529 let i2s_tx = match i2s::init_i2s_tx(
530 i2s_tx_config,
531 peripherals.GPIO42,
532 peripherals.GPIO2,
533 peripherals.GPIO1,
534 peripherals.I2S0,
535 peripherals.DMA_CH0,
536 ) {
537 Ok(i2s_tx) => {
538 status.i2s_tx = StepStatus::Ok;
539 i2s_tx
540 }
541 Err(err) => {
542 status.i2s_tx = StepStatus::Error(err.as_str());
543 log_init_status(&status);
544 return Err(InitError::I2s(err));
545 }
546 };
547 let buzzer = match buzzer::init_buzzer(buzzer_config, peripherals.LEDC, peripherals.GPIO11)
548 {
549 Ok(buzzer) => {
550 status.buzzer = StepStatus::Ok;
551 buzzer
552 }
553 Err(err) => {
554 status.buzzer = StepStatus::Error(err.as_str());
555 log_init_status(&status);
556 return Err(InitError::Buzzer(err));
557 }
558 };
559
560 log_init_status(&status);
561
562 Ok(Self {
563 peripherals: unsafe { Peripherals::steal() },
564 #[cfg(feature = "async")]
565 rtos,
566 delay,
567 controller,
568 adc_battery,
569 display,
570 sd,
571 i2s_tx,
572 buzzer,
573 })
574 }
575}