Skip to main content

esp_hub75/
lib.rs

1//! # ESP-HUB75
2//!
3//! A `no-std` driver for HUB75-style LED matrix panels on ESP32-series
4//! microcontrollers.
5//!
6//! The panel is refreshed over DMA with almost no CPU involvement, using
7//! whichever peripheral fits each chip best:
8//!
9//! - **ESP32-S3**: Uses the `LCD_CAM` peripheral
10//! - **ESP32-C6**: Uses the `PARL_IO` peripheral
11//! - **ESP32-C5**: Uses the `PARL_IO` peripheral (8-bit mode only; requires a
12//!   latch circuit and `Hub75Pins8`)
13//! - **ESP32**: Uses the I2S peripheral in parallel mode
14//!
15//! ## Framebuffers
16//!
17//! Use the **bitplane** framebuffers from the `hub75-framebuffer` crate.
18//! They come in two variants: direct-drive (16-bit, no external latch) and
19//! latched (8-bit, needs an external address-latch circuit). Both can be
20//! handed to the peripheral as-is; there is no extra formatting step.
21//!
22//! Bitplane framebuffers (`framebuffer::bitplane::plain::DmaFrameBuffer` /
23//! `framebuffer::bitplane::latched::DmaFrameBuffer`) store only one bit per
24//! pixel per plane. The driver assembles the BCM (Binary Code Modulation)
25//! output on the fly with DMA descriptors, so RAM use stays low without
26//! losing visual quality.
27//!
28//! ## Usage
29//!
30//! Example for ESP32-C6:
31//!
32//! ```rust,no_run
33//! #![no_std]
34//! #![no_main]
35//!
36//! use embedded_graphics::Drawable;
37//! use embedded_graphics::geometry::Point;
38//! use embedded_graphics::mono_font::MonoTextStyleBuilder;
39//! use embedded_graphics::mono_font::ascii::FONT_5X7;
40//! use embedded_graphics::prelude::RgbColor;
41//! use embedded_graphics::text::Alignment;
42//! use embedded_graphics::text::Text;
43//! use esp_backtrace as _;
44//! use esp_hal::clock::CpuClock;
45//! use esp_hal::gpio::Pin;
46//! use esp_hal::main;
47//! use esp_hub75::Color;
48//! use esp_hub75::Hub75;
49//! use esp_hub75::Hub75Pins16;
50//! use esp_hub75::framebuffer::bitplane::plain::DmaFrameBuffer;
51//! use esp_hub75::framebuffer::compute_rows;
52//!
53//! esp_bootloader_esp_idf::esp_app_desc!();
54//!
55//! const ROWS: usize = 64;
56//! const COLS: usize = 64;
57//! const NROWS: usize = compute_rows(ROWS);
58//! const PLANES: usize = 4;
59//!
60//! type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
61//!
62//! macro_rules! mk_static {
63//!     ($t:ty,$val:expr) => {{
64//!         static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
65//!         #[deny(unused_attributes)]
66//!         let x = STATIC_CELL.uninit().write($val);
67//!         x
68//!     }};
69//! }
70//!
71//! #[main]
72//! fn main() -> ! {
73//!     let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
74//!
75//!     let tx_descriptors = esp_hub75::hub75_dma_descriptors!(FBType);
76//!
77//!     let pins = Hub75Pins16 {
78//!         red1: peripherals.GPIO19.degrade(),
79//!         grn1: peripherals.GPIO20.degrade(),
80//!         blu1: peripherals.GPIO21.degrade(),
81//!         red2: peripherals.GPIO22.degrade(),
82//!         grn2: peripherals.GPIO23.degrade(),
83//!         blu2: peripherals.GPIO15.degrade(),
84//!         addr0: peripherals.GPIO10.degrade(),
85//!         addr1: peripherals.GPIO8.degrade(),
86//!         addr2: peripherals.GPIO1.degrade(),
87//!         addr3: peripherals.GPIO0.degrade(),
88//!         addr4: peripherals.GPIO11.degrade(),
89//!         blank: peripherals.GPIO5.degrade(),
90//!         clock: peripherals.GPIO7.degrade(),
91//!         latch: peripherals.GPIO6.degrade(),
92//!     };
93//!
94//!     let fb = mk_static!(FBType, FBType::new());
95//!     let text_style = MonoTextStyleBuilder::new()
96//!         .font(&FONT_5X7)
97//!         .text_color(Color::YELLOW)
98//!         .background_color(Color::BLACK)
99//!         .build();
100//!     let point = Point::new(32, 32);
101//!     Text::with_alignment("Hello, World!", point, text_style, Alignment::Center)
102//!         .draw(fb)
103//!         .expect("failed to draw text");
104//!
105//!     let _hub75 = Hub75::new(
106//!         peripherals.PARL_IO,
107//!         pins,
108//!         peripherals.DMA_CH0,
109//!         tx_descriptors,
110//!         Hub75Config::new(),
111//!         &*fb,
112//!     )
113//!     .expect("failed to create Hub75");
114//!
115//!     loop {
116//!         core::hint::spin_loop();
117//!     }
118//! }
119//! ```
120//!
121//! ## Crate Features
122//!
123//! - `esp32`: Enable support for the ESP32
124//! - `esp32s3`: Enable support for the ESP32-S3
125//! - `esp32c5`: Enable support for the ESP32-C5
126//! - `esp32c6`: Enable support for the ESP32-C6
127//! - `defmt`: Enable logging with `defmt`
128//! - `log`: Enable logging with the `log` crate
129//! - `invert-blank`: Invert the blank signal in hardware by enabling the GPIO
130//!   output inverter on the blank pin. Applies to both 8-bit latched
131//!   (`Hub75Pins8`) and 16-bit direct-drive (`Hub75Pins16`) configurations.
132//!   Some latch controller boards include a hardware inverter on the blank
133//!   line; enable this feature to compensate.
134//! - `invert-clock`: Invert the clock signal. By default the driver outputs
135//!   data that changes on the falling edge of CLK so that it is stable when the
136//!   panel latches on the rising edge. Enable this feature if your panel
137//!   requires the opposite polarity.
138//! - `invert-oe`: Forwards to the `hub75-framebuffer` crate, inverting the
139//!   output-enable (OE) signal in the generated data stream. Whereas
140//!   `invert-blank` inverts the blank pin in hardware, this feature flips the
141//!   OE polarity at the framebuffer level instead. The two features may seem
142//!   redundant but are meant to be used together: where the peripheral drives
143//!   all pins to 0 when a transfer completes, `invert-blank` turns that idle 0
144//!   into a 1 (blanked), and `invert-oe` compensates for the now-inverted pin.
145//! - `full-chain-dma`: Build the entire BCM repetition chain in a single DMA
146//!   transfer instead of one plane per interrupt. This reduces interrupt
147//!   frequency at the cost of more DMA descriptor RAM. Note that the ESP32-C6
148//!   `PARL_IO` peripheral has a 65,535-byte per-transfer limit, which
149//!   constrains the maximum panel size and plane count when this feature is
150//!   enabled.
151//! - `circular-dma`: Circular DMA descriptor chain (implies `full-chain-dma`).
152//!   The DMA engine starts once and loops forever; buffer swaps are
153//!   pointer-delta updates applied by the swap-boundary ISR at a pass boundary,
154//!   so there is no DMA stop/restart and no mid-frame tearing. In steady state
155//!   **no interrupts are enabled**: a swap temporarily arms the boundary
156//!   detector (`suc_eof` on the last descriptor) and the ISR disarms it again
157//!   after applying the swap. On ESP32-C5 (`PARL_IO`) a consumed `suc_eof`
158//!   *halts* the DMA channel, so the ISR restarts the transfer after each swap;
159//!   on ESP32/S3 the chain free-runs uninterrupted. Supported on ESP32 (`I2S`),
160//!   ESP32-S3 (`LCD_CAM`), and ESP32-C5 (`PARL_IO`); on ESP32-C6 this is a
161//!   compile-time error because `PARL_IO` cannot do circular chains.
162//! - `skip-black-pixels`: Forwards to the `hub75-framebuffer` crate, enabling
163//!   an optimization that skips writing black pixels to the framebuffer.
164//! - `tail-closes-latch`: Forwards to the `hub75-framebuffer` crate. Appends a
165//!   tail word at the end of each DMA buffer (`plain` framebuffers) or at the
166//!   end of each bit-plane (`bitplane::plain`) that drives LATCH LOW when the
167//!   transfer completes. Does not apply to latched framebuffers.
168//! - `iram`: Place the driver's hot path — the refresh ISR, the DMA
169//!   start/finish/wait path, and the BCM segment and descriptor bookkeeping,
170//!   including the framebuffer pointer-delta swap — in Instruction RAM (IRAM)
171//!   to avoid flash-cache stalls (for example during Wi-Fi, PSRAM, or SPI-flash
172//!   activity) that can cause visible flicker. Drawing (`set_pixel`) stays in
173//!   flash. Costs roughly 1–2 KiB of IRAM (about 4 KiB at `opt-level = 0`).
174//! - `lead-blank-1/2/4/8/16` / `trail-blank-1/2/4/8/16`: Forwards to
175//!   `hub75-framebuffer`. Control the number of pixel-clock cycles of blanking
176//!   (OE HIGH) inserted around row address changes. The lead blank controls
177//!   blanking *before* the address change, and the trail blank controls
178//!   blanking *after*. Higher values reduce ghosting at the cost of slightly
179//!   less brightness.
180//! - `inter-row-blank-4/8/16/32`: Forwards to `hub75-framebuffer`. Insert
181//!   additional dead clock cycles at the end of each row. In plain framebuffers
182//!   the gap defers the address change to the first pixel of the next row,
183//!   giving slow panels more time to finish blanking. In latched framebuffers
184//!   the gap adds extra blanked cycles after the address change.
185//! - `reverse-row-order`: Forwards to `hub75-framebuffer`. Stores the rows of
186//!   the framebuffer in reverse scan order so that the DMA stream renders the
187//!   last panel row first and row 0 last.
188//!
189//! ## Safety
190//!
191//! This crate uses `unsafe` code to interface with hardware peripherals, but it
192//! exposes a safe, high-level API.
193
194#![no_std]
195#![warn(missing_docs)]
196#![warn(clippy::all)]
197#![warn(clippy::pedantic)]
198
199use core::cell::Cell;
200use core::marker::PhantomData;
201
202use esp_hal::gpio::AnyPin;
203use esp_hal::interrupt::Priority;
204use esp_hal::time::Rate;
205pub use hub75_framebuffer as framebuffer;
206#[doc(hidden)]
207pub use static_cell;
208pub(crate) mod bcm;
209
210/// Configuration for creating a [`Hub75`] instance.
211///
212/// Passed to [`Hub75::new`](crate::Hub75::new) and
213/// [`Hub75::new_async`](crate::Hub75::new_async) instead of a bare frequency.
214///
215/// [`Hub75Config::new`] (and [`Default`]) start from a 10 MHz pixel clock and
216/// the peripheral's default interrupt priority; override either with the
217/// [`with_frequency`](Hub75Config::with_frequency) and
218/// [`with_interrupt_priority`](Hub75Config::with_interrupt_priority) builders.
219///
220/// The theoretical refresh rate for a given framebuffer type and pixel clock
221/// can be computed at compile time with [`refresh_hz`].
222#[derive(Debug, Clone, Copy)]
223pub struct Hub75Config {
224    /// The HUB75 pixel-clock frequency.
225    pub frequency: Rate,
226    /// Interrupt priority for the HUB75 refresh ISR.
227    ///
228    /// `None` (the default) leaves the ISR at esp-hal's default interrupt
229    /// priority (`Priority::min()`). Raising it lets the refresh ISR preempt
230    /// lower-priority interrupt handlers, which is the main practical
231    /// anti-flicker lever on multi-core chips:
232    ///
233    /// - **ESP32**: Wi-Fi and other long-running interrupt handlers run at low
234    ///   priority and can delay the refresh ISR by hundreds of microseconds,
235    ///   causing visible flicker. Set the priority to `Priority::Priority3`
236    ///   (the maximum) and enable the `iram` feature to keep the ISR resident
237    ///   in instruction RAM.
238    /// - **ESP32-S3**: same treatment, especially when Wi-Fi is active.
239    /// - **Single-core RISC-V chips (C5/C6)**: interrupt priority control is
240    ///   more fine-grained there; raising the priority mainly protects the
241    ///   refresh ISR against other same-core interrupt handlers.
242    ///
243    /// Note that a higher ISR priority increases the latency of everything
244    /// it preempts — including Wi-Fi bookkeeping — so use the lowest value
245    /// that eliminates flicker.
246    pub interrupt_priority: Option<Priority>,
247}
248
249impl Hub75Config {
250    /// Creates a new configuration with the default 10 MHz pixel clock.
251    ///
252    /// Use [`with_frequency`](Hub75Config::with_frequency) to override the
253    /// pixel clock, and
254    /// [`with_interrupt_priority`](Hub75Config::with_interrupt_priority) to
255    /// raise the refresh ISR priority.
256    #[must_use]
257    pub const fn new() -> Self {
258        Self {
259            frequency: Rate::from_mhz(10),
260            interrupt_priority: None,
261        }
262    }
263
264    /// Sets the HUB75 pixel-clock frequency.
265    #[must_use]
266    pub const fn with_frequency(mut self, frequency: Rate) -> Self {
267        self.frequency = frequency;
268        self
269    }
270
271    /// Sets the interrupt priority of the HUB75 refresh ISR.
272    ///
273    /// See [`Hub75Config::interrupt_priority`] for guidance.
274    #[must_use]
275    pub const fn with_interrupt_priority(mut self, priority: Priority) -> Self {
276        self.interrupt_priority = Some(priority);
277        self
278    }
279}
280
281impl Default for Hub75Config {
282    /// Returns the default configuration: a 10 MHz pixel clock and the
283    /// peripheral's default interrupt priority.
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg_attr(hub75_use_i2s_parallel, path = "i2s_parallel.rs")]
290#[cfg_attr(hub75_use_lcd_cam, path = "lcd_cam.rs")]
291#[cfg_attr(hub75_use_parl_io, path = "parl_io.rs")]
292mod driver;
293mod isr;
294
295/// Seam between the documented [`Hub75::new`] / [`Hub75::new_async`]
296/// constructors and the chip-specific backends.
297///
298/// Exactly one backend is compiled in, selected by target chip: `LCD_CAM`
299/// (ESP32-S3), `PARL_IO` (ESP32-C5 / ESP32-C6), or I2S in parallel mode
300/// (ESP32). Each backend implements this trait for its peripheral type, and
301/// the constructors delegate to [`construct`](Hub75Backend::construct), so the
302/// whole construction path monomorphizes to the selected backend with no
303/// dynamic dispatch.
304///
305/// This trait is internal to the driver and is not part of the public API.
306#[doc(hidden)]
307pub trait Hub75Backend<FB, P, CH>
308where
309    FB: framebuffer::FrameBuffer + 'static,
310    P: Hub75Pins<Word = FB::Word>,
311{
312    /// Configures the peripheral, applies the pin assignments, and binds the
313    /// refresh ISR. The initial DMA transfer is started afterwards by
314    /// [`Hub75::new`] / [`Hub75::new_async`].
315    ///
316    /// Called by [`Hub75::new`] / [`Hub75::new_async`] with `self` set to the
317    /// peripheral instance passed to the constructor. Those constructors claim
318    /// the singleton driver slot and validate the framebuffer first, so
319    /// implementations can start directly with their own peripheral setup.
320    ///
321    /// # Errors
322    ///
323    /// Propagates peripheral configuration failures (the backend-specific
324    /// variants listed on [`Hub75::new`]).
325    fn construct<const N: usize>(
326        self,
327        pins: P,
328        channel: CH,
329        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
330        config: Hub75Config,
331    ) -> Result<(), Hub75Error>;
332}
333
334/// HUB75 display controller driven by an interrupt-based BCM refresh loop.
335///
336/// Created via [`Hub75::new`] (blocking) or [`Hub75::new_async`] (async).
337/// The constructor configures the peripheral, applies pin assignments, and
338/// immediately starts DMA-driven display refresh with the provided
339/// framebuffer.
340///
341/// The pin configuration's [`Hub75Pins::Word`](crate::Hub75Pins) type must
342/// match the framebuffer's
343/// [`FrameBuffer::Word`](crate::framebuffer::FrameBuffer::Word); mismatches
344/// are caught at compile time.
345///
346/// `DM` is the driver mode ([`Blocking`](esp_hal::Blocking) or
347/// [`Async`](esp_hal::Async)) and `FB` is the concrete framebuffer type.
348///
349/// Call [`swap()`](Hub75::swap) to exchange framebuffers. It returns a
350/// [`Hub75Swap`] transfer object that can be waited on:
351/// - [`Hub75Swap::wait()`] — spin-loops until the DMA is guaranteed to no
352///   longer read from the old buffer, then returns it.
353/// - [`Hub75Swap::wait_for_done()`] — yields to the executor (async contexts).
354///   Call [`Hub75Swap::wait()`] afterwards to get the result.
355/// - [`Hub75Swap::is_done()`] — non-blocking completion check.
356///
357/// Only **one** `Hub75` instance may exist at a time. The driver uses
358/// module-level statics for the ISR state machine, so creating a second
359/// instance would overwrite the first.
360///
361/// **Framebuffer data must reside in internal DRAM, not PSRAM.** PSRAM
362/// needs cache writeback before DMA reads, and this driver's custom DMA
363/// buffer paths don't do that. A debug assertion checks this at init.
364///
365/// `Hub75` does not implement [`Drop`]. The ISR-driven refresh runs for the
366/// lifetime of the program.
367pub struct Hub75<DM: esp_hal::DriverMode, FB> {
368    _dm: PhantomData<DM>,
369    _fb: PhantomData<fn() -> FB>,
370    _not_sync: PhantomData<Cell<()>>,
371}
372
373// SAFETY: `Hub75` is a zero-sized handle that owns no data — every field is a
374// `PhantomData`. The real driver state (DMA transfer handle, `BcmBuf`, ISR
375// state machine) lives in module-level statics serialized by
376// `esp_sync::NonReentrantMutex`, never inside `Hub75` itself, so moving a
377// `Hub75` between threads is safe regardless of `DM`. This explicit `Send` is
378// required because `esp_hal::Async` is `!Send` (esp-rs/esp-hal#2980, which
379// stops async drivers migrating to another core with their interrupt handler);
380// without it `Hub75<esp_hal::Async, _>` would be `!Send` and unmovable into a
381// spawned task.
382unsafe impl<DM: esp_hal::DriverMode, FB> Send for Hub75<DM, FB> {}
383
384// `Hub75` is intentionally `!Sync` via the `_not_sync: PhantomData<Cell<()>>`
385// field: `Cell<T>` is never `Sync`, and `PhantomData<T>` is `Sync` only when
386// `T` is, so `Hub75` is never `Sync`. `Cell<()>` is chosen over a raw pointer
387// (which is `!Send` *and* `!Sync`) because the marker itself is `Send`; `Send`
388// for the whole type is nonetheless now guaranteed explicitly by the
389// `unsafe impl Send` above, not by field derivation.
390//
391// The `!Sync` bound matters because `swap()` takes `&self` and `STATE` would
392// serialize concurrent callers, but sharing a `&Hub75` across cores would still
393// let two threads race to be the one outstanding swap and would make the
394// single-waker-slot protocol in `SWAP_WAKER` ambiguous. Requiring ownership
395// (`Send` but not `Sync`) keeps the driver single-owner by construction.
396
397impl<DM: esp_hal::DriverMode, FB> Hub75<DM, FB> {
398    pub(crate) fn from_phantom() -> Self {
399        Self {
400            _dm: PhantomData,
401            _fb: PhantomData,
402            _not_sync: PhantomData,
403        }
404    }
405
406    /// Establishes the invariants every constructor must satisfy before a
407    /// backend is allowed to touch hardware: the singleton driver slot is
408    /// claimed, and the framebuffer (together with every BCM segment it
409    /// exposes) is confirmed to live in internal DRAM rather than PSRAM.
410    ///
411    /// Claiming first means a second `Hub75` fails with
412    /// [`Hub75Error::AlreadyInitialised`] before any backend state is
413    /// overwritten. The DRAM assertion is repeated inside `start_internal`
414    /// (which also covers the `Hub75::restart` path); checking it here as well
415    /// makes a PSRAM framebuffer fail before the peripheral, ISR, or DMA state
416    /// is set up.
417    fn claim_and_validate(fb: &'static FB) -> Result<(), Hub75Error>
418    where
419        FB: framebuffer::FrameBuffer + 'static,
420    {
421        crate::isr::claim_driver()?;
422        crate::bcm::validate_fb_internal_ram(fb);
423        Ok(())
424    }
425}
426
427impl<FB: framebuffer::FrameBuffer + 'static> Hub75<esp_hal::Blocking, FB> {
428    /// Creates a new blocking HUB75 driver.
429    ///
430    /// Configures the chip's panel-driving peripheral, applies the pin
431    /// assignments, and immediately starts DMA-driven display refresh with the
432    /// provided framebuffer. The peripheral is whichever one this chip's
433    /// backend uses — `LCD_CAM` on the ESP32-S3, `PARL_IO` on the ESP32-C5 and
434    /// ESP32-C6, or I2S in parallel mode on the ESP32 (see the [crate-level
435    /// documentation](crate)).
436    ///
437    /// The pin configuration's word type must match the framebuffer's word
438    /// type; passing a 16-bit framebuffer with 8-bit pins (or vice versa)
439    /// is a compile-time error.
440    ///
441    /// Takes the peripheral instance, the HUB75 pin configuration (8-bit or
442    /// 16-bit), a DMA channel, DMA descriptor storage from
443    /// [`hub75_dma_descriptors!`], the backend configuration, and the initial
444    /// framebuffer to display.
445    ///
446    /// # Errors
447    ///
448    /// Returns [`Hub75Error::AlreadyInitialised`] if a `Hub75` instance
449    /// already exists. Returns [`Hub75Error::AlreadyRunning`] or
450    /// [`Hub75Error::Dma`] if the initial DMA transfer fails, and a
451    /// backend-specific configuration variant when peripheral setup fails
452    /// (`Hub75Error::I8080` on `LCD_CAM`; `Hub75Error::ParlIo` or
453    /// `Hub75Error::ConfigError` on `PARL_IO`).
454    ///
455    /// [`hub75_dma_descriptors!`]: crate::hub75_dma_descriptors
456    pub fn new<B, P, CH, const N: usize>(
457        peripheral: B,
458        pins: P,
459        channel: CH,
460        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
461        config: Hub75Config,
462        fb: &'static FB,
463    ) -> Result<Self, Hub75Error>
464    where
465        P: Hub75Pins<Word = FB::Word>,
466        B: Hub75Backend<FB, P, CH>,
467    {
468        Self::claim_and_validate(fb)?;
469        peripheral.construct(pins, channel, tx_descriptors, config)?;
470        crate::isr::start_internal(fb)?;
471        Ok(Self::from_phantom())
472    }
473}
474
475impl<FB: framebuffer::FrameBuffer + 'static> Hub75<esp_hal::Async, FB> {
476    /// Creates a new async HUB75 driver.
477    ///
478    /// Identical to [`Hub75::new`], except that a pending framebuffer swap can
479    /// yield to an async executor with [`Hub75Swap::wait_for_done`] before
480    /// blocking on [`Hub75Swap::wait`].
481    ///
482    /// # Errors
483    ///
484    /// See [`Hub75::new`].
485    pub fn new_async<B, P, CH, const N: usize>(
486        peripheral: B,
487        pins: P,
488        channel: CH,
489        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
490        config: Hub75Config,
491        fb: &'static FB,
492    ) -> Result<Self, Hub75Error>
493    where
494        P: Hub75Pins<Word = FB::Word>,
495        B: Hub75Backend<FB, P, CH>,
496    {
497        Self::claim_and_validate(fb)?;
498        peripheral.construct(pins, channel, tx_descriptors, config)?;
499        crate::isr::start_internal(fb)?;
500        Ok(Self::from_phantom())
501    }
502}
503
504/// A pending framebuffer swap.
505///
506/// Returned by [`Hub75::swap`]. The old framebuffer is not safe to reuse until
507/// the DMA is guaranteed to no longer be reading from it. Call
508/// [`wait_for_done()`](Self::wait_for_done) (async) to yield until safe, then
509/// [`wait()`](Self::wait) to obtain the old framebuffer. Or call `wait()`
510/// directly for a blocking spin-loop.
511///
512/// In non-circular mode, "safe" means the ISR has hit a frame boundary and
513/// completed the swap. In circular-DMA mode, "safe" means at least one
514/// `suc_eof` interrupt has fired after the pointer update, guaranteeing the
515/// DMA has completed a full pass and is reading exclusively from the new
516/// buffer.
517#[must_use = "call .wait() to reclaim the old framebuffer, or the buffer is leaked"]
518pub struct Hub75Swap<FB: 'static> {
519    pub(crate) old_fb_ptr: *mut FB,
520    pub(crate) new_fb_ptr: *mut FB,
521}
522
523// SAFETY: The raw pointer always originates from a `&'static mut FB`. Only
524// one `Hub75Swap` exists at a time: `Hub75::swap()` returns
525// `Err(Hub75Error::SwapInFlight, _)` if called while a previous swap is still
526// in-flight, and `Hub75` is `!Sync`, so concurrent `swap()` calls from
527// multiple threads are impossible.
528unsafe impl<FB: 'static> Send for Hub75Swap<FB> {}
529
530/// The color type used by the HUB75 driver.
531pub use hub75_framebuffer::Color;
532
533#[cfg(all(feature = "circular-dma", esp32c6))]
534compile_error!(
535    "circular-dma is not supported on ESP32-C6: the PARL_IO peripheral \
536     stops after the first transfer even with a circular descriptor chain."
537);
538
539/// Maximum number of bytes a single DMA descriptor can transfer on this
540/// platform.
541///
542/// Used by [`dma_descriptor_count`] and [`hub75_dma_descriptors!`] to
543/// compute the required number of DMA descriptors.
544#[doc(hidden)]
545pub const MAX_DMA_CHUNK_SIZE: usize = esp_hal::dma::CHUNK_SIZE;
546
547/// Computes the number of DMA descriptors this driver needs for a
548/// framebuffer of type `FB`.
549///
550/// `max_chunk` is the maximum number of bytes a single DMA descriptor can
551/// transfer (see [`MAX_DMA_CHUNK_SIZE`]).
552///
553/// The count is derived from the framebuffer's static BCM sequence
554/// ([`framebuffer::FrameBuffer::BCM_SEQUENCE`]): a segment of `len`
555/// bytes streamed `reps` times needs `ceil(len / max_chunk) * reps`
556/// descriptors. What exactly is returned depends on the driver's DMA mode:
557///
558/// - **`full-chain-dma` (implied by `circular-dma`):** the whole BCM scan
559///   sequence is chained into a single transfer, so this is the total over all
560///   segments of all periods.
561/// - **Default (group-based):** each transfer covers one group of
562///   [`framebuffer::FrameBuffer::BCM_SEGMENTS_PER_GROUP`] segments and the
563///   descriptor table is rebuilt between transfers, so this is the maximum over
564///   the groups of one period, considerably smaller than the total for
565///   row-major framebuffers.
566///
567/// This is a `const fn` of the framebuffer *type* (no instance needed), so
568/// descriptor tables can be allocated statically, e.g. via
569/// [`hub75_dma_descriptors!`].
570///
571/// # Panics
572///
573/// * In const evaluation (compile-time) if `max_chunk` is zero.
574/// * At compile time if
575///   [`BCM_SEQUENCE_LEN`](framebuffer::FrameBuffer::BCM_SEQUENCE_LEN) is not
576///   divisible by
577///   [`BCM_SEGMENTS_PER_GROUP`](framebuffer::FrameBuffer::BCM_SEGMENTS_PER_GROUP).
578///   Well-formed [`FrameBuffer`](framebuffer::FrameBuffer) implementations
579///   always satisfy this invariant.
580#[must_use]
581pub const fn dma_descriptor_count<FB: framebuffer::FrameBuffer>(max_chunk: usize) -> usize {
582    assert!(max_chunk > 0, "max_chunk must be greater than zero");
583    const {
584        assert!(
585            FB::BCM_SEQUENCE_LEN % FB::BCM_SEGMENTS_PER_GROUP == 0,
586            "BCM_SEQUENCE_LEN must be divisible by BCM_SEGMENTS_PER_GROUP"
587        );
588    }
589    let period = FB::BCM_SEQUENCE_LEN;
590    #[cfg(feature = "full-chain-dma")]
591    let group_size = period; // the whole period is chained into one transfer
592    #[cfg(not(feature = "full-chain-dma"))]
593    let group_size = FB::BCM_SEGMENTS_PER_GROUP;
594    let groups = period / group_size;
595    let mut max_group = 0usize;
596    let mut g = 0usize;
597    while g < groups {
598        let count = group_descriptor_count::<FB>(g, group_size, max_chunk);
599        if count > max_group {
600            max_group = count;
601        }
602        g += 1;
603    }
604    #[cfg(feature = "full-chain-dma")]
605    {
606        // all periods are identical and chained together
607        max_group *= FB::BCM_SEQUENCE_COUNT;
608    }
609    max_group
610}
611
612/// DMA descriptors needed to stream one BCM segment of `len` bytes `reps`
613/// times through `max_chunk`-byte descriptors.
614///
615/// The single implementation of the per-segment descriptor arithmetic, shared
616/// by [`dma_descriptor_count`] and [`group_descriptor_count`].
617#[must_use]
618pub(crate) const fn segment_descriptor_count(len: usize, reps: usize, max_chunk: usize) -> usize {
619    len.div_ceil(max_chunk) * reps
620}
621
622/// DMA descriptors needed for `group_size` consecutive segments starting at
623/// segment `group_idx * group_size` of framebuffer type `FB`'s BCM sequence.
624///
625/// Groups never straddle a period, so when `group_size` divides
626/// [`BCM_SEQUENCE_LEN`](framebuffer::FrameBuffer::BCM_SEQUENCE_LEN) this is the
627/// descriptor count of one transfer group, identical for every period.
628/// [`dma_descriptor_count`] reduces the groups of a period to a single total;
629/// the linear-mode ISR instead indexes the per-group counts directly.
630#[must_use]
631pub(crate) const fn group_descriptor_count<FB: framebuffer::FrameBuffer>(
632    group_idx: usize,
633    group_size: usize,
634    max_chunk: usize,
635) -> usize {
636    let start = group_idx * group_size;
637    let mut total = 0;
638    let mut j = 0;
639    while j < group_size {
640        let entry = FB::BCM_SEQUENCE[(start + j) % FB::BCM_SEQUENCE_LEN];
641        total += segment_descriptor_count(entry.len, entry.reps, max_chunk);
642        j += 1;
643    }
644    total
645}
646
647/// Number of pixel-clock cycles the DMA streams for one complete panel
648/// refresh of framebuffer type `FB`.
649///
650/// This is derived from the framebuffer's static BCM sequence
651/// ([`framebuffer::FrameBuffer::BCM_SEQUENCE`]): a segment of `len` bytes
652/// streamed `reps` times contributes `len / size_of::<Word>()` clock cycles
653/// per repetition. Because the BCM sequence includes the lead/trail blanking,
654/// inter-row gap, and end-of-row trailer segments (whichever are enabled via
655/// features), this count is *exact* for the enabled configuration, not an
656/// approximation.
657///
658/// This is a `const fn` of the framebuffer *type* (no instance needed).
659#[must_use]
660pub const fn frame_clock_cycles<FB: framebuffer::FrameBuffer>() -> u64 {
661    let mut cycles = 0u64;
662    let word = core::mem::size_of::<FB::Word>() as u64;
663    let mut seq = 0;
664    while seq < FB::BCM_SEQUENCE_COUNT {
665        let mut i = 0;
666        while i < FB::BCM_SEQUENCE_LEN {
667            let entry = FB::BCM_SEQUENCE[i];
668            cycles += (entry.len as u64 / word) * entry.reps as u64;
669            i += 1;
670        }
671        seq += 1;
672    }
673    cycles
674}
675
676/// Theoretical refresh rate (in Hz) for framebuffer type `FB` at the given
677/// HUB75 pixel-clock frequency.
678///
679/// One complete panel refresh streams
680/// [`frame_clock_cycles::<FB>()`](frame_clock_cycles) pixel clocks, so:
681///
682/// ```text
683/// refresh_hz = frequency / frame_clock_cycles::<FB>()
684/// ```
685///
686/// This is exact for the enabled configuration (blanking features, row gap,
687/// and trailer segments are all accounted for by
688/// [`frame_clock_cycles`]). It is an upper bound in the default group-based
689/// DMA mode, where the small per-group ISR turnaround adds a few cycles per
690/// BCM group; with `full-chain-dma` or `circular-dma` the value matches
691/// measured refresh rates.
692///
693/// Use this to sanity-check a configuration before committing to it: for
694/// example, a 64×64 panel with 8 planes at 10 MHz yields only ~19 Hz, which
695/// is visibly dim and flickery — reduce the plane count or raise the pixel
696/// clock.
697///
698/// # Examples
699///
700/// ```rust,ignore
701/// type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
702/// const REFRESH_HZ: u32 = esp_hub75::refresh_hz::<FBType>(Rate::from_mhz(10));
703/// ```
704#[must_use]
705#[allow(clippy::cast_possible_truncation)] // refresh rates fit comfortably in u32
706pub const fn refresh_hz<FB: framebuffer::FrameBuffer>(frequency: Rate) -> u32 {
707    frequency.as_hz() / frame_clock_cycles::<FB>() as u32
708}
709
710/// DMA descriptor storage bound to a specific framebuffer type.
711///
712/// The descriptor array is stored inline and sized at compile time from the
713/// framebuffer type `FB` and the enabled DMA features (see
714/// [`COUNT`][Self::COUNT] and [`dma_descriptor_count`]). The type parameter
715/// makes it a compile error to pass descriptor storage built for one
716/// framebuffer type to a driver instance configured for another.
717///
718/// Construct only via [`hub75_dma_descriptors!`], which allocates the storage
719/// in a `static_cell::StaticCell` and returns
720/// `&'static mut Hub75DmaDescriptors<FB, N>`.
721pub struct Hub75DmaDescriptors<FB, const N: usize> {
722    descriptors: [esp_hal::dma::DmaDescriptor; N],
723    _fb: PhantomData<fn() -> FB>,
724}
725
726impl<FB: framebuffer::FrameBuffer, const N: usize> Hub75DmaDescriptors<FB, N> {
727    /// Compile-time descriptor count required for framebuffer type `FB`
728    /// under the currently enabled DMA features.
729    pub const COUNT: usize = dma_descriptor_count::<FB>(MAX_DMA_CHUNK_SIZE);
730
731    /// Creates the storage with all descriptors initialized to
732    /// [`esp_hal::dma::DmaDescriptor::EMPTY`], asserting at (per-instantiation)
733    /// const evaluation time that `N` matches [`Self::COUNT`].
734    ///
735    /// Not intended for direct use; [`hub75_dma_descriptors!`] is the only
736    /// sanctioned constructor and always satisfies this assertion.
737    #[doc(hidden)]
738    #[must_use]
739    pub const fn new() -> Self {
740        assert!(
741            N == Self::COUNT,
742            "descriptor array size does not match the count required by the framebuffer type"
743        );
744        Self {
745            descriptors: [esp_hal::dma::DmaDescriptor::EMPTY; N],
746            _fb: PhantomData,
747        }
748    }
749
750    /// Number of descriptors held (equal to [`Self::COUNT`] by construction).
751    #[must_use]
752    #[allow(clippy::len_without_is_empty)] // the count is compile-time, never zero
753    pub const fn len(&self) -> usize {
754        self.descriptors.len()
755    }
756
757    /// Mutable view of the descriptor array for the driver internals.
758    pub(crate) fn as_slice(&mut self) -> &mut [esp_hal::dma::DmaDescriptor] {
759        &mut self.descriptors
760    }
761}
762
763/// Allocates static DMA descriptors sized for the given framebuffer type.
764///
765/// This macro computes the required number of DMA descriptors at compile
766/// time with [`dma_descriptor_count`] and allocates them in a static cell
767/// wrapped in [`Hub75DmaDescriptors`]. It returns
768/// `&'static mut Hub75DmaDescriptors<$fb_type, N>` suitable for passing to
769/// [`Hub75::new`] or [`Hub75::new_async`].
770///
771/// Because the returned storage is typed by the framebuffer type, passing
772/// descriptors built for a different framebuffer type is a compile error.
773///
774/// # Examples
775/// ```rust,ignore
776/// type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
777/// let tx_descriptors = esp_hub75::hub75_dma_descriptors!(FBType);
778/// ```
779#[macro_export]
780macro_rules! hub75_dma_descriptors {
781    ($fb_type:ty) => {{
782        const __N: usize = $crate::dma_descriptor_count::<$fb_type>($crate::MAX_DMA_CHUNK_SIZE);
783        static __DESC_CELL: $crate::static_cell::StaticCell<
784            $crate::Hub75DmaDescriptors<$fb_type, __N>,
785        > = $crate::static_cell::StaticCell::new();
786        __DESC_CELL
787            .uninit()
788            .write($crate::Hub75DmaDescriptors::new())
789    }};
790}
791
792/// Pin configuration for a HUB75 panel without an external address latch.
793///
794/// This configuration requires 16 bits of data per pixel transfer, as the row
795/// address lines are driven directly along with the color data.
796pub struct Hub75Pins16<'d> {
797    /// Red data line for the upper half of the display
798    pub red1: AnyPin<'d>,
799    /// Green data line for the upper half of the display
800    pub grn1: AnyPin<'d>,
801    /// Blue data line for the upper half of the display
802    pub blu1: AnyPin<'d>,
803    /// Red data line for the lower half of the display
804    pub red2: AnyPin<'d>,
805    /// Green data line for the lower half of the display
806    pub grn2: AnyPin<'d>,
807    /// Blue data line for the lower half of the display
808    pub blu2: AnyPin<'d>,
809    /// Address line 0 for row selection
810    pub addr0: AnyPin<'d>,
811    /// Address line 1 for row selection
812    pub addr1: AnyPin<'d>,
813    /// Address line 2 for row selection
814    pub addr2: AnyPin<'d>,
815    /// Address line 3 for row selection
816    pub addr3: AnyPin<'d>,
817    /// Address line 4 for row selection
818    pub addr4: AnyPin<'d>,
819    /// Blank signal to control display output
820    pub blank: AnyPin<'d>,
821    /// Clock signal for data synchronization
822    pub clock: AnyPin<'d>,
823    /// Latch signal to update display data
824    pub latch: AnyPin<'d>,
825}
826
827/// Pin configuration for a HUB75 panel with an external address latch.
828///
829/// This configuration is more memory-efficient, requiring only 8 bits of data
830/// per pixel transfer. The row address is set once per row and held by an
831/// external latch on the controller board. For an example of a latch circuit,
832/// see the [`hub75-framebuffer` crate documentation](https://crates.io/crates/hub75-framebuffer)
833/// and its [GitHub repository](https://github.com/liebman/hub75-framebuffer).
834pub struct Hub75Pins8<'d> {
835    /// Red data line for the upper half of the display
836    pub red1: AnyPin<'d>,
837    /// Green data line for the upper half of the display
838    pub grn1: AnyPin<'d>,
839    /// Blue data line for the upper half of the display
840    pub blu1: AnyPin<'d>,
841    /// Red data line for the lower half of the display
842    pub red2: AnyPin<'d>,
843    /// Green data line for the lower half of the display
844    pub grn2: AnyPin<'d>,
845    /// Blue data line for the lower half of the display
846    pub blu2: AnyPin<'d>,
847    /// Blank signal to control display output
848    pub blank: AnyPin<'d>,
849    /// Clock signal for data synchronization
850    pub clock: AnyPin<'d>,
851    /// Latch signal to update display data
852    pub latch: AnyPin<'d>,
853}
854
855/// Describes the pins used to drive a HUB75 panel.
856///
857/// Implemented by [`Hub75Pins8`] for latched (8-bit) controller boards and
858/// [`Hub75Pins16`] for direct-drive (16-bit) boards. The trait hides the
859/// differences in pin configuration between peripherals (I2S, LCD-CAM,
860/// `PARL_IO`).
861pub trait Hub75Pins {
862    /// The word type for this pin configuration (`u8` for 8-bit, `u16` for
863    /// 16-bit).
864    ///
865    /// This type must match
866    /// [`FrameBuffer::Word`](framebuffer::FrameBuffer::Word). The driver
867    /// constructors reject a mismatch at compile time.
868    type Word;
869
870    /// Returns the bus width (8-bit or 16-bit) for this pin configuration.
871    fn word_size(&self) -> crate::framebuffer::WordSize;
872}
873
874/// Errors returned by the HUB75 driver.
875///
876/// Wraps the `esp-hal` DMA, buffer, and peripheral errors in one type.
877#[derive(Debug, Clone, Copy, PartialEq)]
878#[cfg_attr(feature = "defmt", derive(defmt::Format))]
879pub enum Hub75Error {
880    /// The driver has not been initialized (no `Hub75` instance exists).
881    NotInitialised,
882    /// The driver has already been initialized. A `Hub75` instance runs for
883    /// the whole program and cannot be released, so only one may exist.
884    AlreadyInitialised,
885    /// Error during a DMA transfer
886    Dma(esp_hal::dma::DmaError),
887    /// Error while managing DMA buffers
888    DmaBuf(esp_hal::dma::DmaBufError),
889    /// A framebuffer swap is already in flight; only one
890    /// [`Hub75Swap`] may be outstanding at a time. Call
891    /// `.wait()` (or `.wait_for_done().await` then `.wait()`) on the
892    /// previous swap before calling `swap()` again.
893    SwapInFlight,
894    /// Error from the `PARL_IO` peripheral
895    #[cfg(hub75_use_parl_io)]
896    ParlIo(esp_hal::parl_io::Error),
897    /// Configuration error for the `PARL_IO` peripheral
898    #[cfg(hub75_use_parl_io)]
899    ConfigError(esp_hal::parl_io::ConfigError),
900    /// Configuration error for the I8080 interface (`LCD_CAM`)
901    #[cfg(hub75_use_lcd_cam)]
902    I8080(esp_hal::lcd_cam::lcd::i8080::ConfigError),
903    /// The driver is already running. `restart()` or `start()` was called while
904    /// a transfer was in-flight. Wait for the transfer to complete before
905    /// restarting.
906    AlreadyRunning,
907}
908
909impl core::fmt::Display for Hub75Error {
910    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
911        match self {
912            Self::NotInitialised => write!(f, "Hub75 not initialized"),
913            Self::AlreadyInitialised => write!(f, "Hub75 driver already initialized"),
914            Self::SwapInFlight => write!(f, "framebuffer swap already in flight"),
915            Self::Dma(e) => write!(f, "DMA error: {e:?}"),
916            Self::DmaBuf(e) => write!(f, "DMA buffer error: {e:?}"),
917            #[cfg(hub75_use_parl_io)]
918            Self::ParlIo(e) => write!(f, "PARL_IO error: {e:?}"),
919            #[cfg(hub75_use_parl_io)]
920            Self::ConfigError(e) => write!(f, "PARL_IO config error: {e:?}"),
921            #[cfg(hub75_use_lcd_cam)]
922            Self::I8080(e) => write!(f, "I8080 config error: {e:?}"),
923            Self::AlreadyRunning => write!(
924                f,
925                "driver is already running; call wait() on the outstanding swap before restarting"
926            ),
927        }
928    }
929}
930
931impl From<esp_hal::dma::DmaError> for Hub75Error {
932    fn from(e: esp_hal::dma::DmaError) -> Self {
933        Self::Dma(e)
934    }
935}
936
937impl From<esp_hal::dma::DmaBufError> for Hub75Error {
938    fn from(e: esp_hal::dma::DmaBufError) -> Self {
939        Self::DmaBuf(e)
940    }
941}
942
943#[cfg(hub75_use_parl_io)]
944impl From<esp_hal::parl_io::Error> for Hub75Error {
945    fn from(e: esp_hal::parl_io::Error) -> Self {
946        Self::ParlIo(e)
947    }
948}
949
950#[cfg(hub75_use_parl_io)]
951impl From<esp_hal::parl_io::ConfigError> for Hub75Error {
952    fn from(e: esp_hal::parl_io::ConfigError) -> Self {
953        Self::ConfigError(e)
954    }
955}