411 lines
12 KiB
Rust
411 lines
12 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) -> Result<TertiaryDisplay<'a>, Self> {
|
|
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");
|
|
Ok(TertiaryDisplay {
|
|
display: lcd,
|
|
charset: create_custom_char_set!(),
|
|
})
|
|
}
|
|
Err(e) => {
|
|
log::error!("I2C tertiary LCD init failed on PCF8574T at address 0x27: {e}");
|
|
Err(self)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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();
|
|
|
|
// Use original to get correct position in original string
|
|
lines
|
|
.original
|
|
.iter()
|
|
.try_for_each(|text| Lines::invalid_chars(text, Some(&self.charset)))?;
|
|
|
|
let lines = lines.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>,
|
|
/// Unmodified original string. Only first entry is populated unless constructed with [`by_line`]
|
|
pub original: [&'a str; TertiaryDisplay::ROWS],
|
|
}
|
|
|
|
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`.
|
|
/// If `charset` is `None`, allow any custom char up to and including maximum.
|
|
pub fn invalid_chars(text: &str, charset: Option<&CustomCharset>) -> Result<(), LinesError> {
|
|
for (pos, c) in text.char_indices() {
|
|
let is_standard_valid = c == '\n' || (' '..='~').contains(&c);
|
|
|
|
if is_standard_valid {
|
|
continue;
|
|
}
|
|
|
|
let first_invalid_custom_char =
|
|
charset.map(|c| c.len).unwrap_or(CUSTOM_CHAR_COUNT.into());
|
|
|
|
if (c as usize) >= first_invalid_custom_char {
|
|
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 {
|
|
let this = |res| Self {
|
|
lines: res,
|
|
original: lines,
|
|
};
|
|
|
|
for line in lines.iter() {
|
|
if let Err(e) = Self::invalid_chars(line, None) {
|
|
return this(Err(e));
|
|
}
|
|
}
|
|
|
|
for (i, line) in lines.iter().enumerate() {
|
|
if line.len() > TertiaryDisplay::WIDTH {
|
|
return this(Err(LinesError::ColumnOverflow {
|
|
line: i as u8,
|
|
columns: line.len(),
|
|
}));
|
|
}
|
|
}
|
|
|
|
this(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 {
|
|
let this = |res| Self {
|
|
lines: res,
|
|
original: [input, ""],
|
|
};
|
|
|
|
if let Err(e) = Self::invalid_chars(input, None) {
|
|
return this(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 this(Err(LinesError::ColumnOverflow {
|
|
line: i as u8,
|
|
columns: part.len(),
|
|
}));
|
|
}
|
|
*out = part;
|
|
}
|
|
}
|
|
|
|
if parts.next().is_some() {
|
|
return this(Err(LinesError::LineOverflow));
|
|
}
|
|
|
|
this(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 {
|
|
let this = |res| Self {
|
|
lines: res,
|
|
original: [input, ""],
|
|
};
|
|
if let Err(e) = Self::invalid_chars(input, None) {
|
|
return this(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;
|
|
}
|
|
|
|
this(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);
|