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}