Skip to main content

device_envoy_core/
lcd_text.rs

1//! A device abstraction for shared HD44780 character LCD protocol/state helpers.
2//!
3//! See `device_envoy_rp::lcd_text` for constructors and usage examples.
4
5use embassy_time::Timer;
6
7/// Character LCD operation errors shared across platform crates.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum LcdTextError {
10    /// I2C write failed for the given 7-bit address.
11    I2cWrite { address: u8 },
12    /// Attempted to set cursor to an out-of-range row.
13    RowOutOfBounds { row: usize },
14}
15
16/// A packed text frame for an HD44780 display.
17#[derive(Clone, Copy, Debug)]
18// Public for cross-crate platform plumbing; hidden from end-user docs.
19#[doc(hidden)]
20pub struct LcdTextFrame<const MAX_CHARS: usize> {
21    /// Frame width in characters.
22    pub width: usize,
23    /// Frame height in characters.
24    pub height: usize,
25    /// Packed row-major cell bytes.
26    pub cells: [u8; MAX_CHARS],
27}
28
29impl<const MAX_CHARS: usize> LcdTextFrame<MAX_CHARS> {
30    /// Create a blank frame with spaces.
31    #[must_use]
32    pub const fn new_blank(width: usize, height: usize) -> Self {
33        assert!(
34            width * height <= MAX_CHARS,
35            "frame geometry exceeds capacity"
36        );
37        Self {
38            width,
39            height,
40            cells: [b' '; MAX_CHARS],
41        }
42    }
43
44    /// Build a packed frame from a fixed `W x H` buffer.
45    #[must_use]
46    pub fn from_rows<const W: usize, const H: usize>(rows: [[u8; W]; H]) -> Self {
47        let mut lcd_text_frame = Self::new_blank(W, H);
48        let mut row_index = 0;
49        while row_index < H {
50            let mut column_index = 0;
51            while column_index < W {
52                let flat_index = row_index * W + column_index;
53                lcd_text_frame.cells[flat_index] = rows[row_index][column_index];
54                column_index += 1;
55            }
56            row_index += 1;
57        }
58        lcd_text_frame
59    }
60
61    /// Returns the byte at `(row, col)` in this frame.
62    #[must_use]
63    pub fn cell(&self, row: usize, col: usize) -> u8 {
64        self.cells[row * self.width + col]
65    }
66}
67
68/// Render text into a fixed-size LCD frame using `W x H` geometry.
69///
70/// Behavior:
71/// - `\n` starts a new row.
72/// - Characters past `W` on a row are ignored.
73/// - Rows past `H` are ignored.
74/// - Non-ASCII Unicode characters are replaced with `?`.
75/// - Missing characters are padded with spaces.
76#[must_use]
77// Public for cross-crate platform plumbing; hidden from end-user docs.
78#[doc(hidden)]
79pub fn render_lcd_text_frame<const W: usize, const H: usize, const MAX_CHARS: usize>(
80    text: &str,
81) -> LcdTextFrame<MAX_CHARS> {
82    let mut rows = [[b' '; W]; H];
83
84    for (row_index, line) in text.split('\n').enumerate() {
85        if row_index >= H {
86            break;
87        }
88
89        for (column_index, ch) in line.chars().enumerate() {
90            if column_index >= W {
91                break;
92            }
93            rows[row_index][column_index] = if ch.is_ascii() { ch as u8 } else { b'?' };
94        }
95    }
96
97    LcdTextFrame::<MAX_CHARS>::from_rows(rows)
98}
99
100/// Platform-agnostic LCD text device contract.
101///
102/// Platform crates implement this trait for their generated LCD text types so
103/// shared logic can write text without knowing the hardware backend.
104///
105/// Design intent:
106///
107/// - This trait is intended for static dispatch on embedded targets.
108/// - Dimensions are const generics so geometry remains compile-time.
109/// - `write_text` accepts any string-like input via `AsRef<str>`.
110///
111/// # Example: Write Text
112///
113/// This example writes text through a generic trait-bound helper.
114///
115/// ```rust,no_run
116/// use device_envoy_core::lcd_text::LcdText;
117///
118/// fn write_message<const W: usize, const H: usize>(lcd_text: &impl LcdText<W, H>) {
119///     lcd_text.write_text("Hello from\ndevice-envoy!");
120/// }
121///
122/// # struct LcdTextSimple;
123/// # impl LcdText<16, 2> for LcdTextSimple {
124/// #     const ADDRESS: u8 = 0x27;
125/// #     fn write_text(&self, _text: impl AsRef<str>) {}
126/// # }
127/// # let lcd_text_simple = LcdTextSimple;
128/// # write_message(&lcd_text_simple);
129/// ```
130pub trait LcdText<const W: usize, const H: usize> {
131    /// Display width in characters.
132    const WIDTH: usize = W;
133    /// Display height in characters.
134    const HEIGHT: usize = H;
135    /// LCD I2C address.
136    const ADDRESS: u8;
137
138    /// Write text to the display.
139    /// See the [LcdText trait documentation](Self) for usage examples.
140    fn write_text(&self, text: impl AsRef<str>);
141}
142
143/// Character LCD write adapter for platform crates.
144pub trait LcdTextWrite {
145    /// Write one byte to the configured LCD I2C expander.
146    fn write(&mut self, address: u8, data: u8) -> Result<(), LcdTextError>;
147}
148
149// PCF8574 pin mapping: P0=RS, P1=RW, P2=E, P3=Backlight, P4-P7=Data.
150const LCD_BACKLIGHT: u8 = 0x08;
151const LCD_ENABLE: u8 = 0x04;
152const LCD_RS: u8 = 0x01;
153
154/// HD44780 protocol driver over a byte-oriented I2C expander transport.
155// Public for cross-crate platform plumbing; hidden from end-user docs.
156#[doc(hidden)]
157pub struct LcdTextDriver {
158    address: u8,
159}
160
161impl LcdTextDriver {
162    /// Creates a driver for a specific PCF8574 backpack address.
163    #[must_use]
164    pub const fn new(address: u8) -> Self {
165        Self { address }
166    }
167
168    /// Set the active LCD I2C address for subsequent writes.
169    pub fn set_address(&mut self, address: u8) {
170        self.address = address;
171    }
172
173    /// Initialize the LCD in 4-bit mode and clear it.
174    pub async fn init(
175        &mut self,
176        lcd_text_write: &mut impl LcdTextWrite,
177    ) -> Result<(), LcdTextError> {
178        Timer::after_millis(50).await;
179
180        self.write_nibble(lcd_text_write, 0x03, false).await?;
181        Timer::after_millis(5).await;
182        self.write_nibble(lcd_text_write, 0x03, false).await?;
183        Timer::after_micros(150).await;
184        self.write_nibble(lcd_text_write, 0x03, false).await?;
185        self.write_nibble(lcd_text_write, 0x02, false).await?;
186
187        // Function set: 4-bit, 2 lines, 5x8 font.
188        self.write_byte(lcd_text_write, 0x28, false).await?;
189        // Display control: display on, cursor off, blink off.
190        self.write_byte(lcd_text_write, 0x0C, false).await?;
191        // Clear display.
192        self.write_byte(lcd_text_write, 0x01, false).await?;
193        Timer::after_millis(2).await;
194        // Entry mode: increment cursor, no shift.
195        self.write_byte(lcd_text_write, 0x06, false).await?;
196        Ok(())
197    }
198
199    /// Write one full frame to the LCD.
200    pub async fn write_frame<const MAX_CHARS: usize>(
201        &mut self,
202        lcd_text_write: &mut impl LcdTextWrite,
203        lcd_text_frame: &LcdTextFrame<MAX_CHARS>,
204    ) -> Result<(), LcdTextError> {
205        self.clear(lcd_text_write).await?;
206
207        for row_index in 0..lcd_text_frame.height {
208            self.set_cursor(lcd_text_write, row_index, 0).await?;
209            for column_index in 0..lcd_text_frame.width {
210                self.write_byte(
211                    lcd_text_write,
212                    lcd_text_frame.cell(row_index, column_index),
213                    true,
214                )
215                .await?;
216            }
217        }
218
219        Ok(())
220    }
221
222    #[expect(clippy::arithmetic_side_effects, reason = "Bit operations")]
223    async fn write_nibble(
224        &mut self,
225        lcd_text_write: &mut impl LcdTextWrite,
226        nibble: u8,
227        rs: bool,
228    ) -> Result<(), LcdTextError> {
229        let rs_bit = if rs { LCD_RS } else { 0 };
230        let data = (nibble << 4) | LCD_BACKLIGHT | rs_bit;
231
232        lcd_text_write.write(self.address, data | LCD_ENABLE)?;
233        Timer::after_micros(1).await;
234        lcd_text_write.write(self.address, data)?;
235        Timer::after_micros(50).await;
236        Ok(())
237    }
238
239    async fn write_byte(
240        &mut self,
241        lcd_text_write: &mut impl LcdTextWrite,
242        byte: u8,
243        rs: bool,
244    ) -> Result<(), LcdTextError> {
245        self.write_nibble(lcd_text_write, (byte >> 4) & 0x0F, rs)
246            .await?;
247        self.write_nibble(lcd_text_write, byte & 0x0F, rs).await?;
248        Ok(())
249    }
250
251    async fn clear(&mut self, lcd_text_write: &mut impl LcdTextWrite) -> Result<(), LcdTextError> {
252        self.write_byte(lcd_text_write, 0x01, false).await?;
253        Timer::after_millis(2).await;
254        Ok(())
255    }
256
257    #[expect(
258        clippy::arithmetic_side_effects,
259        reason = "Row/column values are small"
260    )]
261    async fn set_cursor(
262        &mut self,
263        lcd_text_write: &mut impl LcdTextWrite,
264        row: usize,
265        col: u8,
266    ) -> Result<(), LcdTextError> {
267        let address = match row {
268            0 => col,
269            1 => 0x40_u8 + col,
270            2 => 0x14_u8 + col,
271            3 => 0x54_u8 + col,
272            _ => return Err(LcdTextError::RowOutOfBounds { row }),
273        };
274        self.write_byte(lcd_text_write, 0x80 | address, false)
275            .await?;
276        Ok(())
277    }
278}