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 {}