From e26b1f571db8686ade1f0c91040c4af752098b58 Mon Sep 17 00:00:00 2001 From: ede1998 Date: Tue, 25 Aug 2026 01:05:23 +0200 Subject: [PATCH] improved char display API --- src/bin/main.rs | 12 +- src/drivers/i2c_bus.rs | 16 +- src/drivers/tertiary_lcd.rs | 444 +++++++++++++++++++++++++++++------- 3 files changed, 382 insertions(+), 90 deletions(-) diff --git a/src/bin/main.rs b/src/bin/main.rs index 6a54a85..8223eb4 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -12,7 +12,9 @@ use creaturedex::drivers::i2c_bus::I2cBusPinConfiguration; use creaturedex::drivers::nfc_pn532::NfcPn532Driver; use creaturedex::drivers::secondary_oled::SecondaryDisplay; use creaturedex::drivers::spi_bus; -use creaturedex::drivers::tertiary_lcd::{TertiaryDisplay, init_tertiary_lcd, write_wrapped}; +use creaturedex::drivers::tertiary_lcd::{ + BOX, EMPTY, EXAMPLE, HEART, TertiaryDisplay, TertiaryDisplayPinConfiguration, +}; use creaturedex::navigation::inputs::Inputs; use creaturedex::navigation::navigation::{self}; use creaturedex::navigation::outputs::Outputs; @@ -87,8 +89,12 @@ async fn main(spawner: Spawner) { config.build() }; - let mut lcd: TertiaryDisplay = init_tertiary_lcd(AtomicDevice::new(shared_i2c)); - if let Err(e) = write_wrapped(&mut lcd, "Testing more than 16 chars what happens now?") { + let mut lcd: TertiaryDisplay = TertiaryDisplayPinConfiguration { i2c: shared_i2c }.build(); + lcd.load_charset(EXAMPLE).unwrap(); + let h = EXAMPLE[HEART]; + let e = EXAMPLE[EMPTY]; + let b = EXAMPLE[BOX]; + if let Err(e) = lcd.write(&alloc::format!("Custom symbols: {h}{e}{b}")) { log::error!( "Tertiary LCD startup text write failed over shared I2C bus; check the shared bus, wiring, and device responses: {e}" ); diff --git a/src/drivers/i2c_bus.rs b/src/drivers/i2c_bus.rs index fecf1d2..23500ed 100644 --- a/src/drivers/i2c_bus.rs +++ b/src/drivers/i2c_bus.rs @@ -1,11 +1,13 @@ use alloc::boxed::Box; +use embedded_hal_bus::i2c::AtomicDevice; use embedded_hal_bus::util::AtomicCell; use esp_hal::gpio::interconnect::PeripheralInput; use esp_hal::i2c::master::Config; use esp_hal::{gpio::interconnect::PeripheralOutput, i2c::master::I2c}; -type InnerI2cBus = esp_hal::i2c::master::I2c<'static, esp_hal::Blocking>; -pub type I2cBus = &'static AtomicCell; +type InnerI2cBus<'a> = esp_hal::i2c::master::I2c<'a, esp_hal::Blocking>; +pub type I2cBus<'a> = &'a AtomicCell>; +pub type I2cDevice<'a> = AtomicDevice<'a, esp_hal::i2c::master::I2c<'a, esp_hal::Blocking>>; pub struct I2cBusPinConfiguration { pub i2c_peripheral: I2C, @@ -13,13 +15,13 @@ pub struct I2cBusPinConfiguration { pub sda: SDA, } -impl I2cBusPinConfiguration +impl<'a, I2C, SCL, SDA> I2cBusPinConfiguration where - I2C: esp_hal::i2c::master::Instance + 'static, - SCL: PeripheralOutput<'static> + PeripheralInput<'static>, - SDA: PeripheralOutput<'static> + PeripheralInput<'static>, + I2C: esp_hal::i2c::master::Instance + 'a, + SCL: PeripheralOutput<'a> + PeripheralInput<'a>, + SDA: PeripheralOutput<'a> + PeripheralInput<'a>, { - pub fn build(self) -> I2cBus { + pub fn build(self) -> I2cBus<'a> { let config = Config::default().with_frequency(esp_hal::time::Rate::from_khz(400)); let i2c = match I2c::new(self.i2c_peripheral, config) { Ok(bus) => bus.with_sda(self.sda).with_scl(self.scl), diff --git a/src/drivers/tertiary_lcd.rs b/src/drivers/tertiary_lcd.rs index 5fc11dc..8e420ce 100644 --- a/src/drivers/tertiary_lcd.rs +++ b/src/drivers/tertiary_lcd.rs @@ -1,103 +1,387 @@ -use embedded_hal::i2c::I2c; use embedded_hal_bus::i2c::AtomicDevice; use esp_hal::delay::Delay; use i2c_character_display::{CharacterDisplayPCF8574T, LcdDisplayType}; use log::error; +use crate::create_custom_char_set; +use crate::drivers::i2c_bus::{I2cBus, I2cDevice}; + type GenericTertiaryDisplay = CharacterDisplayPCF8574T; -pub type TertiaryDisplay<'a> = - GenericTertiaryDisplay>>; +type TertiaryDisplayRaw<'a> = GenericTertiaryDisplay>; +pub type TertiaryDisplayError<'a> = i2c_character_display::CharacterDisplayError>; -pub fn init_tertiary_lcd(i2c: I2C) -> GenericTertiaryDisplay -where - I2C: I2c, -{ - let mut lcd = CharacterDisplayPCF8574T::new(i2c, LcdDisplayType::Lcd16x2, Delay::new()); - match lcd.init() { - Ok(()) => { - log::info!("I2C tertiary LCD initialized on PCF8574T at address 0x27"); - lcd - } - Err(_) => { - error!( - "I2C tertiary LCD init failed on PCF8574T at address 0x27; check bus wiring, power, and device address" - ); - panic!("tertiary LCD initialization failed"); +pub struct TertiaryDisplayPinConfiguration<'a> { + pub i2c: I2cBus<'a>, +} + +impl<'a> TertiaryDisplayPinConfiguration<'a> { + pub fn build(self) -> TertiaryDisplay<'a> { + let device = AtomicDevice::new(self.i2c); + let mut lcd = CharacterDisplayPCF8574T::new(device, LcdDisplayType::Lcd16x2, Delay::new()); + match lcd.init() { + Ok(()) => { + log::info!("I2C tertiary LCD initialized on PCF8574T at address 0x27"); + TertiaryDisplay { + display: lcd, + charset: create_custom_char_set!(), + } + } + Err(e) => { + panic!("I2C tertiary LCD init failed on PCF8574T at address 0x27: {e}"); + } } } } -pub fn write_line( - lcd: &mut GenericTertiaryDisplay, - line: u8, - text: &str, -) -> Result<(), i2c_character_display::CharacterDisplayError> -where - I2C: I2c, -{ - if let Err(e) = lcd.set_cursor(0, line) { - error!("I2C LCD cursor move failed on line {line}; bus or device may be unavailable"); - return Err(e); - } - if let Err(e) = lcd.print(text) { - error!( - "I2C LCD print failed on line {line}; payload length={} and device may be busy or disconnected", - text.len() - ); - return Err(e); - } - Ok(()) +#[derive(Debug, Clone)] +pub enum TertiaryDisplayWriteError<'a> { + Display(TertiaryDisplayError<'a>), + ColumnOverflow { + line: u8, + columns: usize, + }, + LineOverflow, + InvalidChar { + /// Offset in bytes + position: usize, + symbol: char, + }, } -pub fn write_wrapped( - lcd: &mut GenericTertiaryDisplay, - text: &str, -) -> Result<(), i2c_character_display::CharacterDisplayError> -where - I2C: I2c, -{ - const DISPLAY_WIDTH: usize = 16; - const DISPLAY_ROWS: usize = 2; +impl<'a> From> for TertiaryDisplayWriteError<'a> { + fn from(value: TertiaryDisplayError<'a>) -> Self { + Self::Display(value) + } +} - if let Err(e) = lcd.clear() { - error!( - "I2C LCD clear failed before wrapped write; check the shared bus and display response" - ); - return Err(e); +impl<'a> core::fmt::Display for TertiaryDisplayWriteError<'a> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + TertiaryDisplayWriteError::Display(e) => write!(f, "display error: {e}"), + TertiaryDisplayWriteError::ColumnOverflow { line, columns } => { + write!( + f, + "text too long for line {line}: {columns} > {}", + TertiaryDisplay::DISPLAY_WIDTH + ) + } + TertiaryDisplayWriteError::LineOverflow => write!(f, "text has too many lines"), + TertiaryDisplayWriteError::InvalidChar { position, symbol } => { + write!(f, "Invalid character {symbol} at offset {position}") + } + } + } +} + +pub struct TertiaryDisplay<'a> { + display: TertiaryDisplayRaw<'a>, + charset: CustomCharSet, +} + +impl<'a> TertiaryDisplay<'a> { + pub const DISPLAY_WIDTH: usize = 16; + pub const DISPLAY_ROWS: usize = 2; + + pub fn inner_mut(&mut self) -> &mut TertiaryDisplayRaw<'a> { + &mut self.display } - let mut offset = 0; - for row in 0..DISPLAY_ROWS { - let remaining = &text[offset..]; - if remaining.is_empty() { - return Ok(()); + pub fn load_charset(&mut self, charset: CustomCharSet) -> Result<(), TertiaryDisplayError<'a>> { + self.charset = charset; + for (index, charmap) in self + .charset + .set + .into_iter() + .enumerate() + .take(self.charset.len) + { + self.display + .create_char(index as u8, charmap) + .inspect_err(|e| error!("I2C Custom character {index} init failed: {e}"))?; + } + Ok(()) + } + + pub fn clear(&mut self) -> Result<(), TertiaryDisplayError<'a>> { + if let Err(e) = self.display.clear() { + error!("I2C LCD clear failed; bus may be busy or device unresponsive"); + return Err(e); + } + Ok(()) + } + + /// Clamp the text's length so that it fits within the character display without overflow. + /// Note: clamp does not work properly yet if line 1 is too long with multiple newlines in string. + pub fn clamp(text: &str) -> &str { + enum NewLines { + None, + One { + offset: usize, + }, + More { + /// Offset for the first 2 newlines. We discard everything from 2nd newline onwards anyway. + offsets: [usize; 2], + }, + } + let pos_of_newlines = |(pos, c)| (c == '\n').then_some(pos); + let mut newlines = NewLines::None; + for pos in text.char_indices().filter_map(pos_of_newlines) { + match newlines { + NewLines::None => { + newlines = NewLines::One { offset: pos }; + } + NewLines::One { offset } => { + newlines = NewLines::More { + offsets: [offset, pos], + }; + break; + } + NewLines::More { offsets } => { + // unreachable because of break in previous arm + newlines = NewLines::More { offsets }; + break; + } + } } - let chunk = if remaining.len() > DISPLAY_WIDTH { - &remaining[..DISPLAY_WIDTH] + let len_no_newline = text.len() + - match newlines { + NewLines::None => 0, + NewLines::One { .. } => 1, + NewLines::More { .. } => 2, + }; + let max = len_no_newline.min(Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH); + + match newlines { + NewLines::None => &text[..max], + NewLines::More { + offsets: [offset, ..], + } + | NewLines::One { offset } + if offset > Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH => + { + // first line already fully fills the screen, + // so new line does not fit on screen anyway + &text[..max] + } + NewLines::One { offset } => { + let line2 = text.split_at(offset).1; + let line2_len = line2.len().min(Self::DISPLAY_WIDTH); + // new line isn't rendered so string may be one longer + &text[..max.min(offset + line2_len) + 1] + } + NewLines::More { + offsets: [eol1, eol2], + } => { + let line2 = text[..eol2].split_at(eol1).1; + let line2_len = line2.len().min(Self::DISPLAY_WIDTH); + // new line isn't rendered so string may be one longer + &text[..max.min(eol1 + line2_len) + 1] + } + } + } + + /// Clamp the text's length so that it fits within a single line of the character display without overflow. + pub fn clamp_line(text: &str) -> &str { + let newline = text.chars().position(|c| c == '\n'); + let end_of_line = newline.unwrap_or(text.len()); + let stop_at = end_of_line.min(Self::DISPLAY_WIDTH); + &text[..stop_at] + } + + pub fn write_line(&mut self, row: u8, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { + self.invalid_chars(text)?; + let line = Self::limit_line(text)?; + + self.display + .set_cursor(0, row) + .inspect_err(|e| error!("I2C LCD cursor move failed on row {row}: {e}"))?; + + self.display + .print(line) + .inspect_err(|e| error!("I2C LCD print failed on row {row}: {e}"))?; + Ok(()) + } + + fn invalid_chars(&self, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { + let is_custom = |c: char| u32::from(c) < self.charset.len as u32; + let is_newline = |c: char| c == '\n'; + let invalid = text + .char_indices() + .find(|&(_, c)| !c.is_ascii() || (c.is_control() && !(is_newline(c) || is_custom(c)))); + match invalid { + Some((position, symbol)) => { + Err(TertiaryDisplayWriteError::InvalidChar { position, symbol }) + } + None => Ok(()), + } + } + + fn limit_line<'b>(text: &'b str) -> Result<&'b str, TertiaryDisplayWriteError<'a>> { + if text.contains('\n') { + return Err(TertiaryDisplayWriteError::LineOverflow); + } + + if text.len() > Self::DISPLAY_WIDTH { + Err(TertiaryDisplayWriteError::ColumnOverflow { + line: 1, + columns: text.len(), + }) } else { - remaining + Ok(text) + } + } + + fn split_lines<'b>(text: &'b str) -> Result<[&'b str; 2], TertiaryDisplayWriteError<'a>> { + let newlines = text.chars().filter(|&c| c == '\n').count(); + match newlines { + 0 => { + if text.len() > Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH { + Err(TertiaryDisplayWriteError::ColumnOverflow { + line: 2, + columns: text.len() - Self::DISPLAY_WIDTH, + }) + } else { + Ok(text + .split_at_checked(Self::DISPLAY_WIDTH) + .unwrap_or((text, "")) + .into()) + } + } + 1 => { + let lines: [_; 2] = text.split_once('\n').unwrap_or((text, "")).into(); + + for (row, line) in lines.into_iter().enumerate() { + if line.len() > Self::DISPLAY_WIDTH { + return Err(TertiaryDisplayWriteError::ColumnOverflow { + line: row as u8 + 1, + columns: line.len(), + }); + } + } + + Ok(lines) + } + _ => Err(TertiaryDisplayWriteError::LineOverflow), + } + } + + /// Writes text to the LCD, wrapping to the next line if it exceeds the display width. + /// Returns an error if the text would overflow. + pub fn write(&mut self, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { + self.invalid_chars(text)?; + let lines = Self::split_lines(text)?; + + self.display + .clear() + .inspect_err(|e| error!("I2C LCD clear failed before wrapped write: {e}"))?; + + for (row, line) in lines.into_iter().enumerate() { + self.display.set_cursor(0, row as u8).inspect_err(|e| { + error!("I2C LCD cursor move failed while writing wrapped row {row}: {e}") + })?; + + self.display + .print(line) + .inspect_err(|e| error!("I2C LCD wrapped write failed on row {row}: {e}"))?; + } + + Ok(()) + } +} + +/// 5x8 pixel charmap. Each set bit is a black pixel. +pub type CharMap = [u8; 8]; +pub struct CustomCharSet { + set: [CharMap; CUSTOM_CHAR_COUNT as usize], + len: usize, +} + +impl core::ops::Index for CustomCharSet { + type Output = &'static str; + + fn index(&self, map: CharMap) -> &Self::Output { + let encoded = self.set[..self.len] + .iter() + .position(|&m| m == map) + .expect("Unknown char map in this set."); + + match encoded { + 0 => &"\x00", + 1 => &"\x01", + 2 => &"\x02", + 3 => &"\x03", + 4 => &"\x04", + 5 => &"\x05", + 6 => &"\x06", + 7 => &"\x07", + 8 => &"\x08", + 9 => &"\x09", + 10 => &"\x0A", + 11 => &"\x0B", + 12 => &"\x0C", + 13 => &"\x0D", + 14 => &"\x0E", + 15 => &"\x0F", + pos => unreachable!( + "More than {CUSTOM_CHAR_COUNT} (=max) elements in array? Found element at {pos}." + ), + } + } +} + +#[macro_export] +macro_rules! create_custom_char_set { + () => {{ + CustomCharSet { set: [EMPTY; CUSTOM_CHAR_COUNT as usize], len: 0 } + }}; + ($($char:expr),+ $(,)?) => {{ + // Count how many characters were passed using macro repetition length + const LEN: usize = [$(stringify!($char)),+].len(); + + // Ensure we don't exceed the defined maximum capacity + const _: () = assert!( + LEN <= CUSTOM_CHAR_COUNT as usize, + "Too many characters provided for CUSTOM_CHAR_COUNT" + ); + + // Build a fixed-size array populated with the provided characters, + // and pad the rest with empty CharMaps (zeros) to fit CUSTOM_CHAR_COUNT. + const SET: [CharMap; CUSTOM_CHAR_COUNT as usize] = { + let mut buffer = [[0u8; 8]; CUSTOM_CHAR_COUNT as usize]; + let input = [$($char),*]; + let mut i = 0; + while i < input.len() { + buffer[i] = input[i]; + i += 1; + } + buffer }; - if let Err(e) = lcd.set_cursor(0, row as u8) { - error!( - "I2C LCD cursor move failed while writing wrapped row {row}; bus may be busy or device unresponsive" - ); - return Err(e); + CustomCharSet { + set: SET, + len: LEN, } - if let Err(e) = lcd.print(chunk) { - error!( - "I2C LCD wrapped write failed on row {row}; chunk length={} and text may exceed display width", - chunk.len() - ); - return Err(e); - } - offset += chunk.len(); - - if offset >= text.len() { - return Ok(()); - } - } - - Ok(()) + }}; } + +pub const CUSTOM_CHAR_COUNT: u8 = 16; + +pub const HEART: CharMap = [ + 0b00000, // Top row + 0b01010, // * * + 0b11111, // ***** + 0b11111, // ***** + 0b01110, // *** + 0b00100, // * + 0b00000, // Bottom row + 0b00000, // Cursor line (usually left blank) +]; + +pub const EMPTY: CharMap = [0; 8]; +pub const BOX: CharMap = [ + 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, 0b11111, + 0b00000, // Cursor line (usually left blank) +]; + +pub const EXAMPLE: CustomCharSet = create_custom_char_set!(HEART, BOX, EMPTY);