esp-hub75 0.17.0

A Rust driver for HUB75 LED matrix displays on ESP32 microcontrollers
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
//! # ESP-HUB75
//!
//! A `no-std` driver for HUB75-style LED matrix panels on ESP32-series
//! microcontrollers.
//!
//! The panel is refreshed over DMA with almost no CPU involvement, using
//! whichever peripheral fits each chip best:
//!
//! - **ESP32-S3**: Uses the `LCD_CAM` peripheral
//! - **ESP32-C6**: Uses the `PARL_IO` peripheral
//! - **ESP32-C5**: Uses the `PARL_IO` peripheral (8-bit mode only; requires a
//!   latch circuit and `Hub75Pins8`)
//! - **ESP32**: Uses the I2S peripheral in parallel mode
//!
//! ## Framebuffers
//!
//! Use the **bitplane** framebuffers from the `hub75-framebuffer` crate.
//! They come in two variants: direct-drive (16-bit, no external latch) and
//! latched (8-bit, needs an external address-latch circuit). Both can be
//! handed to the peripheral as-is; there is no extra formatting step.
//!
//! Bitplane framebuffers (`framebuffer::bitplane::plain::DmaFrameBuffer` /
//! `framebuffer::bitplane::latched::DmaFrameBuffer`) store only one bit per
//! pixel per plane. The driver assembles the BCM (Binary Code Modulation)
//! output on the fly with DMA descriptors, so RAM use stays low without
//! losing visual quality.
//!
//! ## Usage
//!
//! Example for ESP32-C6:
//!
//! ```rust,no_run
//! #![no_std]
//! #![no_main]
//!
//! use embedded_graphics::Drawable;
//! use embedded_graphics::geometry::Point;
//! use embedded_graphics::mono_font::MonoTextStyleBuilder;
//! use embedded_graphics::mono_font::ascii::FONT_5X7;
//! use embedded_graphics::prelude::RgbColor;
//! use embedded_graphics::text::Alignment;
//! use embedded_graphics::text::Text;
//! use esp_backtrace as _;
//! use esp_hal::clock::CpuClock;
//! use esp_hal::gpio::Pin;
//! use esp_hal::main;
//! use esp_hub75::Color;
//! use esp_hub75::Hub75;
//! use esp_hub75::Hub75Pins16;
//! use esp_hub75::framebuffer::bitplane::plain::DmaFrameBuffer;
//! use esp_hub75::framebuffer::compute_rows;
//!
//! esp_bootloader_esp_idf::esp_app_desc!();
//!
//! const ROWS: usize = 64;
//! const COLS: usize = 64;
//! const NROWS: usize = compute_rows(ROWS);
//! const PLANES: usize = 4;
//!
//! type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
//!
//! macro_rules! mk_static {
//!     ($t:ty,$val:expr) => {{
//!         static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
//!         #[deny(unused_attributes)]
//!         let x = STATIC_CELL.uninit().write($val);
//!         x
//!     }};
//! }
//!
//! #[main]
//! fn main() -> ! {
//!     let peripherals = esp_hal::init(esp_hal::Config::default().with_cpu_clock(CpuClock::max()));
//!
//!     let tx_descriptors = esp_hub75::hub75_dma_descriptors!(FBType);
//!
//!     let pins = Hub75Pins16 {
//!         red1: peripherals.GPIO19.degrade(),
//!         grn1: peripherals.GPIO20.degrade(),
//!         blu1: peripherals.GPIO21.degrade(),
//!         red2: peripherals.GPIO22.degrade(),
//!         grn2: peripherals.GPIO23.degrade(),
//!         blu2: peripherals.GPIO15.degrade(),
//!         addr0: peripherals.GPIO10.degrade(),
//!         addr1: peripherals.GPIO8.degrade(),
//!         addr2: peripherals.GPIO1.degrade(),
//!         addr3: peripherals.GPIO0.degrade(),
//!         addr4: peripherals.GPIO11.degrade(),
//!         blank: peripherals.GPIO5.degrade(),
//!         clock: peripherals.GPIO7.degrade(),
//!         latch: peripherals.GPIO6.degrade(),
//!     };
//!
//!     let fb = mk_static!(FBType, FBType::new());
//!     let text_style = MonoTextStyleBuilder::new()
//!         .font(&FONT_5X7)
//!         .text_color(Color::YELLOW)
//!         .background_color(Color::BLACK)
//!         .build();
//!     let point = Point::new(32, 32);
//!     Text::with_alignment("Hello, World!", point, text_style, Alignment::Center)
//!         .draw(fb)
//!         .expect("failed to draw text");
//!
//!     let _hub75 = Hub75::new(
//!         peripherals.PARL_IO,
//!         pins,
//!         peripherals.DMA_CH0,
//!         tx_descriptors,
//!         Hub75Config::new(),
//!         &*fb,
//!     )
//!     .expect("failed to create Hub75");
//!
//!     loop {
//!         core::hint::spin_loop();
//!     }
//! }
//! ```
//!
//! ## Crate Features
//!
//! - `esp32`: Enable support for the ESP32
//! - `esp32s3`: Enable support for the ESP32-S3
//! - `esp32c5`: Enable support for the ESP32-C5
//! - `esp32c6`: Enable support for the ESP32-C6
//! - `defmt`: Enable logging with `defmt`
//! - `log`: Enable logging with the `log` crate
//! - `invert-blank`: Invert the blank signal in hardware by enabling the GPIO
//!   output inverter on the blank pin. Applies to both 8-bit latched
//!   (`Hub75Pins8`) and 16-bit direct-drive (`Hub75Pins16`) configurations.
//!   Some latch controller boards include a hardware inverter on the blank
//!   line; enable this feature to compensate.
//! - `invert-clock`: Invert the clock signal. By default the driver outputs
//!   data that changes on the falling edge of CLK so that it is stable when the
//!   panel latches on the rising edge. Enable this feature if your panel
//!   requires the opposite polarity.
//! - `invert-oe`: Forwards to the `hub75-framebuffer` crate, inverting the
//!   output-enable (OE) signal in the generated data stream. Whereas
//!   `invert-blank` inverts the blank pin in hardware, this feature flips the
//!   OE polarity at the framebuffer level instead. The two features may seem
//!   redundant but are meant to be used together: where the peripheral drives
//!   all pins to 0 when a transfer completes, `invert-blank` turns that idle 0
//!   into a 1 (blanked), and `invert-oe` compensates for the now-inverted pin.
//! - `full-chain-dma`: Build the entire BCM repetition chain in a single DMA
//!   transfer instead of one plane per interrupt. This reduces interrupt
//!   frequency at the cost of more DMA descriptor RAM. Note that the ESP32-C6
//!   `PARL_IO` peripheral has a 65,535-byte per-transfer limit, which
//!   constrains the maximum panel size and plane count when this feature is
//!   enabled.
//! - `circular-dma`: Circular DMA descriptor chain (implies `full-chain-dma`).
//!   The DMA engine starts once and loops forever; buffer swaps are
//!   pointer-delta updates applied by the swap-boundary ISR at a pass boundary,
//!   so there is no DMA stop/restart and no mid-frame tearing. In steady state
//!   **no interrupts are enabled**: a swap temporarily arms the boundary
//!   detector (`suc_eof` on the last descriptor) and the ISR disarms it again
//!   after applying the swap. On ESP32-C5 (`PARL_IO`) a consumed `suc_eof`
//!   *halts* the DMA channel, so the ISR restarts the transfer after each swap;
//!   on ESP32/S3 the chain free-runs uninterrupted. Supported on ESP32 (`I2S`),
//!   ESP32-S3 (`LCD_CAM`), and ESP32-C5 (`PARL_IO`); on ESP32-C6 this is a
//!   compile-time error because `PARL_IO` cannot do circular chains.
//! - `skip-black-pixels`: Forwards to the `hub75-framebuffer` crate, enabling
//!   an optimization that skips writing black pixels to the framebuffer.
//! - `tail-closes-latch`: Forwards to the `hub75-framebuffer` crate. Appends a
//!   tail word at the end of each DMA buffer (`plain` framebuffers) or at the
//!   end of each bit-plane (`bitplane::plain`) that drives LATCH LOW when the
//!   transfer completes. Does not apply to latched framebuffers.
//! - `iram`: Place the driver's hot path — the refresh ISR, the DMA
//!   start/finish/wait path, and the BCM segment and descriptor bookkeeping,
//!   including the framebuffer pointer-delta swap — in Instruction RAM (IRAM)
//!   to avoid flash-cache stalls (for example during Wi-Fi, PSRAM, or SPI-flash
//!   activity) that can cause visible flicker. Drawing (`set_pixel`) stays in
//!   flash. Costs roughly 1–2 KiB of IRAM (about 4 KiB at `opt-level = 0`).
//! - `lead-blank-1/2/4/8/16` / `trail-blank-1/2/4/8/16`: Forwards to
//!   `hub75-framebuffer`. Control the number of pixel-clock cycles of blanking
//!   (OE HIGH) inserted around row address changes. The lead blank controls
//!   blanking *before* the address change, and the trail blank controls
//!   blanking *after*. Higher values reduce ghosting at the cost of slightly
//!   less brightness.
//! - `inter-row-blank-4/8/16/32`: Forwards to `hub75-framebuffer`. Insert
//!   additional dead clock cycles at the end of each row. In plain framebuffers
//!   the gap defers the address change to the first pixel of the next row,
//!   giving slow panels more time to finish blanking. In latched framebuffers
//!   the gap adds extra blanked cycles after the address change.
//! - `reverse-row-order`: Forwards to `hub75-framebuffer`. Stores the rows of
//!   the framebuffer in reverse scan order so that the DMA stream renders the
//!   last panel row first and row 0 last.
//!
//! ## Safety
//!
//! This crate uses `unsafe` code to interface with hardware peripherals, but it
//! exposes a safe, high-level API.

#![no_std]
#![warn(missing_docs)]
#![warn(clippy::all)]
#![warn(clippy::pedantic)]

use core::cell::Cell;
use core::marker::PhantomData;

use esp_hal::gpio::AnyPin;
use esp_hal::interrupt::Priority;
use esp_hal::time::Rate;
pub use hub75_framebuffer as framebuffer;
#[doc(hidden)]
pub use static_cell;
pub(crate) mod bcm;

/// Configuration for creating a [`Hub75`] instance.
///
/// Passed to [`Hub75::new`](crate::Hub75::new) and
/// [`Hub75::new_async`](crate::Hub75::new_async) instead of a bare frequency.
///
/// [`Hub75Config::new`] (and [`Default`]) start from a 10 MHz pixel clock and
/// the peripheral's default interrupt priority; override either with the
/// [`with_frequency`](Hub75Config::with_frequency) and
/// [`with_interrupt_priority`](Hub75Config::with_interrupt_priority) builders.
///
/// The theoretical refresh rate for a given framebuffer type and pixel clock
/// can be computed at compile time with [`refresh_hz`].
#[derive(Debug, Clone, Copy)]
pub struct Hub75Config {
    /// The HUB75 pixel-clock frequency.
    pub frequency: Rate,
    /// Interrupt priority for the HUB75 refresh ISR.
    ///
    /// `None` (the default) leaves the ISR at esp-hal's default interrupt
    /// priority (`Priority::min()`). Raising it lets the refresh ISR preempt
    /// lower-priority interrupt handlers, which is the main practical
    /// anti-flicker lever on multi-core chips:
    ///
    /// - **ESP32**: Wi-Fi and other long-running interrupt handlers run at low
    ///   priority and can delay the refresh ISR by hundreds of microseconds,
    ///   causing visible flicker. Set the priority to `Priority::Priority3`
    ///   (the maximum) and enable the `iram` feature to keep the ISR resident
    ///   in instruction RAM.
    /// - **ESP32-S3**: same treatment, especially when Wi-Fi is active.
    /// - **Single-core RISC-V chips (C5/C6)**: interrupt priority control is
    ///   more fine-grained there; raising the priority mainly protects the
    ///   refresh ISR against other same-core interrupt handlers.
    ///
    /// Note that a higher ISR priority increases the latency of everything
    /// it preempts — including Wi-Fi bookkeeping — so use the lowest value
    /// that eliminates flicker.
    pub interrupt_priority: Option<Priority>,
}

impl Hub75Config {
    /// Creates a new configuration with the default 10 MHz pixel clock.
    ///
    /// Use [`with_frequency`](Hub75Config::with_frequency) to override the
    /// pixel clock, and
    /// [`with_interrupt_priority`](Hub75Config::with_interrupt_priority) to
    /// raise the refresh ISR priority.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            frequency: Rate::from_mhz(10),
            interrupt_priority: None,
        }
    }

    /// Sets the HUB75 pixel-clock frequency.
    #[must_use]
    pub const fn with_frequency(mut self, frequency: Rate) -> Self {
        self.frequency = frequency;
        self
    }

    /// Sets the interrupt priority of the HUB75 refresh ISR.
    ///
    /// See [`Hub75Config::interrupt_priority`] for guidance.
    #[must_use]
    pub const fn with_interrupt_priority(mut self, priority: Priority) -> Self {
        self.interrupt_priority = Some(priority);
        self
    }
}

impl Default for Hub75Config {
    /// Returns the default configuration: a 10 MHz pixel clock and the
    /// peripheral's default interrupt priority.
    fn default() -> Self {
        Self::new()
    }
}

#[cfg_attr(hub75_use_i2s_parallel, path = "i2s_parallel.rs")]
#[cfg_attr(hub75_use_lcd_cam, path = "lcd_cam.rs")]
#[cfg_attr(hub75_use_parl_io, path = "parl_io.rs")]
mod driver;
mod isr;

/// Seam between the documented [`Hub75::new`] / [`Hub75::new_async`]
/// constructors and the chip-specific backends.
///
/// Exactly one backend is compiled in, selected by target chip: `LCD_CAM`
/// (ESP32-S3), `PARL_IO` (ESP32-C5 / ESP32-C6), or I2S in parallel mode
/// (ESP32). Each backend implements this trait for its peripheral type, and
/// the constructors delegate to [`construct`](Hub75Backend::construct), so the
/// whole construction path monomorphizes to the selected backend with no
/// dynamic dispatch.
///
/// This trait is internal to the driver and is not part of the public API.
#[doc(hidden)]
pub trait Hub75Backend<FB, P, CH>
where
    FB: framebuffer::FrameBuffer + 'static,
    P: Hub75Pins<Word = FB::Word>,
{
    /// Configures the peripheral, applies the pin assignments, and binds the
    /// refresh ISR. The initial DMA transfer is started afterwards by
    /// [`Hub75::new`] / [`Hub75::new_async`].
    ///
    /// Called by [`Hub75::new`] / [`Hub75::new_async`] with `self` set to the
    /// peripheral instance passed to the constructor. Those constructors claim
    /// the singleton driver slot and validate the framebuffer first, so
    /// implementations can start directly with their own peripheral setup.
    ///
    /// # Errors
    ///
    /// Propagates peripheral configuration failures (the backend-specific
    /// variants listed on [`Hub75::new`]).
    fn construct<const N: usize>(
        self,
        pins: P,
        channel: CH,
        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
        config: Hub75Config,
    ) -> Result<(), Hub75Error>;
}

/// HUB75 display controller driven by an interrupt-based BCM refresh loop.
///
/// Created via [`Hub75::new`] (blocking) or [`Hub75::new_async`] (async).
/// The constructor configures the peripheral, applies pin assignments, and
/// immediately starts DMA-driven display refresh with the provided
/// framebuffer.
///
/// The pin configuration's [`Hub75Pins::Word`](crate::Hub75Pins) type must
/// match the framebuffer's
/// [`FrameBuffer::Word`](crate::framebuffer::FrameBuffer::Word); mismatches
/// are caught at compile time.
///
/// `DM` is the driver mode ([`Blocking`](esp_hal::Blocking) or
/// [`Async`](esp_hal::Async)) and `FB` is the concrete framebuffer type.
///
/// Call [`swap()`](Hub75::swap) to exchange framebuffers. It returns a
/// [`Hub75Swap`] transfer object that can be waited on:
/// - [`Hub75Swap::wait()`] — spin-loops until the DMA is guaranteed to no
///   longer read from the old buffer, then returns it.
/// - [`Hub75Swap::wait_for_done()`] — yields to the executor (async contexts).
///   Call [`Hub75Swap::wait()`] afterwards to get the result.
/// - [`Hub75Swap::is_done()`] — non-blocking completion check.
///
/// Only **one** `Hub75` instance may exist at a time. The driver uses
/// module-level statics for the ISR state machine, so creating a second
/// instance would overwrite the first.
///
/// **Framebuffer data must reside in internal DRAM, not PSRAM.** PSRAM
/// needs cache writeback before DMA reads, and this driver's custom DMA
/// buffer paths don't do that. A debug assertion checks this at init.
///
/// `Hub75` does not implement [`Drop`]. The ISR-driven refresh runs for the
/// lifetime of the program.
pub struct Hub75<DM: esp_hal::DriverMode, FB> {
    _dm: PhantomData<DM>,
    _fb: PhantomData<fn() -> FB>,
    _not_sync: PhantomData<Cell<()>>,
}

// SAFETY: `Hub75` is a zero-sized handle that owns no data — every field is a
// `PhantomData`. The real driver state (DMA transfer handle, `BcmBuf`, ISR
// state machine) lives in module-level statics serialized by
// `esp_sync::NonReentrantMutex`, never inside `Hub75` itself, so moving a
// `Hub75` between threads is safe regardless of `DM`. This explicit `Send` is
// required because `esp_hal::Async` is `!Send` (esp-rs/esp-hal#2980, which
// stops async drivers migrating to another core with their interrupt handler);
// without it `Hub75<esp_hal::Async, _>` would be `!Send` and unmovable into a
// spawned task.
unsafe impl<DM: esp_hal::DriverMode, FB> Send for Hub75<DM, FB> {}

// `Hub75` is intentionally `!Sync` via the `_not_sync: PhantomData<Cell<()>>`
// field: `Cell<T>` is never `Sync`, and `PhantomData<T>` is `Sync` only when
// `T` is, so `Hub75` is never `Sync`. `Cell<()>` is chosen over a raw pointer
// (which is `!Send` *and* `!Sync`) because the marker itself is `Send`; `Send`
// for the whole type is nonetheless now guaranteed explicitly by the
// `unsafe impl Send` above, not by field derivation.
//
// The `!Sync` bound matters because `swap()` takes `&self` and `STATE` would
// serialize concurrent callers, but sharing a `&Hub75` across cores would still
// let two threads race to be the one outstanding swap and would make the
// single-waker-slot protocol in `SWAP_WAKER` ambiguous. Requiring ownership
// (`Send` but not `Sync`) keeps the driver single-owner by construction.

impl<DM: esp_hal::DriverMode, FB> Hub75<DM, FB> {
    pub(crate) fn from_phantom() -> Self {
        Self {
            _dm: PhantomData,
            _fb: PhantomData,
            _not_sync: PhantomData,
        }
    }

    /// Establishes the invariants every constructor must satisfy before a
    /// backend is allowed to touch hardware: the singleton driver slot is
    /// claimed, and the framebuffer (together with every BCM segment it
    /// exposes) is confirmed to live in internal DRAM rather than PSRAM.
    ///
    /// Claiming first means a second `Hub75` fails with
    /// [`Hub75Error::AlreadyInitialised`] before any backend state is
    /// overwritten. The DRAM assertion is repeated inside `start_internal`
    /// (which also covers the `Hub75::restart` path); checking it here as well
    /// makes a PSRAM framebuffer fail before the peripheral, ISR, or DMA state
    /// is set up.
    fn claim_and_validate(fb: &'static FB) -> Result<(), Hub75Error>
    where
        FB: framebuffer::FrameBuffer + 'static,
    {
        crate::isr::claim_driver()?;
        crate::bcm::validate_fb_internal_ram(fb);
        Ok(())
    }
}

impl<FB: framebuffer::FrameBuffer + 'static> Hub75<esp_hal::Blocking, FB> {
    /// Creates a new blocking HUB75 driver.
    ///
    /// Configures the chip's panel-driving peripheral, applies the pin
    /// assignments, and immediately starts DMA-driven display refresh with the
    /// provided framebuffer. The peripheral is whichever one this chip's
    /// backend uses — `LCD_CAM` on the ESP32-S3, `PARL_IO` on the ESP32-C5 and
    /// ESP32-C6, or I2S in parallel mode on the ESP32 (see the [crate-level
    /// documentation](crate)).
    ///
    /// The pin configuration's word type must match the framebuffer's word
    /// type; passing a 16-bit framebuffer with 8-bit pins (or vice versa)
    /// is a compile-time error.
    ///
    /// Takes the peripheral instance, the HUB75 pin configuration (8-bit or
    /// 16-bit), a DMA channel, DMA descriptor storage from
    /// [`hub75_dma_descriptors!`], the backend configuration, and the initial
    /// framebuffer to display.
    ///
    /// # Errors
    ///
    /// Returns [`Hub75Error::AlreadyInitialised`] if a `Hub75` instance
    /// already exists. Returns [`Hub75Error::AlreadyRunning`] or
    /// [`Hub75Error::Dma`] if the initial DMA transfer fails, and a
    /// backend-specific configuration variant when peripheral setup fails
    /// (`Hub75Error::I8080` on `LCD_CAM`; `Hub75Error::ParlIo` or
    /// `Hub75Error::ConfigError` on `PARL_IO`).
    ///
    /// [`hub75_dma_descriptors!`]: crate::hub75_dma_descriptors
    pub fn new<B, P, CH, const N: usize>(
        peripheral: B,
        pins: P,
        channel: CH,
        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
        config: Hub75Config,
        fb: &'static FB,
    ) -> Result<Self, Hub75Error>
    where
        P: Hub75Pins<Word = FB::Word>,
        B: Hub75Backend<FB, P, CH>,
    {
        Self::claim_and_validate(fb)?;
        peripheral.construct(pins, channel, tx_descriptors, config)?;
        crate::isr::start_internal(fb)?;
        Ok(Self::from_phantom())
    }
}

impl<FB: framebuffer::FrameBuffer + 'static> Hub75<esp_hal::Async, FB> {
    /// Creates a new async HUB75 driver.
    ///
    /// Identical to [`Hub75::new`], except that a pending framebuffer swap can
    /// yield to an async executor with [`Hub75Swap::wait_for_done`] before
    /// blocking on [`Hub75Swap::wait`].
    ///
    /// # Errors
    ///
    /// See [`Hub75::new`].
    pub fn new_async<B, P, CH, const N: usize>(
        peripheral: B,
        pins: P,
        channel: CH,
        tx_descriptors: &'static mut Hub75DmaDescriptors<FB, N>,
        config: Hub75Config,
        fb: &'static FB,
    ) -> Result<Self, Hub75Error>
    where
        P: Hub75Pins<Word = FB::Word>,
        B: Hub75Backend<FB, P, CH>,
    {
        Self::claim_and_validate(fb)?;
        peripheral.construct(pins, channel, tx_descriptors, config)?;
        crate::isr::start_internal(fb)?;
        Ok(Self::from_phantom())
    }
}

/// A pending framebuffer swap.
///
/// Returned by [`Hub75::swap`]. The old framebuffer is not safe to reuse until
/// the DMA is guaranteed to no longer be reading from it. Call
/// [`wait_for_done()`](Self::wait_for_done) (async) to yield until safe, then
/// [`wait()`](Self::wait) to obtain the old framebuffer. Or call `wait()`
/// directly for a blocking spin-loop.
///
/// In non-circular mode, "safe" means the ISR has hit a frame boundary and
/// completed the swap. In circular-DMA mode, "safe" means at least one
/// `suc_eof` interrupt has fired after the pointer update, guaranteeing the
/// DMA has completed a full pass and is reading exclusively from the new
/// buffer.
#[must_use = "call .wait() to reclaim the old framebuffer, or the buffer is leaked"]
pub struct Hub75Swap<FB: 'static> {
    pub(crate) old_fb_ptr: *mut FB,
    pub(crate) new_fb_ptr: *mut FB,
}

// SAFETY: The raw pointer always originates from a `&'static mut FB`. Only
// one `Hub75Swap` exists at a time: `Hub75::swap()` returns
// `Err(Hub75Error::SwapInFlight, _)` if called while a previous swap is still
// in-flight, and `Hub75` is `!Sync`, so concurrent `swap()` calls from
// multiple threads are impossible.
unsafe impl<FB: 'static> Send for Hub75Swap<FB> {}

/// The color type used by the HUB75 driver.
pub use hub75_framebuffer::Color;

#[cfg(all(feature = "circular-dma", esp32c6))]
compile_error!(
    "circular-dma is not supported on ESP32-C6: the PARL_IO peripheral \
     stops after the first transfer even with a circular descriptor chain."
);

/// Maximum number of bytes a single DMA descriptor can transfer on this
/// platform.
///
/// Used by [`dma_descriptor_count`] and [`hub75_dma_descriptors!`] to
/// compute the required number of DMA descriptors.
#[doc(hidden)]
pub const MAX_DMA_CHUNK_SIZE: usize = esp_hal::dma::CHUNK_SIZE;

/// Computes the number of DMA descriptors this driver needs for a
/// framebuffer of type `FB`.
///
/// `max_chunk` is the maximum number of bytes a single DMA descriptor can
/// transfer (see [`MAX_DMA_CHUNK_SIZE`]).
///
/// The count is derived from the framebuffer's static BCM sequence
/// ([`framebuffer::FrameBuffer::BCM_SEQUENCE`]): a segment of `len`
/// bytes streamed `reps` times needs `ceil(len / max_chunk) * reps`
/// descriptors. What exactly is returned depends on the driver's DMA mode:
///
/// - **`full-chain-dma` (implied by `circular-dma`):** the whole BCM scan
///   sequence is chained into a single transfer, so this is the total over all
///   segments of all periods.
/// - **Default (group-based):** each transfer covers one group of
///   [`framebuffer::FrameBuffer::BCM_SEGMENTS_PER_GROUP`] segments and the
///   descriptor table is rebuilt between transfers, so this is the maximum over
///   the groups of one period, considerably smaller than the total for
///   row-major framebuffers.
///
/// This is a `const fn` of the framebuffer *type* (no instance needed), so
/// descriptor tables can be allocated statically, e.g. via
/// [`hub75_dma_descriptors!`].
///
/// # Panics
///
/// * In const evaluation (compile-time) if `max_chunk` is zero.
/// * At compile time if
///   [`BCM_SEQUENCE_LEN`](framebuffer::FrameBuffer::BCM_SEQUENCE_LEN) is not
///   divisible by
///   [`BCM_SEGMENTS_PER_GROUP`](framebuffer::FrameBuffer::BCM_SEGMENTS_PER_GROUP).
///   Well-formed [`FrameBuffer`](framebuffer::FrameBuffer) implementations
///   always satisfy this invariant.
#[must_use]
pub const fn dma_descriptor_count<FB: framebuffer::FrameBuffer>(max_chunk: usize) -> usize {
    assert!(max_chunk > 0, "max_chunk must be greater than zero");
    const {
        assert!(
            FB::BCM_SEQUENCE_LEN % FB::BCM_SEGMENTS_PER_GROUP == 0,
            "BCM_SEQUENCE_LEN must be divisible by BCM_SEGMENTS_PER_GROUP"
        );
    }
    let period = FB::BCM_SEQUENCE_LEN;
    #[cfg(feature = "full-chain-dma")]
    let group_size = period; // the whole period is chained into one transfer
    #[cfg(not(feature = "full-chain-dma"))]
    let group_size = FB::BCM_SEGMENTS_PER_GROUP;
    let groups = period / group_size;
    let mut max_group = 0usize;
    let mut g = 0usize;
    while g < groups {
        let count = group_descriptor_count::<FB>(g, group_size, max_chunk);
        if count > max_group {
            max_group = count;
        }
        g += 1;
    }
    #[cfg(feature = "full-chain-dma")]
    {
        // all periods are identical and chained together
        max_group *= FB::BCM_SEQUENCE_COUNT;
    }
    max_group
}

/// DMA descriptors needed to stream one BCM segment of `len` bytes `reps`
/// times through `max_chunk`-byte descriptors.
///
/// The single implementation of the per-segment descriptor arithmetic, shared
/// by [`dma_descriptor_count`] and [`group_descriptor_count`].
#[must_use]
pub(crate) const fn segment_descriptor_count(len: usize, reps: usize, max_chunk: usize) -> usize {
    len.div_ceil(max_chunk) * reps
}

/// DMA descriptors needed for `group_size` consecutive segments starting at
/// segment `group_idx * group_size` of framebuffer type `FB`'s BCM sequence.
///
/// Groups never straddle a period, so when `group_size` divides
/// [`BCM_SEQUENCE_LEN`](framebuffer::FrameBuffer::BCM_SEQUENCE_LEN) this is the
/// descriptor count of one transfer group, identical for every period.
/// [`dma_descriptor_count`] reduces the groups of a period to a single total;
/// the linear-mode ISR instead indexes the per-group counts directly.
#[must_use]
pub(crate) const fn group_descriptor_count<FB: framebuffer::FrameBuffer>(
    group_idx: usize,
    group_size: usize,
    max_chunk: usize,
) -> usize {
    let start = group_idx * group_size;
    let mut total = 0;
    let mut j = 0;
    while j < group_size {
        let entry = FB::BCM_SEQUENCE[(start + j) % FB::BCM_SEQUENCE_LEN];
        total += segment_descriptor_count(entry.len, entry.reps, max_chunk);
        j += 1;
    }
    total
}

/// Number of pixel-clock cycles the DMA streams for one complete panel
/// refresh of framebuffer type `FB`.
///
/// This is derived from the framebuffer's static BCM sequence
/// ([`framebuffer::FrameBuffer::BCM_SEQUENCE`]): a segment of `len` bytes
/// streamed `reps` times contributes `len / size_of::<Word>()` clock cycles
/// per repetition. Because the BCM sequence includes the lead/trail blanking,
/// inter-row gap, and end-of-row trailer segments (whichever are enabled via
/// features), this count is *exact* for the enabled configuration, not an
/// approximation.
///
/// This is a `const fn` of the framebuffer *type* (no instance needed).
#[must_use]
pub const fn frame_clock_cycles<FB: framebuffer::FrameBuffer>() -> u64 {
    let mut cycles = 0u64;
    let word = core::mem::size_of::<FB::Word>() as u64;
    let mut seq = 0;
    while seq < FB::BCM_SEQUENCE_COUNT {
        let mut i = 0;
        while i < FB::BCM_SEQUENCE_LEN {
            let entry = FB::BCM_SEQUENCE[i];
            cycles += (entry.len as u64 / word) * entry.reps as u64;
            i += 1;
        }
        seq += 1;
    }
    cycles
}

/// Theoretical refresh rate (in Hz) for framebuffer type `FB` at the given
/// HUB75 pixel-clock frequency.
///
/// One complete panel refresh streams
/// [`frame_clock_cycles::<FB>()`](frame_clock_cycles) pixel clocks, so:
///
/// ```text
/// refresh_hz = frequency / frame_clock_cycles::<FB>()
/// ```
///
/// This is exact for the enabled configuration (blanking features, row gap,
/// and trailer segments are all accounted for by
/// [`frame_clock_cycles`]). It is an upper bound in the default group-based
/// DMA mode, where the small per-group ISR turnaround adds a few cycles per
/// BCM group; with `full-chain-dma` or `circular-dma` the value matches
/// measured refresh rates.
///
/// Use this to sanity-check a configuration before committing to it: for
/// example, a 64×64 panel with 8 planes at 10 MHz yields only ~19 Hz, which
/// is visibly dim and flickery — reduce the plane count or raise the pixel
/// clock.
///
/// # Examples
///
/// ```rust,ignore
/// type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
/// const REFRESH_HZ: u32 = esp_hub75::refresh_hz::<FBType>(Rate::from_mhz(10));
/// ```
#[must_use]
#[allow(clippy::cast_possible_truncation)] // refresh rates fit comfortably in u32
pub const fn refresh_hz<FB: framebuffer::FrameBuffer>(frequency: Rate) -> u32 {
    frequency.as_hz() / frame_clock_cycles::<FB>() as u32
}

/// DMA descriptor storage bound to a specific framebuffer type.
///
/// The descriptor array is stored inline and sized at compile time from the
/// framebuffer type `FB` and the enabled DMA features (see
/// [`COUNT`][Self::COUNT] and [`dma_descriptor_count`]). The type parameter
/// makes it a compile error to pass descriptor storage built for one
/// framebuffer type to a driver instance configured for another.
///
/// Construct only via [`hub75_dma_descriptors!`], which allocates the storage
/// in a `static_cell::StaticCell` and returns
/// `&'static mut Hub75DmaDescriptors<FB, N>`.
pub struct Hub75DmaDescriptors<FB, const N: usize> {
    descriptors: [esp_hal::dma::DmaDescriptor; N],
    _fb: PhantomData<fn() -> FB>,
}

impl<FB: framebuffer::FrameBuffer, const N: usize> Hub75DmaDescriptors<FB, N> {
    /// Compile-time descriptor count required for framebuffer type `FB`
    /// under the currently enabled DMA features.
    pub const COUNT: usize = dma_descriptor_count::<FB>(MAX_DMA_CHUNK_SIZE);

    /// Creates the storage with all descriptors initialized to
    /// [`esp_hal::dma::DmaDescriptor::EMPTY`], asserting at (per-instantiation)
    /// const evaluation time that `N` matches [`Self::COUNT`].
    ///
    /// Not intended for direct use; [`hub75_dma_descriptors!`] is the only
    /// sanctioned constructor and always satisfies this assertion.
    #[doc(hidden)]
    #[must_use]
    pub const fn new() -> Self {
        assert!(
            N == Self::COUNT,
            "descriptor array size does not match the count required by the framebuffer type"
        );
        Self {
            descriptors: [esp_hal::dma::DmaDescriptor::EMPTY; N],
            _fb: PhantomData,
        }
    }

    /// Number of descriptors held (equal to [`Self::COUNT`] by construction).
    #[must_use]
    #[allow(clippy::len_without_is_empty)] // the count is compile-time, never zero
    pub const fn len(&self) -> usize {
        self.descriptors.len()
    }

    /// Mutable view of the descriptor array for the driver internals.
    pub(crate) fn as_slice(&mut self) -> &mut [esp_hal::dma::DmaDescriptor] {
        &mut self.descriptors
    }
}

/// Allocates static DMA descriptors sized for the given framebuffer type.
///
/// This macro computes the required number of DMA descriptors at compile
/// time with [`dma_descriptor_count`] and allocates them in a static cell
/// wrapped in [`Hub75DmaDescriptors`]. It returns
/// `&'static mut Hub75DmaDescriptors<$fb_type, N>` suitable for passing to
/// [`Hub75::new`] or [`Hub75::new_async`].
///
/// Because the returned storage is typed by the framebuffer type, passing
/// descriptors built for a different framebuffer type is a compile error.
///
/// # Examples
/// ```rust,ignore
/// type FBType = DmaFrameBuffer<NROWS, COLS, PLANES>;
/// let tx_descriptors = esp_hub75::hub75_dma_descriptors!(FBType);
/// ```
#[macro_export]
macro_rules! hub75_dma_descriptors {
    ($fb_type:ty) => {{
        const __N: usize = $crate::dma_descriptor_count::<$fb_type>($crate::MAX_DMA_CHUNK_SIZE);
        static __DESC_CELL: $crate::static_cell::StaticCell<
            $crate::Hub75DmaDescriptors<$fb_type, __N>,
        > = $crate::static_cell::StaticCell::new();
        __DESC_CELL
            .uninit()
            .write($crate::Hub75DmaDescriptors::new())
    }};
}

/// Pin configuration for a HUB75 panel without an external address latch.
///
/// This configuration requires 16 bits of data per pixel transfer, as the row
/// address lines are driven directly along with the color data.
pub struct Hub75Pins16<'d> {
    /// Red data line for the upper half of the display
    pub red1: AnyPin<'d>,
    /// Green data line for the upper half of the display
    pub grn1: AnyPin<'d>,
    /// Blue data line for the upper half of the display
    pub blu1: AnyPin<'d>,
    /// Red data line for the lower half of the display
    pub red2: AnyPin<'d>,
    /// Green data line for the lower half of the display
    pub grn2: AnyPin<'d>,
    /// Blue data line for the lower half of the display
    pub blu2: AnyPin<'d>,
    /// Address line 0 for row selection
    pub addr0: AnyPin<'d>,
    /// Address line 1 for row selection
    pub addr1: AnyPin<'d>,
    /// Address line 2 for row selection
    pub addr2: AnyPin<'d>,
    /// Address line 3 for row selection
    pub addr3: AnyPin<'d>,
    /// Address line 4 for row selection
    pub addr4: AnyPin<'d>,
    /// Blank signal to control display output
    pub blank: AnyPin<'d>,
    /// Clock signal for data synchronization
    pub clock: AnyPin<'d>,
    /// Latch signal to update display data
    pub latch: AnyPin<'d>,
}

/// Pin configuration for a HUB75 panel with an external address latch.
///
/// This configuration is more memory-efficient, requiring only 8 bits of data
/// per pixel transfer. The row address is set once per row and held by an
/// external latch on the controller board. For an example of a latch circuit,
/// see the [`hub75-framebuffer` crate documentation](https://crates.io/crates/hub75-framebuffer)
/// and its [GitHub repository](https://github.com/liebman/hub75-framebuffer).
pub struct Hub75Pins8<'d> {
    /// Red data line for the upper half of the display
    pub red1: AnyPin<'d>,
    /// Green data line for the upper half of the display
    pub grn1: AnyPin<'d>,
    /// Blue data line for the upper half of the display
    pub blu1: AnyPin<'d>,
    /// Red data line for the lower half of the display
    pub red2: AnyPin<'d>,
    /// Green data line for the lower half of the display
    pub grn2: AnyPin<'d>,
    /// Blue data line for the lower half of the display
    pub blu2: AnyPin<'d>,
    /// Blank signal to control display output
    pub blank: AnyPin<'d>,
    /// Clock signal for data synchronization
    pub clock: AnyPin<'d>,
    /// Latch signal to update display data
    pub latch: AnyPin<'d>,
}

/// Describes the pins used to drive a HUB75 panel.
///
/// Implemented by [`Hub75Pins8`] for latched (8-bit) controller boards and
/// [`Hub75Pins16`] for direct-drive (16-bit) boards. The trait hides the
/// differences in pin configuration between peripherals (I2S, LCD-CAM,
/// `PARL_IO`).
pub trait Hub75Pins {
    /// The word type for this pin configuration (`u8` for 8-bit, `u16` for
    /// 16-bit).
    ///
    /// This type must match
    /// [`FrameBuffer::Word`](framebuffer::FrameBuffer::Word). The driver
    /// constructors reject a mismatch at compile time.
    type Word;

    /// Returns the bus width (8-bit or 16-bit) for this pin configuration.
    fn word_size(&self) -> crate::framebuffer::WordSize;
}

/// Errors returned by the HUB75 driver.
///
/// Wraps the `esp-hal` DMA, buffer, and peripheral errors in one type.
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
pub enum Hub75Error {
    /// The driver has not been initialized (no `Hub75` instance exists).
    NotInitialised,
    /// The driver has already been initialized. A `Hub75` instance runs for
    /// the whole program and cannot be released, so only one may exist.
    AlreadyInitialised,
    /// Error during a DMA transfer
    Dma(esp_hal::dma::DmaError),
    /// Error while managing DMA buffers
    DmaBuf(esp_hal::dma::DmaBufError),
    /// A framebuffer swap is already in flight; only one
    /// [`Hub75Swap`] may be outstanding at a time. Call
    /// `.wait()` (or `.wait_for_done().await` then `.wait()`) on the
    /// previous swap before calling `swap()` again.
    SwapInFlight,
    /// Error from the `PARL_IO` peripheral
    #[cfg(hub75_use_parl_io)]
    ParlIo(esp_hal::parl_io::Error),
    /// Configuration error for the `PARL_IO` peripheral
    #[cfg(hub75_use_parl_io)]
    ConfigError(esp_hal::parl_io::ConfigError),
    /// Configuration error for the I8080 interface (`LCD_CAM`)
    #[cfg(hub75_use_lcd_cam)]
    I8080(esp_hal::lcd_cam::lcd::i8080::ConfigError),
    /// The driver is already running. `restart()` or `start()` was called while
    /// a transfer was in-flight. Wait for the transfer to complete before
    /// restarting.
    AlreadyRunning,
}

impl core::fmt::Display for Hub75Error {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::NotInitialised => write!(f, "Hub75 not initialized"),
            Self::AlreadyInitialised => write!(f, "Hub75 driver already initialized"),
            Self::SwapInFlight => write!(f, "framebuffer swap already in flight"),
            Self::Dma(e) => write!(f, "DMA error: {e:?}"),
            Self::DmaBuf(e) => write!(f, "DMA buffer error: {e:?}"),
            #[cfg(hub75_use_parl_io)]
            Self::ParlIo(e) => write!(f, "PARL_IO error: {e:?}"),
            #[cfg(hub75_use_parl_io)]
            Self::ConfigError(e) => write!(f, "PARL_IO config error: {e:?}"),
            #[cfg(hub75_use_lcd_cam)]
            Self::I8080(e) => write!(f, "I8080 config error: {e:?}"),
            Self::AlreadyRunning => write!(
                f,
                "driver is already running; call wait() on the outstanding swap before restarting"
            ),
        }
    }
}

impl From<esp_hal::dma::DmaError> for Hub75Error {
    fn from(e: esp_hal::dma::DmaError) -> Self {
        Self::Dma(e)
    }
}

impl From<esp_hal::dma::DmaBufError> for Hub75Error {
    fn from(e: esp_hal::dma::DmaBufError) -> Self {
        Self::DmaBuf(e)
    }
}

#[cfg(hub75_use_parl_io)]
impl From<esp_hal::parl_io::Error> for Hub75Error {
    fn from(e: esp_hal::parl_io::Error) -> Self {
        Self::ParlIo(e)
    }
}

#[cfg(hub75_use_parl_io)]
impl From<esp_hal::parl_io::ConfigError> for Hub75Error {
    fn from(e: esp_hal::parl_io::ConfigError) -> Self {
        Self::ConfigError(e)
    }
}