Skip to main content

embassy_stm32_hub75/
lib.rs

1//! # embassy-stm32-hub75
2//!
3//! A `no-std` Rust driver for HUB75-style LED matrix panels on STM32
4//! microcontrollers. HUB75 is a standard interface for driving large, bright,
5//! and colorful RGB LED displays, commonly used in digital signage and art
6//! installations.
7//!
8//! This library uses an ISR-driven DMA refresh loop to continuously output
9//! framebuffer data to a GPIO port. A timer generates the pixel clock via PWM
10//! and triggers DMA transfers on each update event. Once the driver is
11//! initialised via `hub75_define!`'s `init()` function, rendering happens
12//! entirely in the background via DMA transfer-complete interrupts — no CPU
13//! involvement per pixel.
14//!
15//! ## Double Buffering
16//!
17//! The driver supports double-buffered operation via `Hub75::swap()`. The
18//! application writes to one framebuffer while the ISR renders from another,
19//! swapping atomically at frame boundaries.
20//!
21//! ## 8-bit Mode
22//!
23//! In 8-bit mode, an external 74HC574-style latch handles the row address
24//! lines. This requires only 8 data pins: R1, G1, B1, R2, G2, B2, LATCH,
25//! and BLANK. The 8 pins must occupy either the lower byte (pins 0-7) or
26//! upper byte (pins 8-15) of a single GPIO port. Byte-width DMA writes to
27//! the corresponding ODR byte update only those 8 pins without disturbing
28//! the other half of the port.
29//!
30//! ## 16-bit Mode
31//!
32//! In 16-bit mode, all 16 pins of a GPIO port are used. Half-word-width DMA
33//! writes the full ODR register on each clock cycle. Pin layout and bit
34//! assignments depend on the framebuffer implementation used.
35//!
36//! ## Framebuffers
37//!
38//! The `hub75-framebuffer` crate provides bitplane framebuffers that are
39//! strongly recommended for their memory efficiency. The plain 16-bit variant
40//! requires no external hardware beyond the panel itself. The latched 8-bit
41//! variant saves GPIO pins and cuts framebuffer memory nearly in half (one
42//! byte per pixel-clock instead of two), but requires an external
43//! 74HC574-style latch circuit (see the `hub75-framebuffer` README for the
44//! schematic). Both store one bit per pixel per plane and are output via DMA
45//! without format conversion.
46//!
47//! ## Defining an Instance
48//!
49//! Use the `hub75_define!` macro to create a driver module bound to specific
50//! timer and DMA channel peripherals:
51//!
52//! ```ignore
53//! use embassy_stm32::{bind_interrupts, dma, peripherals};
54//! use embassy_stm32_hub75::hub75_define;
55//!
56//! hub75_define!(hub75, embassy_stm32::peripherals::TIM2, embassy_stm32::peripherals::DMA1_CH1);
57//!
58//! bind_interrupts!(struct Irqs {
59//!     DMA1_CHANNEL1 =>
60//!         dma::InterruptHandler<peripherals::DMA1_CH1>,
61//!         hub75::Hub75DmaHandler;
62//! });
63//!
64//! let hub75 = hub75::init(
65//!     p.TIM2, p.PA0, p.DMA1_CH1, Irqs, pins,
66//!     Config::new().frequency(Hertz(6_000_000)),
67//!     fb,
68//! );
69//! ```
70//!
71//! ## Crate Features
72//!
73//! - `defmt` -- enable `defmt` logging (forwards to embassy-stm32, hub75-framebuffer,
74//!   and embedded-graphics)
75//! - `skip-black-pixels` -- skip writing black pixels to the framebuffer,
76//!   leaving the bitplane data unchanged (forwards to hub75-framebuffer)
77//! - `invert-oe` -- invert the output-enable signal in the framebuffer
78//!   (forwards to hub75-framebuffer)
79//! - `tail-closes-latch` -- append a tail word that closes the latch after data
80//!   is shifted in; plain 16-bit mode only (forwards to hub75-framebuffer)
81//! - `blank-delay-{1,2,4,8}` -- insert 1/2/4/8 blank delay cycles after
82//!   latching; plain 16-bit mode only (forwards to hub75-framebuffer)
83
84#![no_std]
85#![warn(missing_docs)]
86#![warn(clippy::all)]
87#![warn(clippy::pedantic)]
88
89use core::ptr::NonNull;
90
91use embassy_stm32::dma::word::WordSize;
92use embassy_stm32::Peri;
93pub use hub75_framebuffer as framebuffer;
94
95/// The color type used by the HUB75 driver.
96pub use hub75_framebuffer::Color;
97
98#[doc(hidden)]
99pub mod bcm;
100pub mod dma;
101
102/// Re-exports used by the [`hub75_define!`] macro. Not part of the public API.
103#[doc(hidden)]
104pub mod __macro_support {
105    pub use critical_section;
106    pub use embassy_stm32;
107}
108
109use core::mem::ManuallyDrop;
110
111use embassy_stm32::gpio::{AnyPin, Flex, Level, Pin};
112
113// re-export items from embassy-stm32 that are used in the public API
114pub use embassy_stm32::gpio::Speed;
115pub use embassy_stm32::time::Hertz;
116
117/// Driver configuration for pixel clock frequency (defaults to 10 MHz) and GPIO output speed (defaults to Medium).
118#[non_exhaustive]
119#[derive(Copy, Clone)]
120pub struct Config {
121    /// Pixel clock frequency for the HUB75 panel.
122    pub frequency: Hertz,
123    /// GPIO output speed for the data and control pins.
124    pub gpio_speed: Speed,
125}
126
127impl Config {
128    /// Sensible starting defaults: 10 MHz pixel clock, medium GPIO speed.
129    #[must_use]
130    pub const fn new() -> Self {
131        Self {
132            frequency: Hertz(10_000_000),
133            gpio_speed: Speed::Medium,
134        }
135    }
136
137    /// Set the pixel clock frequency.
138    #[must_use]
139    pub const fn frequency(mut self, frequency: Hertz) -> Self {
140        self.frequency = frequency;
141        self
142    }
143
144    /// Set the GPIO output speed for the data and control pins.
145    #[must_use]
146    pub const fn gpio_speed(mut self, gpio_speed: Speed) -> Self {
147        self.gpio_speed = gpio_speed;
148        self
149    }
150}
151
152impl Default for Config {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158mod sealed {
159    pub trait Sealed {}
160}
161
162/// Trait implemented by HUB75 pin groups (8-bit or 16-bit).
163///
164/// This trait is sealed and cannot be implemented outside of this crate.
165pub trait Hub75Pins: sealed::Sealed {
166    /// The word type for the GPIO port (`u8` for 8-bit ports, `u16` for 16-bit ports).
167    type Word;
168    /// DMA word size matching [`Self::Word`].
169    const DMA_WORD_SIZE: WordSize;
170    /// Configure the pins and get the ODR pointer.
171    fn configure_and_get_odr(self, speed: Speed) -> NonNull<u8>;
172}
173
174impl sealed::Sealed for Hub75Pins8 {}
175impl sealed::Sealed for Hub75Pins16 {}
176
177/// Pin configuration for a HUB75 panel with an external address latch.
178///
179/// The 8 data pins must all be on the same GPIO port, occupying either the
180/// lower byte (pins 0-7) or upper byte (pins 8-15). The pins must be wired
181/// in order so that R1 maps to bit 0 of the byte, G1 to bit 1, and so on.
182///
183/// The data pins map directly to the `hub75-framebuffer` latched byte layout:
184/// - bit 0: R1
185/// - bit 1: G1
186/// - bit 2: B1
187/// - bit 3: R2
188/// - bit 4: G2
189/// - bit 5: B2
190/// - bit 6: LATCH
191/// - bit 7: BLANK
192///
193/// For upper-byte wiring, pin N+8 corresponds to bit N.
194/// For lower-byte wiring, pin N corresponds to bit N.
195///
196/// The clock pin is passed separately to the `init()` constructor as a raw
197/// GPIO pin. It must be a valid timer channel 1 output for the chosen timer
198/// (enforced at compile time). The driver configures it as a PWM output
199/// internally.
200///
201/// Use [`Hub75Pins8::new()`] to construct; it validates pin layout at
202/// creation time.
203pub struct Hub75Pins8 {
204    /// The 8 GPIO pins in order.
205    pub pins: [AnyPin; 8],
206    /// First pin number in the group (0 or 8).
207    pub base_pin: u8,
208}
209
210impl Hub75Pins8 {
211    /// Create a validated pin configuration.
212    ///
213    /// All 8 pins must be on the same GPIO port and must occupy 8
214    /// consecutive pins in either the lower byte (pins 0-7) or upper byte
215    /// (pins 8-15), with R1 on the lowest pin of the group.
216    ///
217    /// # Errors
218    /// Returns [`Hub75Error::PinNotOnSamePort`] if any pin is on a
219    /// different GPIO port than the first, or
220    /// [`Hub75Error::PinsNotConsecutive`] if the pins are not 8
221    /// consecutive pins starting at pin 0 or pin 8.
222    #[allow(clippy::too_many_arguments)]
223    pub fn new(
224        red1: AnyPin,
225        grn1: AnyPin,
226        blu1: AnyPin,
227        red2: AnyPin,
228        grn2: AnyPin,
229        blu2: AnyPin,
230        latch: AnyPin,
231        blank: AnyPin,
232    ) -> Result<Self, Hub75Error> {
233        let pins_ref: [&AnyPin; 8] = [&red1, &grn1, &blu1, &red2, &grn2, &blu2, &latch, &blank];
234
235        let port = pins_ref[0].port();
236        let first = pins_ref[0].pin();
237
238        if first != 0 && first != 8 {
239            return Err(Hub75Error::PinsNotConsecutive {
240                index: 0,
241                expected: 0,
242                actual: first,
243            });
244        }
245
246        for (i, pin) in pins_ref.iter().enumerate() {
247            #[allow(clippy::cast_possible_truncation)]
248            let i = i as u8;
249            if pin.port() != port {
250                return Err(Hub75Error::PinNotOnSamePort { index: i });
251            }
252            let expected = first + i;
253            if pin.pin() != expected {
254                return Err(Hub75Error::PinsNotConsecutive {
255                    index: i,
256                    expected,
257                    actual: pin.pin(),
258                });
259            }
260        }
261
262        Ok(Self {
263            pins: [red1, grn1, blu1, red2, grn2, blu2, latch, blank],
264            base_pin: first,
265        })
266    }
267}
268
269impl Hub75Pins for Hub75Pins8 {
270    type Word = u8;
271    const DMA_WORD_SIZE: WordSize = WordSize::OneByte;
272    fn configure_and_get_odr(self, speed: Speed) -> NonNull<u8> {
273        let gpio = self.pins[0].block();
274        // STM32 ODR is a 32-bit little-endian register: byte offset 0
275        // addresses the lower byte (pins 0-7), offset 1 the upper byte
276        // (pins 8-15).
277        let byte_offset = usize::from(self.base_pin != 0);
278        let odr_byte_addr = unsafe { (gpio.odr().as_ptr().cast::<u8>()).add(byte_offset) };
279
280        for (i, pin) in self.pins.into_iter().enumerate() {
281            // SAFETY: we own the AnyPin and will leak the Flex to keep it alive.
282            let peri = unsafe { Peri::new_unchecked(pin) };
283            let mut flex = ManuallyDrop::new(Flex::new(peri));
284            if i == 7 {
285                flex.set_level(Level::High);
286            }
287            flex.set_as_output(speed);
288        }
289        // SAFETY: ODR is a memory-mapped hardware register, always non-null.
290        unsafe { NonNull::new_unchecked(odr_byte_addr) }
291    }
292}
293
294/// Pin configuration for a HUB75 panel using 16 data pins (full GPIO port width).
295///
296/// All 16 pins must be on the same GPIO port, occupying pins 0-15 in order.
297/// The DMA writes a full `u16` to the ODR register on each clock cycle.
298///
299/// The data pins map directly to the `hub75-framebuffer` 16-bit layout.
300/// Specific bit assignments depend on the framebuffer implementation used.
301///
302/// The clock pin is passed separately to the `init()` constructor as a raw
303/// GPIO pin. It must be a valid timer channel 1 output for the chosen timer
304/// (enforced at compile time). The driver configures it as a PWM output
305/// internally.
306///
307/// Use [`Hub75Pins16::new()`] to construct; it validates pin layout at
308/// creation time.
309pub struct Hub75Pins16 {
310    /// The 16 GPIO pins in order (pin 0 through pin 15).
311    pub pins: [AnyPin; 16],
312}
313
314impl Hub75Pins16 {
315    /// Create a validated 16-pin configuration.
316    ///
317    /// All 16 pins must be on the same GPIO port and must occupy pins 0-15
318    /// consecutively.
319    ///
320    /// # Errors
321    /// Returns [`Hub75Error::PinNotOnSamePort`] if any pin is on a
322    /// different GPIO port than the first, or
323    /// [`Hub75Error::PinsNotConsecutive`] if the pins are not
324    /// consecutive starting at pin 0.
325    #[allow(clippy::too_many_arguments)]
326    pub fn new(pins: [AnyPin; 16]) -> Result<Self, Hub75Error> {
327        let port = pins[0].port();
328        let first = pins[0].pin();
329
330        if first != 0 {
331            return Err(Hub75Error::PinsNotConsecutive {
332                index: 0,
333                expected: 0,
334                actual: first,
335            });
336        }
337
338        for (i, pin) in pins.iter().enumerate() {
339            #[allow(clippy::cast_possible_truncation)]
340            let i = i as u8;
341            if pin.port() != port {
342                return Err(Hub75Error::PinNotOnSamePort { index: i });
343            }
344            let expected = i;
345            if pin.pin() != expected {
346                return Err(Hub75Error::PinsNotConsecutive {
347                    index: i,
348                    expected,
349                    actual: pin.pin(),
350                });
351            }
352        }
353
354        Ok(Self { pins })
355    }
356}
357
358impl Hub75Pins for Hub75Pins16 {
359    type Word = u16;
360    const DMA_WORD_SIZE: WordSize = WordSize::TwoBytes;
361    fn configure_and_get_odr(self, speed: Speed) -> NonNull<u8> {
362        let gpio = self.pins[0].block();
363        let odr_addr = gpio.odr().as_ptr().cast::<u8>();
364
365        for pin in self.pins {
366            let peri = unsafe { Peri::new_unchecked(pin) };
367            let mut flex = ManuallyDrop::new(Flex::new(peri));
368            flex.set_as_output(speed);
369        }
370        // SAFETY: ODR is a memory-mapped hardware register, always non-null.
371        unsafe { NonNull::new_unchecked(odr_addr) }
372    }
373}
374
375/// Represents errors that can occur during HUB75 driver operations.
376#[derive(Debug)]
377#[cfg_attr(feature = "defmt", derive(defmt::Format))]
378pub enum Hub75Error {
379    /// A pin is on a different GPIO port than the others.
380    /// `index` is the position in the pin list.
381    PinNotOnSamePort {
382        /// Index of the offending pin.
383        index: u8,
384    },
385    /// The pins are not consecutive starting at the expected position.
386    PinsNotConsecutive {
387        /// Index of the offending pin.
388        index: u8,
389        /// Expected pin number.
390        expected: u8,
391        /// Actual pin number.
392        actual: u8,
393    },
394    /// Error occurred during DMA operations.
395    Dma,
396    /// Error occurred during timer configuration.
397    Timer,
398    /// The driver has not been initialised yet.
399    NotInitialised,
400}
401
402impl core::fmt::Display for Hub75Error {
403    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
404        match self {
405            Self::PinNotOnSamePort { index } => {
406                write!(f, "pin at index {index} is on a different GPIO port")
407            }
408            Self::PinsNotConsecutive {
409                index,
410                expected,
411                actual,
412            } => {
413                write!(
414                    f,
415                    "pin at index {index} is not consecutive: expected pin {expected}, got {actual}"
416                )
417            }
418            Self::Dma => f.write_str("DMA error"),
419            Self::Timer => f.write_str("timer configuration error"),
420            Self::NotInitialised => f.write_str("driver not initialised"),
421        }
422    }
423}
424
425impl core::error::Error for Hub75Error {}