diff --git a/ui-app/Cargo.toml b/ui-app/Cargo.toml index 857b768..4464129 100644 --- a/ui-app/Cargo.toml +++ b/ui-app/Cargo.toml @@ -4,5 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +bitmap-font = "0.3.0" defmt = { version = "1.0.1", default-features = false } +embedded-graphics = "0.8.1" +embedded-graphics-core = { version = "0.4.0", default-features = false } heapless = "0.9.1" diff --git a/ui-app/src/lib.rs b/ui-app/src/lib.rs index 108dee2..7711a94 100644 --- a/ui-app/src/lib.rs +++ b/ui-app/src/lib.rs @@ -1,6 +1,15 @@ #![no_std] +use bitmap_font::{TextStyle, tamzen::FONT_14x26}; use defmt::Format; +use embedded_graphics::{ + draw_target::DrawTarget, + geometry::Dimensions, + pixelcolor::{BinaryColor, Rgb565}, + prelude::*, + primitives::{Circle, PrimitiveStyle, Rectangle}, + text::Text, +}; #[derive(Copy, Clone, Debug, PartialEq, Format)] pub enum Key { @@ -130,16 +139,49 @@ pub trait Led { } } -fn rgb565(r: u8, g: u8, b: u8) -> u16 { - // Discard the low-order bits of the colors - (((r as u16) >> 3) << 11) | (((g as u16) >> 2) << 5) | ((b as u16) >> 3) +struct BinaryDisplay<'a, C, D> { + foreground: C, + background: C, + display: &'a mut D, } -pub trait Screen { - fn width(&self) -> usize; - fn height(&self) -> usize; - fn fill(&mut self, color: u16); - fn draw(&mut self, left: usize, right: usize, top: usize, bottom: usize, data: &[u16]); +impl<'a, C, D> BinaryDisplay<'a, C, D> { + fn new(foreground: C, background: C, display: &'a mut D) -> Self { + Self { + foreground, + background, + display, + } + } +} + +impl<'a, C, D> Dimensions for BinaryDisplay<'a, C, D> +where + D: Dimensions, +{ + fn bounding_box(&self) -> Rectangle { + self.display.bounding_box() + } +} + +impl<'a, C, D> DrawTarget for BinaryDisplay<'a, C, D> +where + C: PixelColor, + D: DrawTarget, +{ + type Color = BinaryColor; + type Error = D::Error; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + let rgb = pixels.into_iter().map(|Pixel(point, color)| match color { + BinaryColor::On => Pixel(point, self.foreground), + BinaryColor::Off => Pixel(point, self.background), + }); + self.display.draw_iter(rgb) + } } pub trait NetTx { @@ -148,7 +190,7 @@ pub trait NetTx { pub trait Outputs { fn status_led(&mut self) -> &mut impl Led; - fn screen(&mut self) -> &mut impl Screen; + fn screen(&mut self) -> &mut impl DrawTarget; fn net_tx(&mut self) -> &mut impl NetTx; fn log(&mut self, message: &str); } @@ -173,47 +215,41 @@ impl App { out.status_led().set_color(Color::Black); // Draw a test pattern to the screen - const SIZE: usize = 10; - let screen = out.screen(); - let mut data = [0_u16; SIZE * SIZE]; - - // Background - screen.fill(rgb565(0x88, 0x88, 0x88)); - - // Upper left = R - let (x0, x1, y0, y1) = (SIZE, SIZE + SIZE, SIZE, SIZE + SIZE); - data.fill(rgb565(0xFF, 0x00, 0x00)); - screen.draw(x0, x1, y0, y1, &data); - - // Upper right = G - let (x0, x1, y0, y1) = ( - screen.width() - SIZE - SIZE, - screen.width() - SIZE, - SIZE, - SIZE + SIZE, - ); - data.fill(rgb565(0x00, 0xFF, 0x00)); - screen.draw(x0, x1, y0, y1, &data); - - // Lower left = B - let (x0, x1, y0, y1) = ( - SIZE, - SIZE + SIZE, - screen.height() - SIZE - SIZE, - screen.height() - SIZE, + let rect = out.screen().bounding_box(); + + rect.into_styled(PrimitiveStyle::with_fill(Rgb565::new(0x88, 0x88, 0x88))) + .draw(out.screen()) + .unwrap_or_else(|_| panic!("graphics error")); + + let pad: u32 = 10; + let diameter: u32 = 20; + let width = rect.size.width; + let height = rect.size.height; + + let mut dot = |left, top, color| { + Circle::new(Point::new(left as i32, top as i32), diameter) + .into_styled(PrimitiveStyle::with_fill(color)) + .draw(out.screen()) + .unwrap_or_else(|_| panic!("graphics error")); + }; + + dot(pad, pad, Rgb565::RED); + dot(width - pad - diameter, pad, Rgb565::GREEN); + dot(pad, height - pad - diameter, Rgb565::BLUE); + dot( + width - pad - diameter, + height - pad - diameter, + Rgb565::YELLOW, ); - data.fill(rgb565(0x00, 0x00, 0xFF)); - screen.draw(x0, x1, y0, y1, &data); - - // Lower right = Y - let (x0, x1, y0, y1) = ( - screen.width() - SIZE - SIZE, - screen.width() - SIZE, - screen.height() - SIZE - SIZE, - screen.height() - SIZE, + + let text = Text::new( + "Hello World!", + Point { x: 10, y: 30 }, + TextStyle::new(&FONT_14x26, BinaryColor::On), ); - data.fill(rgb565(0xFF, 0xFF, 0x00)); - screen.draw(x0, x1, y0, y1, &data); + let mut binary_display = BinaryDisplay::new(Rgb565::WHITE, Rgb565::BLACK, out.screen()); + text.draw(&mut binary_display) + .unwrap_or_else(|_| panic!("graphics error")); } pub fn handle(&mut self, event: Event, out: &mut impl Outputs) { diff --git a/ui-app/tests/integration.rs b/ui-app/tests/integration.rs index f627372..aa2b5d4 100644 --- a/ui-app/tests/integration.rs +++ b/ui-app/tests/integration.rs @@ -1,5 +1,9 @@ use ui_app::*; +use embedded_graphics::{ + draw_target::DrawTarget, pixelcolor::Rgb565, prelude::*, primitives::Rectangle, +}; + #[derive(Default)] struct MockLed { color: Option, @@ -15,21 +19,22 @@ impl Led for MockLed { #[derive(Default)] struct MockScreen; -impl Screen for MockScreen { - fn width(&self) -> usize { - 240 - } - - fn height(&self) -> usize { - 320 +impl Dimensions for MockScreen { + fn bounding_box(&self) -> Rectangle { + Rectangle::new(Point::new(0, 0), Size::new(240, 320)) } +} - fn fill(&mut self, _color: u16) { - // TODO: Store pixels - } +impl DrawTarget for MockScreen { + type Color = Rgb565; + type Error = String; - fn draw(&mut self, _left: usize, _right: usize, _top: usize, _bottom: usize, _data: &[u16]) { - // TODO: Store pixels + fn draw_iter(&mut self, _pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + // TODO(RLB) Store pixels + Ok(()) } } @@ -57,7 +62,7 @@ impl Outputs for MockOutputs { &mut self.status_led } - fn screen(&mut self) -> &mut impl Screen { + fn screen(&mut self) -> &mut impl DrawTarget { &mut self.screen } diff --git a/ui-stm32/Cargo.toml b/ui-stm32/Cargo.toml index 6336fe8..62f416e 100644 --- a/ui-stm32/Cargo.toml +++ b/ui-stm32/Cargo.toml @@ -29,6 +29,11 @@ hex = { version = "0.4.3", default-features = false } heapless = { version = "0.9.1", features = ["defmt"] } embedded-io-async = "0.6.1" embedded-io = { version = "0.6.1", default-features = false } +ili9341 = "0.6.0" +display-interface = "0.5.0" +embedded-graphics-core = { version = "0.4.0", default-features = false } +bitmap-font = "0.3.0" +embedded-graphics = "0.8.1" [profile.release] debug = 2 diff --git a/ui-stm32/src/board/ev12.rs b/ui-stm32/src/board/ev12.rs index 30ddaf8..48f79a2 100644 --- a/ui-stm32/src/board/ev12.rs +++ b/ui-stm32/src/board/ev12.rs @@ -1,18 +1,106 @@ -use super::{Button, Keyboard, NetTx, Screen, StatusLed}; +use super::{Button, Keyboard, NetTx, StatusLed}; +use display_interface::{DataFormat, DisplayError, WriteOnlyDataCommand}; use embassy_stm32::{ bind_interrupts, exti::ExtiInput, gpio::{Input, Level, Output, Pull, Speed}, - mode::Async, + mode::{Async, Blocking}, peripherals, - spi::Spi, + spi::{Spi, Word}, usart::{self, UartRx, UartTx}, }; +use embassy_time::Delay; +use embedded_graphics::{pixelcolor::Rgb565, prelude::*}; +use ili9341::{Ili9341, Orientation}; use ui_app::{Led, Outputs}; +struct DisplayData { + // These fields are not used, but we need to keep them alive so that the chip select output is + // held low and the backlight output is held high. + _chip_select: Output<'static>, + _backlight: Output<'static>, + + data_command: Output<'static>, + spi: Spi<'static, Blocking>, +} + +impl DisplayData { + fn new( + mut backlight: Output<'static>, + mut chip_select: Output<'static>, + data_command: Output<'static>, + spi: Spi<'static, Blocking>, + ) -> Self { + backlight.set_high(); + chip_select.set_low(); + + Self { + _backlight: backlight, + _chip_select: chip_select, + data_command, + spi, + } + } + + fn write(&mut self, data: DataFormat<'_>) -> Result<(), DisplayError> { + use DataFormat::*; + match data { + U8(slice) => self.write_slice(slice), + U16(slice) => self.write_slice(slice), + U16BE(slice) => self.write_slice(slice), + U16LE(slice) => self.write_slice(slice), + U8Iter(iter) => self.write_iter(iter), + U16BEIter(iter) => self.write_iter(iter), + U16LEIter(iter) => self.write_iter(iter), + _ => unreachable!(), + } + } + + fn write_slice(&mut self, data: &[W]) -> Result<(), DisplayError> { + self.spi.blocking_write(data).unwrap(); + Ok(()) + } + + fn write_iter( + &mut self, + iter: &mut dyn Iterator, + ) -> Result<(), DisplayError> { + const CHUNK_SIZE: usize = 128; + + // XXX(RLB) Very C-style iteration, could probably write this in a way that would optimize + // better. + let mut data = [W::default(); CHUNK_SIZE]; + let mut n = 0; + for (i, x) in iter.enumerate() { + data[i % CHUNK_SIZE] = x; + n = i + 1; + + if n > 0 && n % CHUNK_SIZE == 0 { + self.spi.blocking_write(&data).unwrap(); + n = 0; + } + } + + self.spi.blocking_write(&data[..n]).unwrap(); + Ok(()) + } +} + +impl WriteOnlyDataCommand for DisplayData { + fn send_commands(&mut self, cmd: DataFormat<'_>) -> Result<(), DisplayError> { + self.data_command.set_low(); + self.write(cmd) + } + + fn send_data(&mut self, buf: DataFormat<'_>) -> Result<(), DisplayError> { + self.data_command.set_high(); + self.write(buf) + } +} + pub struct Board { status_led: StatusLed, - screen: Screen, + screen: Ili9341>, net_tx: NetTx>, pub button_a: Option diff --git a/ui-tauri/src/main.js b/ui-tauri/src/main.js index 3b3ffa8..8f47950 100644 --- a/ui-tauri/src/main.js +++ b/ui-tauri/src/main.js @@ -67,26 +67,19 @@ async function handle_ptt(e) { } } -async function handle_screen(e) { - let { left, right, top, bottom, data } = e.payload; +async function handle_pixels(e) { + let { pixels } = e.payload; const screen = document.getElementById("screen"); const ctx = screen.getContext("2d"); - const width = right - left; - const height = bottom - top; - const imageData = ctx.createImageData(width, height); + const imageData = ctx.createImageData(1, 1); + for (let pixel of pixels) { + const { x, y, r, g, b } = pixel; - if (data.length != imageData.data.length) { - console.log(`malformed command ${data.length} != ${imageData.data.length}`); - return; + ctx.fillStyle = `rgb(${r}, ${g}, ${b}, 1)`; + ctx.fillRect( x, y, 1, 1 ); } - - for (let i = 0; i < imageData.data.length; i ++) { - imageData.data[i] = data[i]; - } - - ctx.putImageData(imageData, left, top); } async function handle_led(e) { @@ -100,9 +93,9 @@ async function handle_led(e) { async function handle_events() { const ptt_state = listen('PttState', handle_ptt); - const screen = listen('Screen', handle_screen); + const pixels = listen('Pixels', handle_pixels); const led = listen('LED', handle_led); - return Promise.all([ptt_state, screen, led]) + return Promise.all([ptt_state, pixels, led]) } window.addEventListener("DOMContentLoaded", async () => { diff --git a/ui-tauri/src/styles.css b/ui-tauri/src/styles.css index 2081606..8b13ddf 100644 --- a/ui-tauri/src/styles.css +++ b/ui-tauri/src/styles.css @@ -37,7 +37,7 @@ body { .container { margin: 0; - padding-top: 5vh; + padding-top: 2vh; display: flex; flex-direction: column; justify-content: center; @@ -46,8 +46,8 @@ body { #screen { background: #999; - width: 320px; - height: 240px; + width: 240px; + height: 320px; margin: 1em auto; }