Skip to main content

hub75_framebuffer/
lib.rs

1//! Framebuffer implementation for HUB75 LED matrix displays.
2//!
3//! ## How HUB75 LED Displays Work
4//!
5//! HUB75 RGB LED matrix panels are scanned, time-multiplexed displays that behave like a long
6//! daisy-chained shift register rather than a random-access framebuffer.
7//!
8//! ### Signal names
9//! - **R1 G1 B1 / R2 G2 B2** – Serial colour data for the upper and lower halves of the active scan line
10//! - **CLK** – Shift-register clock; every rising edge pushes the six colour bits one pixel to the right
11//! - **LAT / STB** – Latch; copies the shift-register contents to the LED drivers for the row currently selected by the address lines
12//! - **OE** – Output-Enable (active LOW): LEDs are lit while OE is LOW and blanked when it is HIGH
13//! - **A B C D (E)** – Row-address select lines (choose which pair of rows is lit)
14//! - **VCC & GND** – 5 V power for panel logic and LED drivers
15//!
16//! ### Row-pair scanning workflow (e.g., 1/16-scan panel)
17//! 1. While the panel is still displaying row pair N − 1, the controller shifts the six-bit colour data for row pair N into the chain (OE remains LOW so row N − 1 stays visible).
18//! 2. After the last pixel is clocked in, the controller raises OE HIGH to blank the LEDs.
19//! 3. With the panel blanked, it first changes the address lines to select row pair N, lets them settle for a few nanoseconds, and **then** pulses LAT to latch the freshly shifted data into the output drivers for that newly selected row.
20//! 4. OE is immediately driven LOW again, lighting row pair N.
21//! 5. Steps 1–4 repeat for every row pair fast enough (hundreds of Hz) that the human eye sees a steady image.
22//!    - If the first row pair is being shifted, the panel continues showing the last row pair of the previous frame until the first blank-address-latch sequence occurs.
23//!
24//! ### Brightness and colour depth (Binary Code Modulation)
25//! - Full colour is typically achieved using **Binary Code Modulation (BCM)**, also known as *Bit-Angle Modulation (BAM)*. Each bit-plane is displayed for a period proportional to its binary weight (1, 2, 4, 8 …), yielding 2ⁿ intensity levels per channel. See [Batsocks – LED dimming using Binary Code Modulation](https://www.batsocks.co.uk/readme/art_bcm_1.htm) for a deeper explanation.
26//! - Because each LED is on for only a fraction of the total frame time, the driver can use relatively high peak currents without overheating while average brightness is preserved.
27//!
28//! ### Implications for software / hardware drivers
29//! - You don't simply "write a pixel" once; you must continuously stream the complete refresh data at MHz-range clock rates.
30//! - Precise timing of CLK, OE, address lines, and LAT is critical—especially the order: blank (OE HIGH) → set address → latch → un-blank (OE LOW).
31//! - Microcontrollers typically employ DMA, PIO, or parallel GPIO tricks, and FPGAs use dedicated logic, to sustain the data throughput while leaving processing resources free.
32//!
33//! In short: a HUB75 panel is a high-speed shift-register chain that relies on rapid row-pair scanning and **Binary Code Modulation (BCM)** to create a bright, full-colour image. Keeping OE LOW almost all the time—blanking only long enough to change the address and pulse LAT—maximises brightness without visible artefacts.
34//!
35//! ## Framebuffer Implementations
36//!
37//! Four framebuffer layouts are provided, covering two hardware variants and
38//! two BCM strategies:
39//!
40//! | Module | Word size | External latch? | BCM strategy |
41//! |--------|-----------|-----------------|--------------|
42//! | [`plain`] | 16-bit | No | Threshold frames |
43//! | [`latched`] | 8-bit | Yes | Threshold frames |
44//! | [`bitplane::plain`] | 16-bit | No | True bitplane |
45//! | [`bitplane::latched`] | 8-bit | Yes | True bitplane |
46//!
47//! ### Plain vs. Latched
48//! - **Plain** packs all HUB75 signals (address, latch, OE, colour) into each
49//!   16-bit word. No extra hardware beyond a parallel output peripheral.
50//! - **Latched** uses 8-bit words with a separate external latch circuit to
51//!   hold the row address and gate the pixel clock, halving per-entry memory.
52//!
53//! ### Threshold Frames vs. True Bitplane
54//! The two BCM strategies differ in how they store colour data and how the DMA
55//! chain must be configured to render it.
56//!
57//! **Threshold frames** (`plain`, `latched`) -- the driver compares each
58//! channel's 8-bit value against per-frame thresholds and stores the resulting
59//! on/off bits. For a colour depth of `BITS`, this produces
60//! `2^BITS - 1` frames. Frame *n* is displayed for a duration proportional to
61//! `2^n`. Memory grows exponentially with colour depth.
62//!
63//! **True bitplane** (`bitplane::plain`, `bitplane::latched`) -- each of
64//! `PLANES` planes (typically 8) stores one bit of every colour channel
65//! directly. To render, configure the DMA descriptor chain so that each
66//! plane's data is output `2^(7 - plane_index)` times (plane 0 = MSB is
67//! scanned 128 times, plane 7 = LSB is scanned once). Memory scales linearly
68//! with the number of planes.
69//!
70//! All four variants have configurable row and column dimensions, support
71//! `embedded-graphics` via the `DrawTarget` trait, and expose per-plane
72//! pointers for DMA setup through the [`FrameBuffer`] trait.
73//!
74//! ## Multiple Panels
75//! Use [`tiling::TiledFrameBuffer`] to drive several HUB75 panels as one large
76//! virtual display. Combine it with a pixel-remapping policy such as
77//! [`tiling::ChainTopRightDown`] and any of the framebuffer flavours above.
78//! The wrapper exposes a single `embedded-graphics` canvas, so for example a
79//! 3 × 3 stack of 64 × 32 panels simply looks like a 192 × 96 screen while
80//! all coordinate translation happens transparently.
81//!
82//! ## Available Feature Flags
83//!
84//! ### `skip-black-pixels` Feature (disabled by default)
85//! When enabled, calls to `set_pixel()` with `Color::BLACK` return early without
86//! writing to the framebuffer. This provides a significant performance boost for
87//! UI applications that frequently draw black pixels (backgrounds, clearing, etc.)
88//! by assuming the framebuffer was already cleared.
89//!
90//! **Important**: This optimization assumes that black pixels represent "no change"
91//! rather than "explicitly set to black". By default, black pixels are written
92//! normally to ensure correct overwrite behavior. To enable the optimization:
93//!
94//! ```toml
95//! [dependencies]
96//! hub75-framebuffer = { version = "0.8.0", features = ["skip-black-pixels"] }
97//! ```
98//!
99//! ### `esp32-ordering` Feature (required for original ESP32 only)
100//! **Required** when targeting the original ESP32 chip (not ESP32-S3 or other variants).
101//! This feature adjusts byte ordering to accommodate the quirky requirements of the
102//! ESP32's I²S peripheral in 8-bit and 16-bit modes. The original ESP32 has different
103//! byte ordering requirements compared to other ESP32 variants (S2, S3, C3, etc.),
104//! which do **not** need this feature.
105//!
106//! ```toml
107//! [dependencies]
108//! hub75-framebuffer = { version = "0.8.0", features = ["esp32-ordering"] }
109//! ```
110//!
111//! ### `defmt` Feature
112//! Implements `defmt::Format` for framebuffer types so they can be emitted with
113//! the `defmt` logging framework. No functional changes; purely adds a trait impl.
114//!
115//! ### `doc-images` Feature
116//! Embeds documentation images when building docs on docs.rs. Not needed for
117//! normal usage.
118#![no_std]
119#![warn(missing_docs)]
120#![warn(clippy::all)]
121#![warn(clippy::pedantic)]
122#![allow(clippy::cast_possible_truncation)]
123#![allow(clippy::cast_sign_loss)]
124
125use embedded_graphics::draw_target::DrawTarget;
126use embedded_graphics::pixelcolor::Rgb888;
127use embedded_graphics::prelude::Point;
128
129pub mod bitplane;
130pub mod latched;
131pub mod plain;
132pub mod tiling;
133
134/// Color type used in the framebuffer
135pub type Color = Rgb888;
136
137/// Word size configuration for the framebuffer
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WordSize {
140    /// 8-bit word size
141    Eight,
142    /// 16-bit word size
143    Sixteen,
144}
145
146/// Computes the NROWS value from ROWS for `DmaFrameBuffer`
147///
148/// # Arguments
149///
150/// * `rows` - Total number of rows in the display
151///
152/// # Returns
153///
154/// Number of rows needed internally for `DmaFrameBuffer`
155#[must_use]
156pub const fn compute_rows(rows: usize) -> usize {
157    rows / 2
158}
159
160/// Computes the number of frames needed for a given bit depth
161///
162/// This is used to determine how many frames are needed to achieve
163/// the desired color depth through Binary Code Modulation (BCM).
164///
165/// # Arguments
166///
167/// * `bits` - Number of bits per color channel
168///
169/// # Returns
170///
171/// Number of frames required for the given bit depth
172#[must_use]
173pub const fn compute_frame_count(bits: u8) -> usize {
174    (1usize << bits) - 1
175}
176
177/// Trait for read-only framebuffers.
178pub trait FrameBuffer {
179    /// Returns the word size configuration for this framebuffer
180    fn get_word_size(&self) -> WordSize;
181
182    /// Returns the number of BCM bit-planes in this framebuffer.
183    ///
184    /// Contiguous (threshold-based) framebuffers return `1` — the entire
185    /// buffer is treated as a single plane.  True bit-plane framebuffers
186    /// return the number of planes (typically equal to the colour depth in
187    /// bits).
188    fn plane_count(&self) -> usize;
189
190    /// Returns a raw pointer and byte length for the given plane.
191    ///
192    /// For a single-plane framebuffer (`plane_count() == 1`), `plane_idx`
193    /// must be `0` and the returned span covers the whole DMA-ready buffer.
194    ///
195    /// # Panics
196    ///
197    /// May panic if `plane_idx >= plane_count()`.
198    fn plane_ptr_len(&self, plane_idx: usize) -> (*const u8, usize);
199}
200
201/// Trait for mutable framebuffers
202///
203/// This trait extends `FrameBuffer` with the ability to draw to the framebuffer
204/// using the `embedded_graphics` drawing primitives.
205pub trait MutableFrameBuffer:
206    FrameBuffer + DrawTarget<Color = Color, Error = core::convert::Infallible>
207{
208}
209
210/// Trait for all operations a user may want to call on a framebuffer.
211pub trait FrameBufferOperations: FrameBuffer {
212    /// Erase pixel colors while preserving control bits.
213    /// This is much faster than `format()` and is the typical way to clear the display.
214    fn erase(&mut self);
215
216    /// Set a pixel in the framebuffer.
217    fn set_pixel(&mut self, p: Point, color: Color);
218}
219
220#[cfg(test)]
221mod tests {
222    extern crate std;
223
224    use std::format;
225
226    use super::*;
227    use embedded_graphics::pixelcolor::RgbColor;
228
229    #[test]
230    fn test_compute_rows() {
231        // Test typical panel sizes
232        assert_eq!(compute_rows(32), 16);
233        assert_eq!(compute_rows(64), 32);
234        assert_eq!(compute_rows(16), 8);
235        assert_eq!(compute_rows(128), 64);
236
237        // Test edge cases
238        assert_eq!(compute_rows(2), 1);
239        assert_eq!(compute_rows(0), 0);
240
241        // Test that it always divides by 2
242        for rows in [8, 16, 24, 32, 48, 64, 96, 128, 256] {
243            assert_eq!(compute_rows(rows), rows / 2);
244        }
245    }
246
247    #[test]
248    fn test_compute_frame_count() {
249        // Test common bit depths
250        assert_eq!(compute_frame_count(1), 1); // 2^1 - 1 = 1
251        assert_eq!(compute_frame_count(2), 3); // 2^2 - 1 = 3
252        assert_eq!(compute_frame_count(3), 7); // 2^3 - 1 = 7
253        assert_eq!(compute_frame_count(4), 15); // 2^4 - 1 = 15
254        assert_eq!(compute_frame_count(5), 31); // 2^5 - 1 = 31
255        assert_eq!(compute_frame_count(6), 63); // 2^6 - 1 = 63
256        assert_eq!(compute_frame_count(7), 127); // 2^7 - 1 = 127
257        assert_eq!(compute_frame_count(8), 255); // 2^8 - 1 = 255
258
259        // Test the formula: (2^bits) - 1
260        for bits in 1..=8 {
261            let expected = (1usize << bits) - 1;
262            assert_eq!(compute_frame_count(bits), expected);
263        }
264    }
265
266    #[test]
267    fn test_compute_frame_count_properties() {
268        // Test that frame count grows exponentially
269        assert!(compute_frame_count(2) > compute_frame_count(1));
270        assert!(compute_frame_count(3) > compute_frame_count(2));
271        assert!(compute_frame_count(4) > compute_frame_count(3));
272
273        // Test doubling property: each additional bit approximately doubles frame count
274        for bits in 1..=7 {
275            let current_frames = compute_frame_count(bits);
276            let next_frames = compute_frame_count(bits + 1);
277            // next_frames should be approximately 2 * current_frames + 1
278            assert_eq!(next_frames, 2 * current_frames + 1);
279        }
280    }
281
282    #[test]
283    fn test_word_size_enum() {
284        // Test enum values
285        let eight = WordSize::Eight;
286        let sixteen = WordSize::Sixteen;
287
288        assert_ne!(eight, sixteen);
289        assert_eq!(eight, WordSize::Eight);
290        assert_eq!(sixteen, WordSize::Sixteen);
291    }
292
293    #[test]
294    fn test_word_size_debug() {
295        let eight = WordSize::Eight;
296        let sixteen = WordSize::Sixteen;
297
298        let eight_debug = format!("{:?}", eight);
299        let sixteen_debug = format!("{:?}", sixteen);
300
301        assert_eq!(eight_debug, "Eight");
302        assert_eq!(sixteen_debug, "Sixteen");
303    }
304
305    #[test]
306    fn test_word_size_clone_copy() {
307        let original = WordSize::Eight;
308        let cloned = original.clone();
309        let copied = original;
310
311        assert_eq!(original, cloned);
312        assert_eq!(original, copied);
313        assert_eq!(cloned, copied);
314    }
315
316    #[test]
317    fn test_color_type_alias() {
318        // Test that Color is an alias for Rgb888
319        let red_color: Color = Color::RED;
320        let red_rgb888: Rgb888 = Rgb888::RED;
321
322        assert_eq!(red_color, red_rgb888);
323        assert_eq!(red_color.r(), 255);
324        assert_eq!(red_color.g(), 0);
325        assert_eq!(red_color.b(), 0);
326
327        // Test various colors
328        let colors = [
329            (Color::RED, (255, 0, 0)),
330            (Color::GREEN, (0, 255, 0)),
331            (Color::BLUE, (0, 0, 255)),
332            (Color::WHITE, (255, 255, 255)),
333            (Color::BLACK, (0, 0, 0)),
334            (Color::CYAN, (0, 255, 255)),
335            (Color::MAGENTA, (255, 0, 255)),
336            (Color::YELLOW, (255, 255, 0)),
337        ];
338
339        for (color, (r, g, b)) in colors {
340            assert_eq!(color.r(), r);
341            assert_eq!(color.g(), g);
342            assert_eq!(color.b(), b);
343        }
344    }
345
346    #[test]
347    fn test_color_construction() {
348        // Test Color construction from RGB values
349        let custom_color = Color::new(128, 64, 192);
350        assert_eq!(custom_color.r(), 128);
351        assert_eq!(custom_color.g(), 64);
352        assert_eq!(custom_color.b(), 192);
353
354        // Test that it behaves like Rgb888
355        let rgb888_color = Rgb888::new(128, 64, 192);
356        assert_eq!(custom_color, rgb888_color);
357    }
358
359    #[test]
360    fn test_helper_functions_const() {
361        // Test that helper functions can be used in const contexts
362        const ROWS: usize = 32;
363        const COMPUTED_NROWS: usize = compute_rows(ROWS);
364        const BITS: u8 = 4;
365        const COMPUTED_FRAME_COUNT: usize = compute_frame_count(BITS);
366
367        assert_eq!(COMPUTED_NROWS, 16);
368        assert_eq!(COMPUTED_FRAME_COUNT, 15);
369    }
370
371    #[test]
372    fn test_realistic_panel_configurations() {
373        // Test common HUB75 panel configurations
374        struct PanelConfig {
375            rows: usize,
376            cols: usize,
377            bits: u8,
378        }
379
380        let configs = [
381            PanelConfig {
382                rows: 32,
383                cols: 64,
384                bits: 3,
385            }, // 32x64 panel, 3-bit color
386            PanelConfig {
387                rows: 64,
388                cols: 64,
389                bits: 4,
390            }, // 64x64 panel, 4-bit color
391            PanelConfig {
392                rows: 32,
393                cols: 32,
394                bits: 5,
395            }, // 32x32 panel, 5-bit color
396            PanelConfig {
397                rows: 16,
398                cols: 32,
399                bits: 6,
400            }, // 16x32 panel, 6-bit color
401        ];
402
403        for config in configs {
404            let nrows = compute_rows(config.rows);
405            let frame_count = compute_frame_count(config.bits);
406
407            // Basic sanity checks for rows
408            assert!(nrows > 0);
409            assert!(nrows <= config.rows);
410            assert_eq!(nrows * 2, config.rows);
411
412            // Basic sanity checks for columns
413            assert!(config.cols > 0);
414            assert!(config.cols <= 256); // Reasonable upper limit for HUB75 panels
415
416            // Frame count checks
417            assert!(frame_count > 0);
418            assert!(frame_count < 256); // Should be reasonable for typical bit depths
419
420            // Frame count should grow with bit depth
421            let prev_frame_count = compute_frame_count(config.bits - 1);
422            assert!(frame_count > prev_frame_count);
423        }
424    }
425
426    #[test]
427    fn test_memory_calculations() {
428        // Test that we can calculate memory requirements using helper functions
429        const ROWS: usize = 64;
430        const COLS: usize = 64;
431        const BITS: u8 = 4;
432
433        const NROWS: usize = compute_rows(ROWS);
434        const FRAME_COUNT: usize = compute_frame_count(BITS);
435
436        // These should be compile-time constants
437        assert_eq!(NROWS, 32);
438        assert_eq!(FRAME_COUNT, 15);
439
440        // Verify the relationship between parameters
441        assert_eq!(NROWS * 2, ROWS);
442        assert_eq!(FRAME_COUNT, (1 << BITS) - 1);
443
444        // Verify COLS is reasonable for memory calculations
445        assert!(COLS > 0);
446        assert!(COLS <= 256); // Reasonable limit for HUB75 panels
447    }
448
449    #[test]
450    fn test_edge_cases() {
451        // Test minimum values
452        assert_eq!(compute_rows(2), 1);
453        assert_eq!(compute_frame_count(1), 1);
454
455        // Test maximum reasonable values
456        assert_eq!(compute_rows(512), 256);
457        assert_eq!(compute_frame_count(8), 255);
458
459        // Test zero (though not practical)
460        assert_eq!(compute_rows(0), 0);
461    }
462
463    // Note: We can't easily test the traits directly since they're abstract,
464    // but they are thoroughly tested through their implementations in
465    // the plain and latched modules.
466
467    #[test]
468    fn test_word_size_equality() {
469        // Test all combinations of equality
470        assert_eq!(WordSize::Eight, WordSize::Eight);
471        assert_eq!(WordSize::Sixteen, WordSize::Sixteen);
472        assert_ne!(WordSize::Eight, WordSize::Sixteen);
473        assert_ne!(WordSize::Sixteen, WordSize::Eight);
474    }
475
476    #[test]
477    fn test_bit_depth_limits() {
478        // Test that our bit depth calculations work for the full range
479        for bits in 1..=8 {
480            let frame_count = compute_frame_count(bits);
481
482            // Frame count should be positive
483            assert!(frame_count > 0);
484
485            // Frame count should be less than 2^bits
486            assert!(frame_count < (1 << bits));
487
488            // Frame count should be exactly (2^bits) - 1
489            assert_eq!(frame_count, (1 << bits) - 1);
490        }
491    }
492
493    #[test]
494    fn test_documentation_examples() {
495        // Test the example values from the documentation
496        const ROWS: usize = 32;
497        const COLS: usize = 64;
498        const NROWS: usize = ROWS / 2;
499        const BITS: u8 = 8;
500        const FRAME_COUNT: usize = (1 << BITS) - 1;
501
502        // Verify using our helper functions
503        assert_eq!(compute_rows(ROWS), NROWS);
504        assert_eq!(compute_frame_count(BITS), FRAME_COUNT);
505
506        // Verify the values match documentation
507        assert_eq!(ROWS, 32);
508        assert_eq!(COLS, 64);
509        assert_eq!(NROWS, 16);
510        assert_eq!(FRAME_COUNT, 255);
511
512        // Verify this matches typical panel dimensions
513        assert!(COLS > 0);
514        assert_eq!(NROWS * 2, ROWS);
515    }
516}