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; type TertiaryDisplayRaw<'a> = GenericTertiaryDisplay>; pub type TertiaryDisplayError<'a> = i2c_character_display::CharacterDisplayError>; 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}"); } } } } #[derive(Debug, Clone)] pub enum TertiaryDisplayWriteError<'a> { Display(TertiaryDisplayError<'a>), Format(LinesError), } impl<'a> From> for TertiaryDisplayWriteError<'a> { fn from(value: TertiaryDisplayError<'a>) -> Self { Self::Display(value) } } impl From for TertiaryDisplayWriteError<'_> { fn from(value: LinesError) -> Self { Self::Format(value) } } impl<'a> core::fmt::Display for TertiaryDisplayWriteError<'a> { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Display(e) => write!(f, "display error: {e}"), Self::Format(e) => write!(f, "format error: {e}"), } } } pub struct TertiaryDisplay<'a> { display: TertiaryDisplayRaw<'a>, charset: CustomCharset, } impl<'a> TertiaryDisplay<'a> { pub const WIDTH: usize = 16; pub const ROWS: usize = 2; pub fn inner_mut(&mut self) -> &mut TertiaryDisplayRaw<'a> { &mut self.display } 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(()) } /// Writes text to the LCD. Normal strings wrap by default but this can be controlled /// via explicit [`Lines::checked`]. pub fn write<'b, L: Into>>( &mut self, text: L, ) -> Result<(), TertiaryDisplayWriteError<'a>> { let lines = text.into().into_result()?; 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(()) } } #[derive(Debug, PartialEq, Eq, Clone)] pub enum LinesError { ColumnOverflow { line: u8, columns: usize }, LineOverflow, InvalidChar { position: usize, symbol: char }, } impl core::fmt::Display for LinesError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::ColumnOverflow { line, columns } => { write!( f, "text too long for line {line}: {columns} > {}", TertiaryDisplay::WIDTH ) } Self::LineOverflow => write!(f, "text has too many lines"), Self::InvalidChar { position, symbol } => { write!(f, "Invalid character {symbol} at offset {position}") } } } } #[derive(Debug, PartialEq, Eq, Clone)] pub struct Lines<'a> { pub lines: Result<[&'a str; TertiaryDisplay::ROWS], LinesError>, } impl<'a> Lines<'a> { pub fn into_result(self) -> Result<[&'a str; TertiaryDisplay::ROWS], LinesError> { self.lines } /// Validates that the text contains only non-control ASCII characters, /// newlines, and contiguous custom characters up to `charset.len`. pub fn invalid_chars(text: &str, charset: &CustomCharset) -> Result<(), LinesError> { for (pos, c) in text.char_indices() { let is_standard_valid = c == '\n' || (' '..='~').contains(&c); if !is_standard_valid { // If it's not standard ASCII, check if it falls within the allowed custom range. // We cast `c` to usize to compare against length. if (c as usize) >= charset.len { return Err(LinesError::InvalidChar { position: pos, symbol: c, }); } } } Ok(()) } /// Checks pre-split lines strictly for length and valid characters. pub fn by_line(lines: [&'a str; TertiaryDisplay::ROWS]) -> Self { for line in lines.iter() { if let Err(e) = Self::invalid_chars(line, &create_custom_char_set!()) { return Self { lines: Err(e) }; } } for (i, line) in lines.iter().enumerate() { if line.len() > TertiaryDisplay::WIDTH { return Self { lines: Err(LinesError::ColumnOverflow { line: i as u8, columns: line.len(), }), }; } } Self { lines: Ok(lines) } } /// Strictly checks an input string. /// Fails if there are invalid chars, too many newlines, or if any segment exceeds WIDTH. pub fn checked(input: &'a str) -> Self { if let Err(e) = Self::invalid_chars(input, &create_custom_char_set!()) { return Self { lines: Err(e) }; } let mut out = [""; TertiaryDisplay::ROWS]; let mut parts = input.split('\n'); for (i, out) in out.iter_mut().enumerate() { if let Some(part) = parts.next() { if part.len() > TertiaryDisplay::WIDTH { return Self { lines: Err(LinesError::ColumnOverflow { line: i as u8, columns: part.len(), }), }; } *out = part; } } if parts.next().is_some() { return Self { lines: Err(LinesError::LineOverflow), }; } Self { lines: Ok(out) } } /// Wraps text automatically at `WIDTH` or at newlines. /// Discards (clamps) any leftover text that exceeds `ROWS`. /// Fails immediately if invalid characters are present. pub fn clamped(input: &'a str) -> Self { if let Err(e) = Self::invalid_chars(input, &create_custom_char_set!()) { return Self { lines: Err(e) }; } let mut out = [""; TertiaryDisplay::ROWS]; let mut remaining = input; for out in out.iter_mut() { if remaining.is_empty() { break; } let (segment, rest) = Self::next_segment(remaining, TertiaryDisplay::WIDTH); *out = segment; remaining = rest; } Self { lines: Ok(out) } } /// Helper function to slice an ASCII string up to `max_width` /// or until the next newline, whichever comes first. fn next_segment(s: &str, max_width: usize) -> (&str, &str) { let nl_pos = s.find('\n'); let split_pos = match nl_pos { Some(pos) if pos <= max_width => pos, _ => core::cmp::min(s.len(), max_width), }; let segment = &s[..split_pos]; let mut rest = &s[split_pos..]; if let Some(stripped) = rest.strip_prefix('\n') { rest = stripped; } (segment, rest) } } impl<'a> From<&'a str> for Lines<'a> { fn from(value: &'a str) -> Self { Self::clamped(value) } } impl<'a> From<&'a alloc::string::String> for Lines<'a> { fn from(value: &'a alloc::string::String) -> Self { Self::clamped(value) } } /// 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 }; CustomCharset { set: SET, len: LEN, } }}; } 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); #[cfg(test)] mod tests { use super::*; const EMPTY_CHAR: CustomCharset = CustomCharset::EMPTY; // Allows custom chars \x00, \x01, \x02 const CUSTOM_CHAR: CustomCharset = EXAMPLE; #[test] fn test_invalid_chars() { // Valid standard ASCII assert_eq!( Lines::invalid_chars("Hello World! 123", &EMPTY_CHAR), Ok(()) ); assert_eq!(Lines::invalid_chars("Line1\nLine2", &EMPTY_CHAR), Ok(())); // Invalid control characters (e.g., \t, \r) assert_eq!( Lines::invalid_chars("Hello\tWorld", &EMPTY_CHAR), Err(LinesError::InvalidChar { position: 5, symbol: '\t' }) ); assert_eq!( Lines::invalid_chars("Hello\r\nWorld", &EMPTY_CHAR), Err(LinesError::InvalidChar { position: 5, symbol: '\r' }) ); // Valid custom characters assert_eq!(Lines::invalid_chars("Temp: 20\x00C", &CUSTOM_CHAR), Ok(())); assert_eq!(Lines::invalid_chars("\x00\x01\x02", &CUSTOM_CHAR), Ok(())); // Out of bounds custom character (\x03 is >= len 3) assert_eq!( Lines::invalid_chars("Temp: 20\x03C", &CUSTOM_CHAR), Err(LinesError::InvalidChar { position: 8, symbol: '\x03' }) ); // Non-ASCII characters assert_eq!( Lines::invalid_chars("Hellö", &EMPTY_CHAR), Err(LinesError::InvalidChar { position: 4, symbol: 'ö' }) ); } #[test] fn test_by_line() { // Valid lines let valid = Lines::by_line(["Short", "Text"], &EMPTY_CHAR).into_result(); assert_eq!(valid, Ok(["Short", "Text"])); // Column overflow let overflow = Lines::by_line(["12345678901234567", "Ok"], &EMPTY_CHAR).into_result(); assert_eq!( overflow, Err(LinesError::ColumnOverflow { line: 0, columns: 17 }) ); // Invalid character in pre-split line let invalid = Lines::by_line(["Good", "B\x05ad"], &EMPTY_CHAR).into_result(); assert_eq!( invalid, Err(LinesError::InvalidChar { position: 1, symbol: '\x05' }) ); } #[test] fn test_checked() { // Valid single line let single = Lines::checked("Single", &EMPTY_CHAR).into_result(); assert_eq!(single, Ok(["Single", ""])); // Valid exactly two lines let two_lines = Lines::checked("Line1\nLine2", &EMPTY_CHAR).into_result(); assert_eq!(two_lines, Ok(["Line1", "Line2"])); // Column overflow let col_overflow = Lines::checked("Line1\n12345678901234567", &EMPTY_CHAR).into_result(); assert_eq!( col_overflow, Err(LinesError::ColumnOverflow { line: 1, columns: 17 }) ); // Lines overflow let lines_overflow = Lines::checked("One\nTwo\nThree", &EMPTY_CHAR).into_result(); assert_eq!(lines_overflow, Err(LinesError::LineOverflow)); // Invalid character detection let bad_char = Lines::checked("One\nTwö", &EMPTY_CHAR).into_result(); assert_eq!( bad_char, Err(LinesError::InvalidChar { position: 6, symbol: 'ö' }) ); } #[test] fn test_clamped() { // Simple string fits on one line let short = Lines::clamped("Hello", &EMPTY_CHAR).into_result(); assert_eq!(short, Ok(["Hello", ""])); // Exactly width (16) let exact = Lines::clamped("1234567890123456", &EMPTY_CHAR).into_result(); assert_eq!(exact, Ok(["1234567890123456", ""])); // Wraps perfectly on 16 characters let wrapped = Lines::clamped("1234567890123456789", &EMPTY_CHAR).into_result(); assert_eq!(wrapped, Ok(["1234567890123456", "789"])); // Manual newlines let newlines = Lines::clamped("A\nB", &EMPTY_CHAR).into_result(); assert_eq!(newlines, Ok(["A", "B"])); // Exact match with a manual newline let wrap_with_newline = Lines::clamped("1234567890123456\nNext", &EMPTY_CHAR).into_result(); assert_eq!(wrap_with_newline, Ok(["1234567890123456", "Next"])); // Clamps excess lines silently (truncates after row 2) let clamped = Lines::clamped("A\nB\nC", &EMPTY_CHAR).into_result(); assert_eq!(clamped, Ok(["A", "B"])); // "C" is discarded // Invalid char returns error immediately let invalid = Lines::clamped("1234\t5", &EMPTY_CHAR).into_result(); assert_eq!( invalid, Err(LinesError::InvalidChar { position: 4, symbol: '\t' }) ); } }