Files
creaturedex/src/drivers/tertiary_lcd.rs
T
2026-08-25 17:38:19 +02:00

543 lines
17 KiB
Rust

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<I2C> = CharacterDisplayPCF8574T<I2C, Delay>;
type TertiaryDisplayRaw<'a> = GenericTertiaryDisplay<I2cDevice<'a>>;
pub type TertiaryDisplayError<'a> = i2c_character_display::CharacterDisplayError<I2cDevice<'a>>;
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<TertiaryDisplayError<'a>> for TertiaryDisplayWriteError<'a> {
fn from(value: TertiaryDisplayError<'a>) -> Self {
Self::Display(value)
}
}
impl From<LinesError> 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<Lines<'b>>>(
&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<CharMap> 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'
})
);
}
}