const LCD_COLS: usize = 20;
const LCD_ROWS: usize = 2;
#[derive(Clone)]
pub struct LcdState {
pub rows: [[u8; 20]; 2],
pub cursor: (u8, u8),
}
impl Default for LcdState {
fn default() -> Self {
Self {
rows: [[b' '; 20]; 2],
cursor: (0, 0),
}
}
}
#[derive(Default)]
pub struct LcdDecoder {
sclk_mask: u32,
data_mask: u32,
cs_mask: u32,
state: LcdState,
prev_gpio: u32,
have_prev: bool,
in_frame: bool,
shift: u8,
bit_count: u8,
rx_buf: Vec<u8>,
}
impl LcdDecoder {
pub fn new(sclk_pin: u8, data_pin: u8, cs_pin: u8) -> Self {
Self {
sclk_mask: 1u32 << sclk_pin,
data_mask: 1u32 << data_pin,
cs_mask: 1u32 << cs_pin,
..Self::default()
}
}
pub fn sample(&mut self, gpio_out: u32) {
if !self.have_prev {
self.prev_gpio = gpio_out;
self.have_prev = true;
return;
}
let cs_now = (gpio_out & self.cs_mask) == 0;
let cs_prev = (self.prev_gpio & self.cs_mask) == 0;
let sclk_now = (gpio_out & self.sclk_mask) != 0;
let sclk_prev = (self.prev_gpio & self.sclk_mask) != 0;
let data_now = (gpio_out & self.data_mask) != 0;
let cs_falling = cs_now && !cs_prev;
if cs_falling {
self.in_frame = true;
self.shift = 0;
self.bit_count = 0;
self.rx_buf.clear();
}
if self.in_frame && !cs_falling && sclk_now && !sclk_prev {
self.shift = (self.shift << 1) | u8::from(data_now);
self.bit_count += 1;
if self.bit_count == 8 {
self.rx_buf.push(self.shift);
self.shift = 0;
self.bit_count = 0;
}
}
if !cs_now && cs_prev {
self.in_frame = false;
self.apply_frame();
}
self.prev_gpio = gpio_out;
if cs_falling {
self.prev_gpio &= !self.sclk_mask;
}
}
pub fn state(&self) -> LcdState {
self.state.clone()
}
fn apply_frame(&mut self) {
let bytes = std::mem::take(&mut self.rx_buf);
let Some(&first) = bytes.first() else {
return;
};
match first {
0x01 => {
self.state.rows = [[b' '; LCD_COLS]; LCD_ROWS];
self.state.cursor = (0, 0);
}
0x02 => {
let col = bytes.get(1).copied().unwrap_or(0);
let row = bytes.get(2).copied().unwrap_or(0);
let col = col.min((LCD_COLS - 1) as u8);
let row = row.min((LCD_ROWS - 1) as u8);
self.state.cursor = (col, row);
}
0x03 => {
for &b in &bytes[1..] {
self.write_char(b);
}
}
_ => {}
}
}
fn write_char(&mut self, c: u8) {
let (mut col, mut row) = self.state.cursor;
if col as usize >= LCD_COLS {
col = 0;
row = row.saturating_add(1);
}
if row as usize >= LCD_ROWS {
self.scroll_up();
row = (LCD_ROWS - 1) as u8;
}
self.state.rows[row as usize][col as usize] = c;
col += 1;
if col as usize >= LCD_COLS {
col = 0;
row = row.saturating_add(1);
if row as usize >= LCD_ROWS {
self.scroll_up();
row = (LCD_ROWS - 1) as u8;
}
}
self.state.cursor = (col, row);
}
fn scroll_up(&mut self) {
for r in 1..LCD_ROWS {
self.state.rows[r - 1] = self.state.rows[r];
}
self.state.rows[LCD_ROWS - 1] = [b' '; LCD_COLS];
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SCLK: u8 = 14;
const TEST_DATA: u8 = 15;
const TEST_CS: u8 = 16;
fn make_gpio(cs_low: bool, sclk_high: bool, data_high: bool) -> u32 {
let mut g: u32 = 0;
if !cs_low {
g |= 1u32 << TEST_CS;
}
if sclk_high {
g |= 1u32 << TEST_SCLK;
}
if data_high {
g |= 1u32 << TEST_DATA;
}
g
}
fn push_byte(dec: &mut LcdDecoder, byte: u8) {
for i in 0..8 {
let bit = (byte >> (7 - i)) & 1 != 0;
dec.sample(make_gpio(true, false, bit));
dec.sample(make_gpio(true, true, bit));
dec.sample(make_gpio(true, false, bit));
}
}
fn start_frame(dec: &mut LcdDecoder) {
dec.sample(make_gpio(false, false, false));
dec.sample(make_gpio(true, false, false));
}
fn end_frame(dec: &mut LcdDecoder) {
dec.sample(make_gpio(false, false, false));
}
fn push_frame(dec: &mut LcdDecoder, payload: &[u8]) {
start_frame(dec);
for &b in payload {
push_byte(dec, b);
}
end_frame(dec);
}
#[test]
fn clear_set_cursor_and_write_hi() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x01]);
push_frame(&mut dec, &[0x02, 0, 0]);
push_frame(&mut dec, &[0x03, b'H', b'i']);
let state = dec.state();
assert_eq!(state.rows[0][0], b'H');
assert_eq!(state.rows[0][1], b'i');
assert_eq!(state.rows[0][2], b' ');
assert_eq!(state.cursor, (2, 0));
}
#[test]
fn clear_fills_rows_with_spaces_and_homes_cursor() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x02, 5, 1]);
push_frame(&mut dec, &[0x03, b'X']);
assert_eq!(dec.state().rows[1][5], b'X');
push_frame(&mut dec, &[0x01]);
let state = dec.state();
assert!(state.rows.iter().all(|row| row.iter().all(|&b| b == b' ')));
assert_eq!(state.cursor, (0, 0));
}
#[test]
fn wrap_and_scroll_when_row1_overflows() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x01]);
push_frame(&mut dec, &[0x02, 19, 1]);
push_frame(&mut dec, &[0x03, b'A', b'B']);
let state = dec.state();
assert_eq!(state.rows[0][19], b'A');
assert_eq!(state.rows[1][0], b'B');
}
#[test]
fn unknown_opcode_is_silently_dropped() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x03, b'X']);
let before = dec.state();
assert_eq!(before.rows[0][0], b'X');
push_frame(&mut dec, &[0x00, b'a', b'b', b'c']);
push_frame(&mut dec, &[0x7F, b'!', b'!']);
push_frame(&mut dec, &[0xFF, b'?']);
let after = dec.state();
assert_eq!(after.rows, before.rows, "unknown opcode must not write");
assert_eq!(after.cursor, before.cursor, "cursor must not move");
}
#[test]
fn zero_byte_frame_is_a_noop() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x03, b'Y']);
let before = dec.state();
start_frame(&mut dec);
end_frame(&mut dec);
let after = dec.state();
assert_eq!(after.rows, before.rows, "zero-byte frame must be noop");
assert_eq!(after.cursor, before.cursor);
}
#[test]
fn set_cursor_missing_row_arg_defaults_to_zero() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x02, 5]);
assert_eq!(dec.state().cursor, (5, 0));
}
#[test]
fn set_cursor_out_of_range_args_clamp() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x02, 25, 5]);
assert_eq!(dec.state().cursor, (19, 1));
}
#[test]
fn col_wrap_without_scroll_keeps_cursor_in_range() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x01]);
push_frame(&mut dec, &[0x02, 19, 0]);
push_frame(&mut dec, &[0x03, b'X', b'Y']);
let state = dec.state();
assert_eq!(state.rows[0][19], b'X');
assert_eq!(state.rows[1][0], b'Y');
assert_eq!(state.cursor, (1, 1));
assert!((state.cursor.0 as usize) < LCD_COLS);
assert!((state.cursor.1 as usize) < LCD_ROWS);
}
#[test]
fn cursor_never_out_of_range_after_single_row0_write() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x01]);
push_frame(&mut dec, &[0x02, 19, 0]);
push_frame(&mut dec, &[0x03, b'Z']);
let state = dec.state();
assert_eq!(state.rows[0][19], b'Z');
assert_eq!(state.cursor, (0, 1));
assert!((state.cursor.0 as usize) < LCD_COLS);
assert!((state.cursor.1 as usize) < LCD_ROWS);
}
#[test]
fn mid_byte_frame_abort_drops_partial_byte() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x03, b'Q']);
let before = dec.state();
start_frame(&mut dec);
for i in 0..4 {
let bit = (0b1010u8 >> (3 - i)) & 1 != 0;
dec.sample(make_gpio(true, false, bit));
dec.sample(make_gpio(true, true, bit));
dec.sample(make_gpio(true, false, bit));
}
end_frame(&mut dec);
let after = dec.state();
assert_eq!(after.rows, before.rows);
assert_eq!(after.cursor, before.cursor);
}
#[test]
fn set_cursor_no_args_defaults_to_origin() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x02, 7, 1]);
assert_eq!(dec.state().cursor, (7, 1));
push_frame(&mut dec, &[0x02]);
assert_eq!(dec.state().cursor, (0, 0));
}
#[test]
fn row1_overflow_scrolls_twice_within_one_write() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x01]);
push_frame(&mut dec, &[0x02, 19, 1]);
let payload: Vec<u8> = std::iter::once(0x03)
.chain((b'A'..=b'V').take(22))
.collect();
push_frame(&mut dec, &payload);
let state = dec.state();
assert!(
(state.cursor.0 as usize) < LCD_COLS,
"cursor col {} out of range",
state.cursor.0,
);
assert!(
(state.cursor.1 as usize) < LCD_ROWS,
"cursor row {} out of range",
state.cursor.1,
);
assert!(
state.rows[1].contains(&b'V'),
"row 1 should contain 'V' after multiple scrolls: {:?}",
std::str::from_utf8(&state.rows[1]).unwrap_or("?"),
);
}
#[test]
fn set_cursor_at_max_boundary_does_not_clamp() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
push_frame(&mut dec, &[0x02, 19, 1]);
assert_eq!(dec.state().cursor, (19, 1));
}
#[test]
fn sclk_high_before_cs_falls_still_captures_bit7() {
let mut dec = LcdDecoder::new(TEST_SCLK, TEST_DATA, TEST_CS);
dec.sample(make_gpio(false, true, false));
dec.sample(make_gpio(true, true, false));
dec.sample(make_gpio(true, true, true));
dec.sample(make_gpio(true, false, true));
for i in 1..8 {
let bit = (0x81u8 >> (7 - i)) & 1 != 0;
dec.sample(make_gpio(true, false, bit));
dec.sample(make_gpio(true, true, bit));
dec.sample(make_gpio(true, false, bit));
}
push_byte(&mut dec, 0xAA);
push_byte(&mut dec, 0x55);
push_byte(&mut dec, 0x00);
end_frame(&mut dec);
let state = dec.state();
assert_eq!(
state.rows[0][0], b' ',
"bit 7 of first byte was lost: rows[0][0] = {:#x}, expected space",
state.rows[0][0]
);
assert_eq!(
state.cursor,
(0, 0),
"bit 7 of first byte was lost: unknown opcode (0x81) was misread"
);
}
}