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}