A fork of pulp-os for the xteink4 adding custom apps
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add: strip based rendering for big ol memory savings

hansmrtn 449fb1e3 8e66b0e3

+355 -225
+41 -22
src/bin/main.rs
··· 16 16 use embedded_graphics::mono_font::ascii::FONT_10X20; 17 17 18 18 use pulp_os::board::Board; 19 + use pulp_os::board::StripBuffer; 19 20 use pulp_os::board::button::Button as HwButton; 20 21 use pulp_os::drivers::input::{Event, InputDriver}; 21 22 use pulp_os::kernel::wake::{WakeReason, signal_timer, try_wake}; ··· 73 74 let mut delay = Delay::new(); 74 75 75 76 board.display.epd.init(&mut delay); 76 - board.display.epd.fill_white(); 77 77 info!("hardware initialized."); 78 + 79 + // strip buffer — 4KB instead of 48KB framebuffer 80 + let mut strip = StripBuffer::new(); 78 81 79 82 // widgets 80 83 let title = Label::new(TITLE, "pulp-os", &FONT_10X20); ··· 85 88 let mut selected: usize = 0; 86 89 item0.set_pressed(true); 87 90 88 - title.draw(&mut board.display.epd).unwrap(); 89 - item0.draw(&mut board.display.epd).unwrap(); 90 - item1.draw(&mut board.display.epd).unwrap(); 91 - status.draw(&mut board.display.epd).unwrap(); 92 - board.display.epd.refresh_full(&mut delay); 91 + board.display.epd.render_full(&mut strip, &mut delay, |s| { 92 + title.draw(s).unwrap(); 93 + item0.draw(s).unwrap(); 94 + item1.draw(s).unwrap(); 95 + status.draw(s).unwrap(); 96 + }); 93 97 94 98 info!("ui ready."); 95 99 ··· 123 127 } 124 128 125 129 // 2. Check wake events (non-blocking). 126 - // When nothing is pending, WFI suspends the core until the next interrupt. 130 + // When nothing is pending, idle via WFI so we don't spin at full speed. 127 131 let should_poll = match try_wake() { 128 132 Some(WakeReason::Timer) | Some(WakeReason::Multiple) => poller.tick(), 129 133 ··· 169 173 &mut item0, 170 174 &mut item1, 171 175 &mut board.display.epd, 176 + &mut strip, 172 177 &mut delay, 173 178 ); 174 179 } ··· 183 188 &mut item0, 184 189 &mut item1, 185 190 &mut board.display.epd, 191 + &mut strip, 186 192 &mut delay, 187 193 ); 188 194 } ··· 195 201 "Selected: Item 1" 196 202 }; 197 203 status.set_text(msg); 198 - status.draw(&mut board.display.epd).unwrap(); 199 204 let r = status.refresh_bounds(); 200 - board 201 - .display 202 - .epd 203 - .refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 205 + board.display.epd.render_partial( 206 + &mut strip, 207 + r.x, 208 + r.y, 209 + r.w, 210 + r.h, 211 + &mut delay, 212 + |s| { 213 + status.draw(s).unwrap(); 214 + }, 215 + ); 204 216 scheduler.push_or_drop(Job::RenderPage); 205 217 } 206 218 HwButton::Power => { ··· 217 229 info!("[BTN] LongPress: {}", button.name()); 218 230 if button == HwButton::Power { 219 231 status.set_text("Shutting down..."); 220 - status.draw(&mut board.display.epd).unwrap(); 221 232 let r = status.refresh_bounds(); 222 - board 223 - .display 224 - .epd 225 - .refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 233 + board.display.epd.render_partial( 234 + &mut strip, 235 + r.x, 236 + r.y, 237 + r.w, 238 + r.h, 239 + &mut delay, 240 + |s| { 241 + status.draw(s).unwrap(); 242 + }, 243 + ); 226 244 } 227 245 } 228 246 Event::Repeat(button) => { ··· 239 257 item0: &mut Button, 240 258 item1: &mut Button, 241 259 display: &mut Epd, 260 + strip: &mut StripBuffer, 242 261 delay: &mut Delay, 243 262 ) { 244 263 item0.set_pressed(selected == 0); 245 264 item1.set_pressed(selected == 1); 246 265 247 - item0.draw(display).unwrap(); 248 - item1.draw(display).unwrap(); 249 - 250 - // Single refresh for both items 266 + // Single partial refresh for both items 251 267 let r = Region::new(16, 80, 200, 112).align8(); 252 - display.refresh_partial(r.x, r.y, r.w, r.h, delay); 268 + display.render_partial(strip, r.x, r.y, r.w, r.h, delay, |s| { 269 + item0.draw(s).unwrap(); 270 + item1.draw(s).unwrap(); 271 + }); 253 272 254 273 info!("Selected: Item {}", selected); 255 274 }
+102 -202
src/board/display.rs
··· 2 2 //! 3 3 //! Based on GxEPD2_426_GDEQ0426T82.cpp by Jean-Marc Zingg 4 4 //! <https://github.com/ZinggJM/GxEPD2> 5 - use embedded_graphics_core::{ 6 - Pixel, 7 - draw_target::DrawTarget, 8 - geometry::{OriginDimensions, Size}, 9 - pixelcolor::BinaryColor, 10 - }; 5 + use embedded_graphics_core::geometry::{OriginDimensions, Size}; 11 6 use embedded_hal::digital::{InputPin, OutputPin}; 12 7 use embedded_hal::spi::SpiDevice; 13 8 use esp_hal::delay::Delay; 14 9 10 + use super::strip::{StripBuffer, STRIP_BUF_SIZE, STRIP_COUNT}; 11 + 15 12 // Display dimensions (physical) 16 13 pub const WIDTH: u16 = 800; 17 14 pub const HEIGHT: u16 = 480; 18 - pub const FRAMEBUFFER_SIZE: usize = (WIDTH as usize * HEIGHT as usize) / 8; 19 15 20 16 // SPI frequency 21 17 pub const SPI_FREQ_MHZ: u32 = 20; ··· 30 26 /// Display rotation 31 27 #[derive(Clone, Copy, Debug, Default, PartialEq)] 32 28 pub enum Rotation { 33 - /// Landscape, 800x480, no rotation 34 29 #[default] 35 30 Deg0, 36 - /// Portrait, 480x800, rotated 90° clockwise 37 31 Deg90, 38 - /// Landscape, 800x480, upside down 39 32 Deg180, 40 - /// Portrait, 480x800, rotated 270° clockwise 41 33 Deg270, 42 34 } 43 35 44 - // SSD1677 Commands (matching GxEPD2 exactly) 36 + // SSD1677 Commands (matching GxEPD2 45 37 mod cmd { 46 38 pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; 47 39 pub const BOOSTER_SOFT_START: u8 = 0x0C; ··· 49 41 pub const DATA_ENTRY_MODE: u8 = 0x11; 50 42 pub const SW_RESET: u8 = 0x12; 51 43 pub const TEMPERATURE_SENSOR: u8 = 0x18; 44 + #[allow(dead_code)] 52 45 pub const WRITE_TEMP_REGISTER: u8 = 0x1A; 53 46 pub const MASTER_ACTIVATION: u8 = 0x20; 54 47 pub const DISPLAY_UPDATE_CONTROL_1: u8 = 0x21; ··· 62 55 pub const SET_RAM_Y_COUNTER: u8 = 0x4F; 63 56 } 64 57 65 - /// Display driver for SSD1677-based e-paper (GDEQ0426T82) 58 + // Display driver for SSD1677-based e-paper (GDEQ0426T82) 59 + // No framebuffer — rendering is done through StripBuffer. 60 + // The display controller has its own 48KB RAM; we stream into it. 66 61 pub struct DisplayDriver<SPI, DC, RST, BUSY> { 67 62 spi: SPI, 68 63 dc: DC, 69 64 rst: RST, 70 65 busy: BUSY, 71 - framebuffer: [u8; FRAMEBUFFER_SIZE], 72 66 rotation: Rotation, 73 67 power_is_on: bool, 74 68 init_done: bool, 75 69 initial_refresh: bool, 76 - initial_write: bool, 77 70 } 78 71 79 72 impl<SPI, DC, RST, BUSY, E> DisplayDriver<SPI, DC, RST, BUSY> ··· 83 76 RST: OutputPin, 84 77 BUSY: InputPin, 85 78 { 86 - /// Create a new display driver 79 + // Create a new display driver 87 80 pub fn new(spi: SPI, dc: DC, rst: RST, busy: BUSY) -> Self { 88 81 Self { 89 82 spi, 90 83 dc, 91 84 rst, 92 85 busy, 93 - framebuffer: [0xFF; FRAMEBUFFER_SIZE], // White 94 86 rotation: Rotation::Deg270, 95 87 power_is_on: false, 96 88 init_done: false, 97 89 initial_refresh: true, 98 - initial_write: true, 99 90 } 100 91 } 101 92 102 - /// Set display rotation 103 93 pub fn set_rotation(&mut self, rotation: Rotation) { 104 94 self.rotation = rotation; 105 95 } 106 96 107 - /// Get current rotation 108 97 pub fn rotation(&self) -> Rotation { 109 98 self.rotation 110 99 } 111 100 112 - /// Get logical display size (accounts for rotation) 113 101 pub fn size(&self) -> Size { 114 102 match self.rotation { 115 103 Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), ··· 117 105 } 118 106 } 119 107 120 - /// Hardware reset 121 108 pub fn reset(&mut self, delay: &mut Delay) { 122 109 let _ = self.rst.set_high(); 123 110 delay.delay_millis(20); ··· 127 114 delay.delay_millis(20); 128 115 } 129 116 130 - /// Initialize the display 131 117 pub fn init(&mut self, delay: &mut Delay) { 132 118 self.reset(delay); 133 119 self.init_display(delay); 134 120 } 135 121 136 - /// Clear the entire screen to white 137 122 pub fn clear(&mut self, delay: &mut Delay) { 138 - self.framebuffer.fill(0xFF); 139 123 self.clear_screen(delay); 140 124 } 141 125 142 - /// Fill framebuffer only (no display update) 143 - pub fn fill_white(&mut self) { 144 - self.framebuffer.fill(0xFF); 145 - } 146 - 147 - /// Fill framebuffer with black (no display update) 148 - pub fn fill_black(&mut self) { 149 - self.framebuffer.fill(0x00); 150 - } 151 - 152 - /// Set a pixel in the framebuffer using LOGICAL coordinates 153 - /// Coordinates are transformed based on rotation 154 - pub fn set_pixel(&mut self, x: u16, y: u16, black: bool) { 155 - // Get logical dimensions for bounds check 156 - let (log_w, log_h) = match self.rotation { 157 - Rotation::Deg0 | Rotation::Deg180 => (WIDTH, HEIGHT), 158 - Rotation::Deg90 | Rotation::Deg270 => (HEIGHT, WIDTH), 159 - }; 160 - 161 - if x >= log_w || y >= log_h { 162 - return; 163 - } 164 - 165 - // Transform logical → physical coordinates 166 - let (px, py) = match self.rotation { 167 - Rotation::Deg0 => (x, y), 168 - Rotation::Deg90 => (WIDTH - 1 - y, x), 169 - Rotation::Deg180 => (WIDTH - 1 - x, HEIGHT - 1 - y), 170 - Rotation::Deg270 => (y, HEIGHT - 1 - x), 171 - }; 172 - 173 - let idx = (px as usize / 8) + (py as usize * (WIDTH as usize / 8)); 174 - let bit = 7 - (px % 8); 175 - if black { 176 - self.framebuffer[idx] &= !(1 << bit); 177 - } else { 178 - self.framebuffer[idx] |= 1 << bit; 179 - } 180 - } 181 - 182 - /// Fill framebuffer with color (true = black, false = white) 183 - pub fn fill(&mut self, black: bool) { 184 - self.framebuffer.fill(if black { 0x00 } else { 0xFF }); 185 - } 186 - 187 - /// Full screen refresh (use after initial setup or to clear ghosting) 188 - pub fn refresh_full(&mut self, delay: &mut Delay) { 126 + pub fn render_full<F>(&mut self, strip: &mut StripBuffer, delay: &mut Delay, draw: F) 127 + where 128 + F: Fn(&mut StripBuffer), 129 + { 189 130 if !self.init_done { 190 131 self.init_display(delay); 191 132 } 192 133 193 - // Small delay to ensure display is ready 194 134 delay.delay_millis(1); 195 135 196 - // Write to both buffers for full refresh 197 - self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 198 - self.write_full_buffer(cmd::WRITE_RAM_RED); // Previous 199 - 200 - delay.delay_millis(1); // Yield between large transfers 136 + // Write to both display RAM buffers via strips 137 + for &ram_cmd in &[cmd::WRITE_RAM_RED, cmd::WRITE_RAM_BW] { 138 + self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 139 + self.send_command(ram_cmd); 140 + delay.delay_millis(1); 201 141 202 - self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 203 - self.write_full_buffer(cmd::WRITE_RAM_BW); // Current 142 + for i in 0..STRIP_COUNT { 143 + strip.begin_strip(self.rotation, i); 144 + draw(strip); 145 + self.send_data(strip.data()); 146 + } 147 + } 204 148 205 149 self.update_full(delay); 206 150 self.initial_refresh = false; 207 - self.initial_write = false; 208 151 } 209 152 210 - /// Partial screen refresh 211 - /// Takes LOGICAL coordinates 212 - pub fn refresh_partial(&mut self, x: u16, y: u16, w: u16, h: u16, delay: &mut Delay) { 153 + /// Render a partial region and do a partial refresh. 154 + pub fn render_partial<F>( 155 + &mut self, 156 + strip: &mut StripBuffer, 157 + x: u16, 158 + y: u16, 159 + w: u16, 160 + h: u16, 161 + delay: &mut Delay, 162 + draw: F, 163 + ) where 164 + F: Fn(&mut StripBuffer), 165 + { 213 166 // Initial refresh must be full 214 167 if self.initial_refresh { 215 - return self.refresh_full(delay); 168 + return self.render_full(strip, delay, draw); 216 169 } 217 170 218 171 if !self.init_done { ··· 240 193 return; 241 194 } 242 195 243 - // Step 1: Write to current buffer (0x24) only 244 - self.write_image_partial_physical(cmd::WRITE_RAM_BW, px, py, pw, ph); 196 + self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 245 197 246 - // Step 2: Partial refresh 247 198 self.set_partial_ram_area(px, py, pw, ph); 248 199 self.update_partial(delay); 249 200 250 - // Step 3: Sync buffers - write to BOTH previous (0x26) AND current (0x24) 251 - // This is writeImageAgain() in GxEPD2 252 - self.write_image_partial_physical(cmd::WRITE_RAM_RED, px, py, pw, ph); 253 - self.write_image_partial_physical(cmd::WRITE_RAM_BW, px, py, pw, ph); 201 + self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_RED, &draw); 202 + self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 254 203 255 - // Step 4: Power off display controller to save power. 256 - // E-paper retains image without power. Leaving it on draws ~15mA idle. 257 204 self.power_off(delay); 258 205 } 259 206 260 - /// Transform logical region to physical region based on rotation 261 - fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 262 - match self.rotation { 263 - Rotation::Deg0 => (x, y, w, h), 264 - Rotation::Deg90 => { 265 - // Logical (x,y,w,h) in 480x800 → Physical in 800x480 266 - // Logical top-left (x,y) → Physical (WIDTH-1-y, x) 267 - // But we need the physical top-left of the region 268 - (WIDTH - y - h, x, h, w) 269 - } 270 - Rotation::Deg180 => (WIDTH - x - w, HEIGHT - y - h, w, h), 271 - Rotation::Deg270 => (y, HEIGHT - x - w, h, w), 272 - } 273 - } 274 - 275 - /// Refresh a rectangular window (convenience method) 276 - pub fn refresh_window(&mut self, x: u16, y: u16, w: u16, h: u16, delay: &mut Delay) { 277 - self.refresh_partial(x, y, w, h, delay); 207 + // partial refresh with a region tuple 208 + pub fn render_window<F>( 209 + &mut self, 210 + strip: &mut StripBuffer, 211 + x: u16, 212 + y: u16, 213 + w: u16, 214 + h: u16, 215 + delay: &mut Delay, 216 + draw: F, 217 + ) where 218 + F: Fn(&mut StripBuffer), 219 + { 220 + self.render_partial(strip, x, y, w, h, delay, draw); 278 221 } 279 222 280 - /// Power off the display (reduces power consumption, prevents fading) 223 + // Power off the display 281 224 pub fn power_off(&mut self, delay: &mut Delay) { 282 225 if self.power_is_on { 283 226 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); ··· 296 239 self.init_done = false; 297 240 } 298 241 299 - // ========== Private methods ========== 242 + /// Write a physical region to display RAM using strip iteration. 243 + /// Handles regions larger than the strip buffer by splitting into 244 + /// multiple passes with as many rows as fit. 245 + fn write_region_strips<F>( 246 + &mut self, 247 + strip: &mut StripBuffer, 248 + px: u16, 249 + py: u16, 250 + pw: u16, 251 + ph: u16, 252 + ram_cmd: u8, 253 + draw: &F, 254 + ) where 255 + F: Fn(&mut StripBuffer), 256 + { 257 + let max_rows = StripBuffer::max_rows_for_width(pw); 258 + 259 + self.set_partial_ram_area(px, py, pw, ph); 260 + self.send_command(ram_cmd); 261 + 262 + let mut y = py; 263 + while y < py + ph { 264 + let rows = max_rows.min(py + ph - y); 265 + strip.begin_window(self.rotation, px, y, pw, rows); 266 + draw(strip); 267 + self.send_data(strip.data()); 268 + y += rows; 269 + } 270 + } 300 271 301 272 fn init_display(&mut self, delay: &mut Delay) { 302 273 // Software reset ··· 329 300 self.init_done = true; 330 301 } 331 302 303 + // Transform logical region to physical region based on rotation 304 + fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 305 + match self.rotation { 306 + Rotation::Deg0 => (x, y, w, h), 307 + Rotation::Deg90 => { 308 + // Logical (x,y,w,h) in 480x800 → Physical in 800x480 309 + // Logical top-left (x,y) → Physical (WIDTH-1-y, x) 310 + // But we need the physical top-left of the region 311 + (WIDTH - y - h, x, h, w) 312 + } 313 + Rotation::Deg180 => (WIDTH - x - w, HEIGHT - y - h, w, h), 314 + Rotation::Deg270 => (y, HEIGHT - x - w, h, w), 315 + } 316 + } 317 + 332 318 fn set_partial_ram_area(&mut self, x: u16, y: u16, w: u16, h: u16) { 333 319 // Gates are reversed on this display - flip Y 334 320 let y_flipped = HEIGHT - y - h; ··· 372 358 self.init_display(delay); 373 359 } 374 360 375 - // Write white to both buffers 361 + // write white to both buffers 376 362 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 377 363 self.write_screen_buffer(cmd::WRITE_RAM_RED, 0xFF); 378 364 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 379 365 self.write_screen_buffer(cmd::WRITE_RAM_BW, 0xFF); 380 366 381 367 self.update_full(delay); 382 - self.initial_write = false; 383 368 self.initial_refresh = false; 384 369 } 385 370 ··· 395 380 } 396 381 } 397 382 398 - fn write_full_buffer(&mut self, command: u8) { 399 - self.send_command(command); 400 - // Write in normal row order - gate reversal is handled by RAM address setup 401 - // (Y-decrease mode in _setPartialRamArea), NOT by reversing rows here 402 - let bytes_per_row = (WIDTH / 8) as usize; 403 - 404 - // Use a temporary buffer to avoid borrow checker issues 405 - let mut row_buf = [0u8; 256]; 406 - 407 - for row in 0..HEIGHT as usize { 408 - let start = row * bytes_per_row; 409 - // Write row in chunks to avoid issues 410 - for chunk_start in (0..bytes_per_row).step_by(256) { 411 - let chunk_end = (chunk_start + 256).min(bytes_per_row); 412 - let chunk_len = chunk_end - chunk_start; 413 - 414 - // Copy to temp buffer 415 - row_buf[..chunk_len] 416 - .copy_from_slice(&self.framebuffer[start + chunk_start..start + chunk_end]); 417 - self.send_data(&row_buf[..chunk_len]); 418 - } 419 - } 420 - } 421 - 422 - fn write_image_partial_physical(&mut self, command: u8, x: u16, y: u16, w: u16, h: u16) { 423 - self.set_partial_ram_area(x, y, w, h); 424 - self.send_command(command); 425 - 426 - let bytes_per_row = (WIDTH / 8) as usize; 427 - let window_bytes = (w / 8) as usize; 428 - let x_byte = (x / 8) as usize; 429 - 430 - // Use a temporary buffer to avoid borrow checker issues 431 - // Max window width is 800/8 = 100 bytes per row 432 - let mut row_buf = [0u8; 100]; 433 - 434 - // Write rows in NORMAL order (row 0, 1, 2, ...) 435 - // Gate reversal is handled by the RAM address setup, not here! 436 - // x, y, w, h are PHYSICAL coordinates 437 - for row in 0..h as usize { 438 - let src_row = y as usize + row; 439 - let src_start = src_row * bytes_per_row + x_byte; 440 - 441 - // Copy to temp buffer 442 - row_buf[..window_bytes] 443 - .copy_from_slice(&self.framebuffer[src_start..src_start + window_bytes]); 444 - self.send_data(&row_buf[..window_bytes]); 445 - } 446 - } 447 - 448 383 fn update_full(&mut self, delay: &mut Delay) { 449 - // Display Update Control 1: bypass RED as 0 450 384 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 451 385 self.send_data(&[0x40, 0x00]); 452 386 453 - // Use standard full refresh (0xF7) - more reliable than fast mode (0xD7) 454 - // Fast mode requires temperature register which may not work on all panels 455 387 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 456 388 self.send_data(&[0xF7]); 457 389 ··· 462 394 } 463 395 464 396 fn update_partial(&mut self, delay: &mut Delay) { 465 - // Display Update Control 1: RED normal 466 397 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 467 398 self.send_data(&[0x00, 0x00]); 468 399 ··· 499 430 } 500 431 501 432 // embedded-graphics integration 433 + // NOTE: size queriies only, drawing goes through StripBuffer 502 434 503 435 impl<SPI, DC, RST, BUSY, E> OriginDimensions for DisplayDriver<SPI, DC, RST, BUSY> 504 436 where ··· 514 446 } 515 447 } 516 448 } 517 - 518 - impl<SPI, DC, RST, BUSY, E> DrawTarget for DisplayDriver<SPI, DC, RST, BUSY> 519 - where 520 - SPI: SpiDevice<Error = E>, 521 - DC: OutputPin, 522 - RST: OutputPin, 523 - BUSY: InputPin, 524 - { 525 - type Color = BinaryColor; 526 - type Error = core::convert::Infallible; 527 - 528 - fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error> 529 - where 530 - I: IntoIterator<Item = Pixel<Self::Color>>, 531 - { 532 - let size = self.size(); 533 - let log_w = size.width as i32; 534 - let log_h = size.height as i32; 535 - 536 - for Pixel(coord, color) in pixels { 537 - // Bounds check against LOGICAL dimensions 538 - if coord.x >= 0 && coord.x < log_w && coord.y >= 0 && coord.y < log_h { 539 - self.set_pixel( 540 - coord.x as u16, 541 - coord.y as u16, 542 - color == BinaryColor::On, // On = black (foreground), Off = white (background) 543 - ); 544 - } 545 - } 546 - Ok(()) 547 - } 548 - }
+3 -1
src/board/mod.rs
··· 7 7 pub mod button; 8 8 pub mod display; 9 9 pub mod pins; 10 + pub mod strip; 10 11 11 12 pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 12 - pub use display::{DisplayDriver, FRAMEBUFFER_SIZE, HEIGHT, SPI_FREQ_MHZ, WIDTH}; 13 + pub use display::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH}; 14 + pub use strip::StripBuffer; 13 15 14 16 use embedded_hal_bus::spi::ExclusiveDevice; 15 17 use esp_hal::{
+209
src/board/strip.rs
··· 1 + // Strip-based rendering buffer 2 + // 3 + // Instead of holding a full 48KB framebuffer in SRAM, we render 4 + // through a small strip buffer (~4KB) and stream each strip to 5 + // the display controller via SPI. 6 + // The display is divided into horizontal bands of physical rows. 7 + // For each band: 8 + // 1. Clear the strip buffer to white 9 + // 2. Draw all widgets (DrawTarget clips to current band) 10 + // 3. SPI transfer the strip data to display controller RAM 11 + // 4. Reuse the buffer for the next band 12 + 13 + use embedded_graphics_core::{ 14 + Pixel, 15 + draw_target::DrawTarget, 16 + geometry::{OriginDimensions, Size}, 17 + pixelcolor::BinaryColor, 18 + }; 19 + 20 + use super::display::{HEIGHT, Rotation, WIDTH}; 21 + 22 + // Physical rows per strip for full-page rendering. 23 + // 480 / 40 = 12 strips, each 100 bytes/row × 40 rows = 4000 bytes. 24 + pub const STRIP_ROWS: u16 = 40; 25 + pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8; // 100 26 + 27 + pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000 28 + pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12 29 + 30 + 31 + // A small rendering buffer that covers a physical rectangle of the display. 32 + // 33 + // Operates in two modes: 34 + // - Full-width strips: For full-page rendering. Covers 800×40 physical 35 + // pixels (4000 bytes). Iterate 12 strips top-to-bottom. 36 + // - Windowed: For partial refresh of small UI regions. Covers an 37 + // arbitrary physical rectangle that fits within STRIP_BUF_SIZE bytes. 38 + pub struct StripBuffer { 39 + buf: [u8; STRIP_BUF_SIZE], 40 + rotation: Rotation, 41 + // Physical window this strip covers 42 + win_x: u16, 43 + win_y: u16, 44 + win_w: u16, 45 + win_h: u16, 46 + // Derived from win_w for indexing 47 + row_bytes: u16, 48 + } 49 + 50 + impl StripBuffer { 51 + pub const fn new() -> Self { 52 + Self { 53 + buf: [0xFF; STRIP_BUF_SIZE], // White 54 + rotation: Rotation::Deg270, 55 + win_x: 0, 56 + win_y: 0, 57 + win_w: WIDTH, 58 + win_h: STRIP_ROWS, 59 + row_bytes: (WIDTH / 8), 60 + } 61 + } 62 + 63 + // Configure for full-width strip rendering at the given strip index. 64 + // Clears the buffer to white. 65 + // 66 + // strip_idx: 0..STRIP_COUNT (physical row bands top-to-bottom) 67 + pub fn begin_strip(&mut self, rotation: Rotation, strip_idx: u16) { 68 + self.rotation = rotation; 69 + self.win_x = 0; 70 + self.win_y = strip_idx * STRIP_ROWS; 71 + self.win_w = WIDTH; 72 + self.win_h = STRIP_ROWS; 73 + self.row_bytes = PHYS_BYTES_PER_ROW as u16; 74 + 75 + // Clear to white 76 + self.buf[..STRIP_BUF_SIZE].fill(0xFF); 77 + } 78 + 79 + // Configure for an arbitrary physical window (partial refresh mode). 80 + // Region must be byte-aligned (x and w multiples of 8). 81 + // NOTE: Panics if the window doesn't fit in STRIP_BUF_SIZE. 82 + pub fn begin_window(&mut self, rotation: Rotation, x: u16, y: u16, w: u16, h: u16) { 83 + let rb = (w / 8) as usize; 84 + let total = rb * h as usize; 85 + assert!( 86 + total <= STRIP_BUF_SIZE, 87 + "partial region {}×{} = {} bytes exceeds strip buffer ({})", 88 + w, 89 + h, 90 + total, 91 + STRIP_BUF_SIZE, 92 + ); 93 + 94 + self.rotation = rotation; 95 + self.win_x = x; 96 + self.win_y = y; 97 + self.win_w = w; 98 + self.win_h = h; 99 + self.row_bytes = rb as u16; 100 + 101 + self.buf[..total].fill(0xFF); 102 + } 103 + 104 + // Get the valid data bytes for SPI transfer. 105 + // Only the bytes covering the current window are returned. 106 + pub fn data(&self) -> &[u8] { 107 + let total = self.row_bytes as usize * self.win_h as usize; 108 + &self.buf[..total] 109 + } 110 + 111 + // Current window's physical origin and size. 112 + pub fn window(&self) -> (u16, u16, u16, u16) { 113 + (self.win_x, self.win_y, self.win_w, self.win_h) 114 + } 115 + 116 + pub const fn strip_count() -> u16 { 117 + STRIP_COUNT 118 + } 119 + 120 + // Max rows that fit in the buffer at a given window width. 121 + pub fn max_rows_for_width(width: u16) -> u16 { 122 + let rb = (width / 8) as usize; 123 + if rb == 0 { 124 + return 0; 125 + } 126 + (STRIP_BUF_SIZE / rb) as u16 127 + } 128 + 129 + // Transform logical coordinates to physical based on rotation. 130 + #[inline] 131 + fn to_physical(&self, lx: u16, ly: u16) -> (u16, u16) { 132 + match self.rotation { 133 + Rotation::Deg0 => (lx, ly), 134 + Rotation::Deg90 => (WIDTH - 1 - ly, lx), 135 + Rotation::Deg180 => (WIDTH - 1 - lx, HEIGHT - 1 - ly), 136 + Rotation::Deg270 => (ly, HEIGHT - 1 - lx), 137 + } 138 + } 139 + 140 + // set a pixel in the buffer using physical coordinates. 141 + // silently clips if outside current window. 142 + #[inline] 143 + fn set_pixel_physical(&mut self, px: u16, py: u16, black: bool) { 144 + // clip to window 145 + if px < self.win_x || px >= self.win_x + self.win_w { 146 + return; 147 + } 148 + if py < self.win_y || py >= self.win_y + self.win_h { 149 + return; 150 + } 151 + 152 + let local_x = (px - self.win_x) as usize; 153 + let local_y = (py - self.win_y) as usize; 154 + let idx = (local_x / 8) + (local_y * self.row_bytes as usize); 155 + let bit = 7 - (local_x as u16 % 8); 156 + 157 + if black { 158 + self.buf[idx] &= !(1 << bit); 159 + } else { 160 + self.buf[idx] |= 1 << bit; 161 + } 162 + } 163 + } 164 + 165 + impl Default for StripBuffer { 166 + fn default() -> Self { 167 + Self::new() 168 + } 169 + } 170 + 171 + // embedded-graphics integration 172 + 173 + impl OriginDimensions for StripBuffer { 174 + // Report FULL logical display size. 175 + // Widgets think they're drawing to the entire screen; 176 + // the strip clips at the physical level. 177 + fn size(&self) -> Size { 178 + match self.rotation { 179 + Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), 180 + Rotation::Deg90 | Rotation::Deg270 => Size::new(HEIGHT as u32, WIDTH as u32), 181 + } 182 + } 183 + } 184 + 185 + impl DrawTarget for StripBuffer { 186 + type Color = BinaryColor; 187 + type Error = core::convert::Infallible; 188 + 189 + fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error> 190 + where 191 + I: IntoIterator<Item = Pixel<Self::Color>>, 192 + { 193 + let size = self.size(); 194 + let log_w = size.width as i32; 195 + let log_h = size.height as i32; 196 + 197 + for Pixel(coord, color) in pixels { 198 + // Bounds check against logical dimensions 199 + if coord.x < 0 || coord.x >= log_w || coord.y < 0 || coord.y >= log_h { 200 + continue; 201 + } 202 + 203 + // Transform logical → physical, then clip to current strip 204 + let (px, py) = self.to_physical(coord.x as u16, coord.y as u16); 205 + self.set_pixel_physical(px, py, color == BinaryColor::On); 206 + } 207 + Ok(()) 208 + } 209 + }