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}