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.

greatly improve comments

hansmrtn b871684f 757e5a2e

+268 -1571
-2
.cargo/config.toml
··· 6 6 7 7 [build] 8 8 rustflags = [ 9 - # Required to obtain backtraces (e.g. when using the "esp-backtrace" crate.) 10 - # NOTE: May negatively impact performance of produced code 11 9 "-C", "force-frame-pointers", 12 10 ] 13 11
+1 -3
Cargo.toml
··· 33 33 34 34 35 35 [profile.dev] 36 - # Rust debug is too slow. 37 - # For debug builds always builds with some optimization 38 36 opt-level = "s" 39 37 40 38 [profile.release] 41 - codegen-units = 1 # LLVM can perform better optimizations using a single thread 39 + codegen-units = 1 42 40 debug = 2 43 41 debug-assertions = false 44 42 incremental = false
-2
build.rs
··· 1 1 fn main() { 2 2 linker_be_nice(); 3 - // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link) 4 3 println!("cargo:rustc-link-arg=-Tlinkall.x"); 5 4 } 6 5 ··· 54 53 } 55 54 _ => (), 56 55 }, 57 - // we don't have anything helpful for "missing-lib" yet 58 56 _ => { 59 57 std::process::exit(1); 60 58 }
+13 -64
src/apps/files.rs
··· 1 - //! File browser app. 2 - //! 3 - //! Displays a paginated list of files from the SD card root directory. 4 - //! Up/Down to navigate, Confirm to open files, Back to return home. 5 - //! 6 - //! Storage access pattern: FilesApp doesn't touch hardware directly. 7 - //! It sets `needs_load = true`, and main.rs calls `load_page()` with 8 - //! data from the storage driver. 9 - //! 10 - //! ## Render ownership 11 - //! 12 - //! Two kinds of visual updates: 13 - //! 14 - //! - **Within-page scroll**: selection highlight moves, data unchanged. 15 - //! `move_up`/`move_down` call `ctx.mark_dirty()` on the old and new 16 - //! rows. The framework coalesces them (union of ~100px vs full 364px 17 - //! list — 3.8× less SPI data, less visible flicker). 18 - //! 19 - //! - **Page-boundary scroll**: new data needed from cache. 20 - //! Sets `needs_load = true` only (no mark_dirty). The LoadDirectory 21 - //! job owns the render decision — it fires after data arrives and 22 - //! requests a partial redraw of LIST_REGION. 1 + // Paginated file browser for SD card root directory 2 + // 3 + // Scrolling within a page marks two rows dirty (old + new selection). 4 + // Scrolling across a page boundary sets needs_load; the kernel runs 5 + // AppWork which reads from DirCache and owns the render decision. 23 6 24 - use embedded_graphics::mono_font::ascii::{FONT_10X20, FONT_6X13}; 7 + use embedded_graphics::Drawable; 8 + use embedded_graphics::mono_font::ascii::{FONT_6X13, FONT_10X20}; 25 9 use embedded_graphics::prelude::Primitive; 26 - use embedded_graphics::Drawable; 27 10 28 11 use crate::apps::{App, AppContext, AppId, Services, Transition}; 29 12 use crate::board::button::Button as HwButton; ··· 32 15 use crate::drivers::storage::DirEntry; 33 16 use crate::ui::{Alignment, Button as UiButton, CONTENT_TOP, DynamicLabel, Label, Region, Widget}; 34 17 35 - /// How many entries fit on screen at once. 36 18 const PAGE_SIZE: usize = 7; 37 19 38 - /// Layout — all Y values relative to CONTENT_TOP. 39 20 const HEADER_REGION: Region = Region::new(16, CONTENT_TOP + 4, 300, 28); 40 21 const STATUS_REGION: Region = Region::new(320, CONTENT_TOP + 4, 140, 28); 41 22 42 23 const LIST_Y: u16 = CONTENT_TOP + 40; 43 24 const ROW_H: u16 = 52; 44 25 45 - /// The scrollable file list region. 46 26 const LIST_REGION: Region = Region::new(8, LIST_Y, 464, ROW_H * PAGE_SIZE as u16); 47 27 48 28 fn row_region(index: usize) -> Region { ··· 56 36 scroll: usize, 57 37 selected: usize, 58 38 needs_load: bool, 59 - /// Set on fresh entry — tells on_work to invalidate the dir cache 60 - /// before loading, since SD contents may have changed. 61 39 stale_cache: bool, 62 40 error: Option<&'static str>, 63 41 } ··· 104 82 105 83 fn move_up(&mut self, ctx: &mut AppContext) { 106 84 if self.selected > 0 { 107 - // Within-page: mark old and new rows dirty. 108 85 ctx.mark_dirty(row_region(self.selected)); 109 86 self.selected -= 1; 110 87 ctx.mark_dirty(row_region(self.selected)); 111 88 } else if self.scroll > 0 { 112 - // Page boundary: need fresh data from cache. 113 - // LoadDirectory owns the render — don't mark dirty here. 114 89 self.scroll = self.scroll.saturating_sub(1); 115 90 self.needs_load = true; 116 91 } ··· 118 93 119 94 fn move_down(&mut self, ctx: &mut AppContext) { 120 95 if self.selected + 1 < self.count { 121 - // Within-page: mark old and new rows dirty. 122 96 ctx.mark_dirty(row_region(self.selected)); 123 97 self.selected += 1; 124 98 ctx.mark_dirty(row_region(self.selected)); 125 99 } else if self.scroll + self.count < self.total { 126 - // Page boundary: need fresh data from cache. 127 - // LoadDirectory owns the render — don't mark dirty here. 128 100 self.scroll += 1; 129 101 self.needs_load = true; 130 102 } ··· 136 108 self.scroll = 0; 137 109 self.selected = 0; 138 110 self.needs_load = true; 139 - self.stale_cache = true; // SD may have changed since last visit 111 + self.stale_cache = true; 140 112 self.error = None; 141 - // Full redraw for header/chrome. AppWork will populate the 142 - // list and inherit this Full request (partial won't downgrade 143 - // it — see AppContext::request_partial_redraw). 144 113 ctx.request_full_redraw(); 145 114 } 146 115 ··· 148 117 self.count = 0; 149 118 } 150 119 151 - /// Pushed behind a child app (e.g. Reader). Preserve scroll 152 - /// position and cached entries — don't clear count. 153 - fn on_suspend(&mut self) { 154 - // no-op: entries, scroll, selected all stay valid 155 - } 120 + fn on_suspend(&mut self) {} 156 121 157 - /// Returning from a child app. Entries are still cached, 158 - /// just repaint. No SD reload needed. 159 122 fn on_resume(&mut self, ctx: &mut AppContext) { 160 123 ctx.request_full_redraw(); 161 124 } ··· 229 192 } 230 193 231 194 fn draw(&self, strip: &mut StripBuffer) { 232 - // Header 233 195 Label::new(HEADER_REGION, "Files", &FONT_10X20) 234 196 .alignment(Alignment::CenterLeft) 235 197 .draw(strip) 236 198 .unwrap(); 237 199 238 - // Status (page indicator) 239 200 if self.total > 0 { 240 201 let mut status = DynamicLabel::<20>::new(STATUS_REGION, &FONT_6X13) 241 202 .alignment(Alignment::CenterRight); 242 203 use core::fmt::Write; 243 - let _ = write!( 244 - status, 245 - "{}/{}", 246 - self.scroll + self.selected + 1, 247 - self.total 248 - ); 204 + let _ = write!(status, "{}/{}", self.scroll + self.selected + 1, self.total); 249 205 status.draw(strip).unwrap(); 250 206 } 251 207 252 - // Error state 253 208 if let Some(msg) = self.error { 254 209 Label::new(row_region(0), msg, &FONT_10X20) 255 210 .alignment(Alignment::CenterLeft) ··· 258 213 return; 259 214 } 260 215 261 - // Empty state 262 216 if self.count == 0 && !self.needs_load { 263 217 Label::new(row_region(0), "No files found", &FONT_10X20) 264 218 .alignment(Alignment::CenterLeft) ··· 267 221 return; 268 222 } 269 223 270 - // File list 271 224 for i in 0..PAGE_SIZE { 272 225 let region = row_region(i); 273 226 ··· 281 234 } 282 235 btn.draw(strip).unwrap(); 283 236 } else { 284 - // Clear empty rows 285 237 region 286 238 .to_rect() 287 - .into_styled( 288 - embedded_graphics::primitives::PrimitiveStyle::with_fill( 289 - embedded_graphics::pixelcolor::BinaryColor::Off, 290 - ), 291 - ) 239 + .into_styled(embedded_graphics::primitives::PrimitiveStyle::with_fill( 240 + embedded_graphics::pixelcolor::BinaryColor::Off, 241 + )) 292 242 .draw(strip) 293 243 .unwrap(); 294 244 } 295 245 } 296 246 } 297 - 298 247 }
+21 -13
src/apps/home.rs
··· 1 - //! Home screen — the app launcher. 1 + // Launcher screen, entry point after boot 2 2 3 3 use embedded_graphics::mono_font::ascii::FONT_10X20; 4 4 5 5 use crate::apps::{App, AppContext, AppId, Transition}; 6 + use crate::board::button::Button as HwButton; 6 7 use crate::board::strip::StripBuffer; 7 8 use crate::drivers::input::Event; 8 - use crate::board::button::Button as HwButton; 9 9 use crate::ui::{Alignment, CONTENT_TOP, Label, Region, Widget}; 10 10 11 11 const TITLE_REGION: Region = Region::new(16, CONTENT_TOP, 200, 32); ··· 22 22 } 23 23 24 24 const ITEMS: &[MenuItem] = &[ 25 - MenuItem { region: Region::new(16, ITEM_Y, 200, ITEM_H), name: "Files", app: AppId::Files }, 26 - MenuItem { region: Region::new(16, ITEM_Y + ITEM_STRIDE, 200, ITEM_H), name: "Reader", app: AppId::Reader }, 27 - MenuItem { region: Region::new(16, ITEM_Y + ITEM_STRIDE * 2, 200, ITEM_H), name: "Settings", app: AppId::Settings }, 25 + MenuItem { 26 + region: Region::new(16, ITEM_Y, 200, ITEM_H), 27 + name: "Files", 28 + app: AppId::Files, 29 + }, 30 + MenuItem { 31 + region: Region::new(16, ITEM_Y + ITEM_STRIDE, 200, ITEM_H), 32 + name: "Reader", 33 + app: AppId::Reader, 34 + }, 35 + MenuItem { 36 + region: Region::new(16, ITEM_Y + ITEM_STRIDE * 2, 200, ITEM_H), 37 + name: "Settings", 38 + app: AppId::Settings, 39 + }, 28 40 ]; 29 41 30 42 pub struct HomeApp { ··· 33 45 34 46 impl HomeApp { 35 47 pub const fn new() -> Self { 36 - Self { 37 - selected: 0, 38 - } 48 + Self { selected: 0 } 39 49 } 40 50 41 51 fn item_count(&self) -> usize { ··· 69 79 self.move_selection(-1, ctx); 70 80 Transition::None 71 81 } 72 - Event::Press(HwButton::Confirm) => { 73 - Transition::Push(ITEMS[self.selected].app) 74 - } 82 + Event::Press(HwButton::Confirm) => Transition::Push(ITEMS[self.selected].app), 75 83 _ => Transition::None, 76 84 } 77 85 } 78 86 79 87 fn draw(&self, strip: &mut StripBuffer) { 80 - let title = Label::new(TITLE_REGION, "pulp-os", &FONT_10X20) 81 - .alignment(Alignment::CenterLeft); 88 + let title = 89 + Label::new(TITLE_REGION, "pulp-os", &FONT_10X20).alignment(Alignment::CenterLeft); 82 90 title.draw(strip).unwrap(); 83 91 84 92 for (i, item) in ITEMS.iter().enumerate() {
+31 -180
src/apps/mod.rs
··· 1 - //! App framework for pulp-os 2 - //! 3 - //! Apps are self-contained screen-owning modules. The launcher manages 4 - //! transitions between them and routes input events. 5 - //! 6 - //! # Writing an app 7 - //! 8 - //! ```ignore 9 - //! pub struct MyApp { /* state */ } 10 - //! 11 - //! impl App for MyApp { 12 - //! fn on_enter(&mut self, ctx: &mut AppContext) { 13 - //! ctx.request_full_redraw(); 14 - //! } 15 - //! 16 - //! fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition { 17 - //! match event { 18 - //! Event::Press(Button::Back) => Transition::Pop, 19 - //! Event::Press(Button::Right) => { 20 - //! self.selected += 1; 21 - //! ctx.mark_dirty(old_region); 22 - //! ctx.mark_dirty(new_region); 23 - //! Transition::None 24 - //! } 25 - //! _ => Transition::None, 26 - //! } 27 - //! } 28 - //! 29 - //! fn draw(&self, strip: &mut StripBuffer) { 30 - //! // Draw widgets — called per-strip during refresh 31 - //! } 32 - //! 33 - //! // For apps with async I/O: 34 - //! fn needs_work(&self) -> bool { self.needs_load } 35 - //! 36 - //! fn on_work<SPI: embedded_hal::spi::SpiDevice>( 37 - //! &mut self, svc: &mut Services<'_, SPI>, ctx: &mut AppContext, 38 - //! ) { 39 - //! // Use svc.dir_page(), svc.read_file_chunk(), etc. 40 - //! // Then ctx.mark_dirty() for changed regions. 41 - //! } 42 - //! } 43 - //! ``` 1 + // Application framework and launcher 2 + // 3 + // Apps are stack allocated structs behind the App trait. The Launcher 4 + // holds a fixed depth navigation stack (max 4) and an AppContext for 5 + // inter-app messaging and redraw requests. No dyn dispatch, no heap. 6 + // 7 + // Lifecycle: on_enter -> on_event* -> on_suspend/on_exit 8 + // on_resume -> on_event* -> on_exit 9 + // 10 + // Async I/O: apps that need SD access return needs_work() = true. 11 + // The kernel calls on_work() with a Services handle before rendering. 12 + // This prevents stale renders (the "render ownership invariant"): 13 + // if needs_work() is true, PollInput will not enqueue Render. 14 + // 15 + // Services is the syscall boundary. Apps never touch SPI or caches 16 + // directly. Generic over SPI so board types do not leak in. 44 17 45 18 pub mod files; 46 19 pub mod home; ··· 53 26 use crate::drivers::storage::{self, DirCache, DirEntry, DirPage}; 54 27 use crate::ui::Region; 55 28 56 - /// Identity of each app in the system. 57 29 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 58 30 pub enum AppId { 59 31 Home, ··· 62 34 Settings, 63 35 } 64 36 65 - /// What should happen after handling an event. 37 + // Push: new app on top, old app suspended (gets on_resume later) 38 + // Pop: return to parent, current app exits 39 + // Replace: swap in place, no back navigation 40 + // Home: unwind entire stack back to Home 66 41 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 67 42 pub enum Transition { 68 - /// Stay on current app, no action. 69 43 None, 70 - /// Push a new app onto the stack (current stays underneath). 71 44 Push(AppId), 72 - /// Pop current app, return to the one below. 73 45 Pop, 74 - /// Replace current app entirely (no back navigation). 75 46 Replace(AppId), 76 - /// Pop all the way back to Home. 77 47 Home, 78 48 } 79 49 80 - /// Redraw request from an app. 81 50 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 51 pub enum Redraw { 83 - /// Nothing to draw. 84 52 None, 85 - /// Partial refresh of a specific region. 86 53 Partial(Region), 87 - /// Full screen refresh needed (e.g. on first enter). 88 54 Full, 89 55 } 90 56 91 - /// Small message buffer for passing data between apps. 92 - /// e.g. file browser sets a path, reader reads it on entry. 57 + // 64 bytes is enough for an 8.3 filename or short path 93 58 const MSG_BUF_SIZE: usize = 64; 94 59 95 - /// Shared context passed to apps. Gives access to cross-app 96 - /// communication without apps needing to know about each other. 97 60 pub struct AppContext { 98 - /// Message buffer for inter-app data (file path, etc.) 99 61 msg_buf: [u8; MSG_BUF_SIZE], 100 62 msg_len: usize, 101 - /// Redraw request set by the app 102 63 redraw: Redraw, 103 64 } 104 65 ··· 111 72 } 112 73 } 113 74 114 - /// Set a message for the next app (e.g. a file path to open). 115 75 pub fn set_message(&mut self, data: &[u8]) { 116 76 let len = data.len().min(MSG_BUF_SIZE); 117 77 self.msg_buf[..len].copy_from_slice(&data[..len]); 118 78 self.msg_len = len; 119 79 } 120 80 121 - /// Read the message left by the previous app. 122 81 pub fn message(&self) -> &[u8] { 123 82 &self.msg_buf[..self.msg_len] 124 83 } 125 84 126 - /// Read message as UTF-8 string. 127 85 pub fn message_str(&self) -> &str { 128 86 core::str::from_utf8(self.message()).unwrap_or("") 129 87 } 130 88 131 - /// Clear the message buffer. 132 89 pub fn clear_message(&mut self) { 133 90 self.msg_len = 0; 134 91 } 135 92 136 - /// Request a full screen redraw. 137 93 pub fn request_full_redraw(&mut self) { 138 94 self.redraw = Redraw::Full; 139 95 } 140 96 141 - /// Request a partial redraw of a region. 142 - /// If a partial is already pending, coalesces via bounding box union. 143 97 pub fn request_partial_redraw(&mut self, region: Region) { 144 98 match self.redraw { 145 99 Redraw::Full => {} ··· 150 104 } 151 105 } 152 106 153 - /// Mark a region as needing redraw. Apps call this during `on_event` 154 - /// for each UI element that changed — the framework coalesces 155 - /// multiple calls via bounding-box union automatically. 156 - /// 157 - /// This is the primary way apps request visual updates. Prefer 158 - /// marking individual changed elements over full-screen redraws 159 - /// to minimize e-paper flicker and SPI transfer. 107 + // primary way apps request visual updates; coalesces via bounding box 160 108 #[inline] 161 109 pub fn mark_dirty(&mut self, region: Region) { 162 110 self.request_partial_redraw(region); ··· 166 114 !matches!(self.redraw, Redraw::None) 167 115 } 168 116 169 - /// Take the pending redraw request (resets to None). 170 117 pub fn take_redraw(&mut self) -> Redraw { 171 118 let r = self.redraw; 172 119 self.redraw = Redraw::None; ··· 174 121 } 175 122 } 176 123 177 - // ── Services ─────────────────────────────────────────────────── 178 - // 179 - // The app-OS boundary. Apps request hardware operations through 180 - // Services methods — they never touch SPI, SD, or caches directly. 181 - // 182 - // Constructed by the kernel per-job, borrowing the long-lived 183 - // state from main(). Zero-cost: just assembles two references. 184 - // 185 - // Generic over SPI so no concrete board types leak into the app 186 - // framework. The compiler monomorphizes to the real type via 187 - // with_app! dispatch in main.rs. 188 - 189 - /// OS services available to apps during `on_work`. 190 - /// 191 - /// This is the "syscall interface". Apps get high-level operations 192 - /// (load a directory page, read a file chunk) without knowing about 193 - /// SPI buses, SD protocols, or caching strategies. 194 124 pub struct Services<'a, SPI: embedded_hal::spi::SpiDevice> { 195 125 dir_cache: &'a mut DirCache, 196 126 sd: &'a SdStorage<SPI>, 197 127 } 198 128 199 129 impl<'a, SPI: embedded_hal::spi::SpiDevice> Services<'a, SPI> { 200 - /// Construct Services. Called by the kernel at the start of AppWork. 201 130 pub fn new(dir_cache: &'a mut DirCache, sd: &'a SdStorage<SPI>) -> Self { 202 131 Self { dir_cache, sd } 203 132 } 204 133 205 - /// Load a page of directory entries from the root directory. 206 - /// 207 - /// Uses an internal cache — first call reads from SD (~100ms), 208 - /// subsequent calls are pure memory copies (instant). 209 134 pub fn dir_page( 210 135 &mut self, 211 136 offset: usize, ··· 215 140 Ok(self.dir_cache.page(offset, buf)) 216 141 } 217 142 218 - /// Mark the directory cache as stale. Next `dir_page()` will 219 - /// re-read from SD. Call this when the SD card contents may 220 - /// have changed (e.g. fresh app entry). 221 143 pub fn invalidate_dir_cache(&mut self) { 222 144 self.dir_cache.invalidate(); 223 145 } 224 146 225 - /// Read a chunk of a file starting at `offset`. 226 - /// Returns the number of bytes read into `buf`. 227 147 pub fn read_file_chunk( 228 148 &self, 229 149 name: &str, ··· 233 153 storage::read_file_chunk(self.sd, name, offset, buf) 234 154 } 235 155 236 - /// Get the size of a file in the root directory. 237 156 pub fn file_size(&self, name: &str) -> Result<u32, &'static str> { 238 157 storage::file_size(self.sd, name) 239 158 } 240 159 } 241 160 242 - /// Core app trait. Each app implements this. 243 - /// 244 - /// Apps are statically allocated and own their UI state. 245 - /// They don't hold references to hardware — that's passed 246 - /// in via the draw callback and main loop. 247 - /// 248 - /// ## Lifecycle 249 - /// 250 - /// - `on_enter` — first activation, or replacing another app. 251 - /// Call `ctx.request_full_redraw()`. 252 - /// - `on_exit` — permanently leaving (Pop, Replace, Home). 253 - /// - `on_suspend` — being pushed behind a child app. Override to 254 - /// preserve state (default: delegates to `on_exit`). 255 - /// - `on_resume` — returning from a child app. Override to skip 256 - /// expensive reinit (default: delegates to `on_enter`). 257 - /// 258 - /// ## Async work 259 - /// 260 - /// Some apps need deferred I/O (loading files, reading SD). 261 - /// Override `needs_work()` to return true when work is pending, 262 - /// and `on_work()` to perform it using OS `Services`. The kernel 263 - /// schedules this as a generic `AppWork` job — no app-specific 264 - /// job variants or handlers needed. 265 161 pub trait App { 266 - /// Called when this app becomes the active screen for the first time 267 - /// (or via Replace). Read `ctx.message()` for data from the launcher. 268 162 fn on_enter(&mut self, ctx: &mut AppContext); 269 - 270 - /// Called when this app is permanently removed from the stack. 271 - fn on_exit(&mut self) { 272 - // Default: no-op 273 - } 274 - 275 - /// Called when a child app is pushed on top. 276 - /// The app stays on the stack and will get `on_resume` when the 277 - /// child pops. Override to preserve state; default delegates to `on_exit`. 163 + fn on_exit(&mut self) {} 278 164 fn on_suspend(&mut self) { 279 165 self.on_exit(); 280 166 } 281 - 282 - /// Called when returning from a child app (the child popped). 283 - /// Override to skip reloading data that's still valid. 284 - /// Default delegates to `on_enter`. 285 167 fn on_resume(&mut self, ctx: &mut AppContext) { 286 168 self.on_enter(ctx); 287 169 } 288 - 289 - /// Handle an input event. Return a transition to navigate. 290 - /// 291 - /// Call `ctx.mark_dirty(region)` for each UI element that changed. 292 - /// The framework coalesces multiple dirty regions automatically. 293 170 fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition; 294 171 295 - /// Draw the app's UI into the strip buffer. 296 - /// Called once per strip during refresh — widgets clip automatically. 172 + // called once per strip during refresh; widgets clip automatically 297 173 fn draw(&self, strip: &mut StripBuffer); 298 174 299 - /// Does this app have async work pending? 300 - /// 301 - /// Called after every event to decide whether to enqueue `AppWork`. 302 - /// When true, `on_work()` will be called before the next render — 303 - /// this preserves the render ownership invariant (no stale renders). 304 175 fn needs_work(&self) -> bool { 305 176 false 306 177 } 307 - 308 - /// Perform async work using OS services. 309 - /// 310 - /// Called by the kernel when the `AppWork` job fires. Use `services` 311 - /// for I/O (directory listing, file reads) and `ctx.mark_dirty()` 312 - /// to request a render of changed regions. 313 - /// 314 - /// The kernel handles render scheduling — just mark what changed. 315 178 fn on_work<SPI: embedded_hal::spi::SpiDevice>( 316 179 &mut self, 317 180 _services: &mut Services<'_, SPI>, 318 181 _ctx: &mut AppContext, 319 182 ) { 320 - // Default: no-op 321 183 } 322 184 } 323 185 324 - /// App navigation stack. Fixed-size, no heap. 325 186 const MAX_STACK_DEPTH: usize = 4; 326 187 327 - /// Describes which lifecycle methods to call after a navigation. 328 188 #[derive(Debug, Clone, Copy)] 329 189 pub struct NavEvent { 330 190 pub from: AppId, 331 191 pub to: AppId, 332 - /// If true, `from` was suspended (still on stack → call `on_suspend`). 333 - /// If false, `from` was removed (→ call `on_exit`). 334 192 pub suspend: bool, 335 - /// If true, `to` was already on the stack (→ call `on_resume`). 336 - /// If false, `to` is freshly entered (→ call `on_enter`). 337 193 pub resume: bool, 338 194 } 339 195 ··· 347 203 pub const fn new() -> Self { 348 204 Self { 349 205 stack: [AppId::Home; MAX_STACK_DEPTH], 350 - depth: 1, // Start with Home 206 + depth: 1, 351 207 ctx: AppContext::new(), 352 208 } 353 209 } 354 210 355 - /// Currently active app. 356 211 pub fn active(&self) -> AppId { 357 212 self.stack[self.depth - 1] 358 213 } 359 214 360 - /// Apply a transition. Returns a `NavEvent` if an app switch 361 - /// occurred, so the main loop can call the correct lifecycle methods. 362 215 pub fn apply(&mut self, transition: Transition) -> Option<NavEvent> { 363 216 let old = self.active(); 364 217 ··· 367 220 368 221 Transition::Push(id) => { 369 222 if self.depth >= MAX_STACK_DEPTH { 370 - // Stack full — can't preserve the old app, must replace. 371 223 self.stack[self.depth - 1] = id; 372 - (false, false) // exit old, enter new 224 + (false, false) 373 225 } else { 374 226 self.stack[self.depth] = id; 375 227 self.depth += 1; 376 - (true, false) // suspend old, enter new 228 + (true, false) 377 229 } 378 230 } 379 231 380 232 Transition::Pop => { 381 233 if self.depth > 1 { 382 234 self.depth -= 1; 383 - (false, true) // exit old, resume parent 235 + (false, true) 384 236 } else { 385 - return None; // Can't pop below Home 237 + return None; 386 238 } 387 239 } 388 240 389 241 Transition::Replace(id) => { 390 242 self.stack[self.depth - 1] = id; 391 - (false, false) // exit old, enter new 243 + (false, false) 392 244 } 393 245 394 246 Transition::Home => { 395 247 self.depth = 1; 396 248 self.stack[0] = AppId::Home; 397 - (false, true) // exit old, resume Home 249 + (false, true) 398 250 } 399 251 }; 400 252 ··· 411 263 } 412 264 } 413 265 414 - /// Stack depth (for debug). 415 266 pub fn depth(&self) -> usize { 416 267 self.depth 417 268 }
+2 -4
src/apps/reader.rs
··· 1 - //! Reader app (stub). 2 - //! 3 - //! Will eventually render e-book content. For now shows the filename 4 - //! passed from the file browser. 1 + // File viewer (stub) 2 + // Receives filename from FilesApp via ctx.message() on entry. 5 3 6 4 use embedded_graphics::mono_font::ascii::FONT_10X20; 7 5
+1 -1
src/apps/settings.rs
··· 1 - //! Settings app (stub). 1 + // System settings (stub) 2 2 3 3 use embedded_graphics::mono_font::ascii::FONT_10X20; 4 4
+42 -110
src/bin/main.rs
··· 1 + // pulp-os entry point and main loop 2 + // 3 + // Boot sequence: timer -> hardware -> UI -> enter Home app 4 + // Main loop: drain scheduler -> WFI -> translate wake flags -> repeat 5 + // 6 + // Apps are stack allocated and dispatched via with_app! macro (no dyn). 7 + // Timer scales from 10ms (active) to 100ms (idle) to save power; 8 + // any button activity snaps it back immediately. 9 + 1 10 #![no_std] 2 11 #![no_main] 3 12 ··· 12 21 use core::cell::RefCell; 13 22 use critical_section::Mutex; 14 23 15 - use pulp_os::apps::{App, AppId, Launcher, Redraw, Services}; 16 24 use pulp_os::apps::files::FilesApp; 17 25 use pulp_os::apps::home::HomeApp; 18 26 use pulp_os::apps::reader::ReaderApp; 19 27 use pulp_os::apps::settings::SettingsApp; 28 + use pulp_os::apps::{App, AppId, Launcher, Redraw, Services}; 20 29 use pulp_os::board::Board; 21 30 use pulp_os::board::StripBuffer; 22 31 use pulp_os::drivers::battery; ··· 24 33 use pulp_os::drivers::storage::DirCache; 25 34 use pulp_os::kernel::wake::{self, signal_timer, try_wake}; 26 35 use pulp_os::kernel::{Job, Scheduler}; 27 - use pulp_os::ui::{StatusBar, SystemStatus, free_stack_bytes, BAR_HEIGHT}; 36 + use pulp_os::ui::{BAR_HEIGHT, StatusBar, SystemStatus, free_stack_bytes}; 28 37 29 38 extern crate alloc; 30 39 31 40 esp_bootloader_esp_idf::esp_app_desc!(); 32 41 33 - /// How often to refresh the status bar (in 10ms ticks). 500 = 5 seconds. 34 - const STATUSBAR_INTERVAL_TICKS: u32 = 500; 35 - 36 - // ── Idle timer scaling ───────────────────────────────────────── 37 - // 38 - // The timer interrupt wakes the CPU from WFI to poll ADC buttons. 39 - // During reading, no buttons are pressed for minutes — every 10ms 40 - // wake is wasted power. After a period of inactivity, we slow the 41 - // timer to 100ms (10× fewer wakes). Button presses snap it back. 42 - // 43 - // Power button (GPIO3) has its own interrupt and wakes instantly 44 - // regardless of timer period. ADC buttons get up to ~130ms latency 45 - // on first press during idle (100ms poll + 30ms debounce) — the 46 - // debounce snap-back ensures confirmation is fast once detected. 42 + const STATUSBAR_INTERVAL_TICKS: u32 = 500; // 5 seconds in 10ms ticks 47 43 48 - /// Base timer period (ms). Used during active interaction. 49 44 const ACTIVE_TIMER_MS: u64 = 10; 50 - /// Slow timer period (ms). Used after idle threshold. 51 45 const IDLE_TIMER_MS: u64 = 100; 52 - /// How many consecutive empty polls before switching to slow timer. 53 - /// 200 × 10ms = 2 seconds of inactivity. 54 - const IDLE_THRESHOLD_POLLS: u32 = 200; 55 - 56 - // ── Timer interrupt ──────────────────────────────────────────── 46 + const IDLE_THRESHOLD_POLLS: u32 = 200; // 200 * 10ms = 2s before idle 57 47 58 48 static TIMER0: Mutex<RefCell<Option<PeriodicTimer<'static, esp_hal::Blocking>>>> = 59 49 Mutex::new(RefCell::new(None)); ··· 68 58 signal_timer(); 69 59 } 70 60 71 - /// Change the timer period at runtime. Updates the tick weight so 72 - /// uptime_ticks() stays in consistent 10ms units. 73 61 fn set_timer_period(ms: u64) { 74 62 wake::set_tick_weight((ms / ACTIVE_TIMER_MS) as u32); 75 63 critical_section::with(|cs| { ··· 79 67 }); 80 68 } 81 69 82 - /// Dispatch to the active app. Apps are stack-allocated — no dyn, no heap. 83 70 macro_rules! with_app { 84 71 ($id:expr, $home:expr, $files:expr, $reader:expr, $settings:expr, |$app:ident| $body:expr) => { 85 72 match $id { 86 - AppId::Home => { let $app = &mut $home; $body } 87 - AppId::Files => { let $app = &mut $files; $body } 88 - AppId::Reader => { let $app = &mut $reader; $body } 89 - AppId::Settings => { let $app = &mut $settings; $body } 73 + AppId::Home => { 74 + let $app = &mut $home; 75 + $body 76 + } 77 + AppId::Files => { 78 + let $app = &mut $files; 79 + $body 80 + } 81 + AppId::Reader => { 82 + let $app = &mut $reader; 83 + $body 84 + } 85 + AppId::Settings => { 86 + let $app = &mut $settings; 87 + $body 88 + } 90 89 } 91 90 }; 92 91 } ··· 100 99 101 100 info!("booting..."); 102 101 103 - // Timer: 10ms tick 104 102 let timg0 = TimerGroup::new(unsafe { peripherals.TIMG0.clone_unchecked() }); 105 103 let mut timer0 = PeriodicTimer::new(timg0.timer0); 106 104 critical_section::with(|cs| { ··· 111 109 }); 112 110 info!("timer initialized."); 113 111 114 - // Hardware 115 112 let mut board = Board::init(peripherals); 116 113 let mut delay = Delay::new(); 117 114 board.display.epd.init(&mut delay); ··· 119 116 120 117 let mut strip = StripBuffer::new(); 121 118 122 - // Status bar — persistent across all apps 123 119 let mut statusbar = StatusBar::new(); 124 120 let sd_ok = board 125 121 .storage ··· 128 124 .open_volume(embedded_sdmmc::VolumeIdx(0)) 129 125 .is_ok(); 130 126 131 - // Apps — all stack-allocated, zero heap 132 127 let mut home = HomeApp::new(); 133 128 let mut files = FilesApp::new(); 134 129 let mut reader = ReaderApp::new(); 135 130 let mut settings = SettingsApp::new(); 136 131 137 - // Launcher (owns navigation stack + inter-app context) 138 132 let mut launcher = Launcher::new(); 139 133 140 - // Scheduler + input 141 134 let mut sched = Scheduler::new(); 142 135 let mut input = InputDriver::new(board.input); 143 136 let mut last_statusbar_ticks: u32 = 0; ··· 145 138 let mut timer_is_slow = false; 146 139 let mut dir_cache = DirCache::new(); 147 140 148 - // ── Boot: explicit init, no scheduler ────────────────────── 149 141 home.on_enter(&mut launcher.ctx); 150 142 update_statusbar(&mut statusbar, &mut input, sd_ok); 151 143 board.display.epd.render_full(&mut strip, &mut delay, |s| { ··· 155 147 info!("ui ready."); 156 148 info!("kernel ready."); 157 149 158 - // ── Main loop ────────────────────────────────────────────── 159 150 loop { 160 - // 1. Drain all pending jobs by priority 151 + // drain all pending jobs by priority (high first, FIFO within tier) 161 152 while let Some(job) = sched.pop() { 162 153 match job { 163 - // ── PollInput (High) ─────────────────────────── 164 154 Job::PollInput => { 165 155 let Some(event) = input.poll() else { 166 - // No confirmed event yet. 167 - // 168 - // If debouncing (raw activity, not yet confirmed), 169 - // snap timer to fast so confirmation arrives in 170 - // ~10ms instead of ~100ms. Worst-case first-press 171 - // latency: 100ms (idle poll) + 30ms (debounce) = 130ms 172 - // instead of 200ms without this check. 173 156 if timer_is_slow && input.is_debouncing() { 174 157 set_timer_period(ACTIVE_TIMER_MS); 175 158 timer_is_slow = false; ··· 185 168 continue; 186 169 }; 187 170 188 - // Got input — snap back to fast timer 189 171 if timer_is_slow { 190 172 set_timer_period(ACTIVE_TIMER_MS); 191 173 timer_is_slow = false; ··· 193 175 } 194 176 idle_polls = 0; 195 177 196 - // Route to active app 197 178 let active = launcher.active(); 198 - let transition = 199 - with_app!(active, home, files, reader, settings, |app| { 200 - app.on_event(event, &mut launcher.ctx) 201 - }); 179 + let transition = with_app!(active, home, files, reader, settings, |app| { 180 + app.on_event(event, &mut launcher.ctx) 181 + }); 202 182 203 - // Apply navigation 204 183 if let Some(nav) = launcher.apply(transition) { 205 184 info!("app: {:?} -> {:?}", nav.from, nav.to); 206 185 207 - // Departing app: suspend if staying on stack, exit if leaving 208 186 if nav.suspend { 209 187 with_app!(nav.from, home, files, reader, settings, |app| { 210 188 app.on_suspend(); ··· 215 193 }); 216 194 } 217 195 218 - // Arriving app: resume if returning, enter if fresh 219 196 if nav.resume { 220 197 with_app!(nav.to, home, files, reader, settings, |app| { 221 198 app.on_resume(&mut launcher.ctx); ··· 225 202 app.on_enter(&mut launcher.ctx); 226 203 }); 227 204 } 228 - } else { 229 - // No navigation — dirty regions (if any) were 230 - // already pushed into ctx by on_event via mark_dirty(). 231 205 } 232 206 233 - // ── Cascade: enqueue follow-on work ──────── 234 - // 235 - // RENDER OWNERSHIP INVARIANT: 236 - // When an app has pending async work, IT owns the 237 - // render decision. PollInput must not enqueue Render 238 - // alongside AppWork — doing so renders stale data 239 - // before the work completes, then renders again 240 - // after (double refresh, one wasted). 241 - // 242 - // The `else if` enforces this structurally. 207 + // if app has pending async work, let AppWork own the render 208 + // decision (else if); avoids double refresh on e-paper 243 209 let active = launcher.active(); 244 210 let needs = with_app!(active, home, files, reader, settings, |app| { 245 211 app.needs_work() ··· 251 217 } 252 218 } 253 219 254 - // ── Render (High) ────────────────────────────── 255 220 Job::Render => { 256 221 let active = launcher.active(); 257 222 match launcher.ctx.take_redraw() { 258 223 Redraw::Full => { 259 224 update_statusbar(&mut statusbar, &mut input, sd_ok); 260 225 with_app!(active, home, files, reader, settings, |app| { 261 - board.display.epd.render_full( 262 - &mut strip, 263 - &mut delay, 264 - |s| { 265 - statusbar.draw(s).unwrap(); 266 - app.draw(s); 267 - }, 268 - ); 226 + board.display.epd.render_full(&mut strip, &mut delay, |s| { 227 + statusbar.draw(s).unwrap(); 228 + app.draw(s); 229 + }); 269 230 }); 270 231 } 271 232 Redraw::Partial(r) => { ··· 288 249 ); 289 250 }); 290 251 } 291 - Redraw::None => {} // Race: already consumed 252 + Redraw::None => {} 292 253 } 293 254 } 294 255 295 - // ── AppWork (Normal) ──────────────────────────── 296 - // 297 - // Generic async work for the active app. The kernel 298 - // constructs Services (just two refs, zero cost) and 299 - // calls on_work(). The app handles everything — cache 300 - // management, error handling, dirty region marking. 301 - // 302 - // No app-specific code lives here. Adding a new app 303 - // with async I/O needs zero changes to this handler. 304 256 Job::AppWork => { 305 257 let active = launcher.active(); 306 258 let mut svc = Services::new(&mut dir_cache, &board.storage.sd); ··· 312 264 } 313 265 } 314 266 315 - // ── UpdateStatusBar (Normal) ─────────────────── 316 267 Job::UpdateStatusBar => { 317 268 update_statusbar(&mut statusbar, &mut input, sd_ok); 318 - // Don't enqueue a render — the next full refresh 319 - // will pick up the new text. Avoids unnecessary 320 - // partial refreshes just for the status bar. 321 269 } 322 270 } 323 271 } 324 272 325 - // 2. Wait for wake events 273 + // wait for wake event then translate flags into jobs 326 274 let wake = match try_wake() { 327 275 Some(w) => w, 328 276 None => { ··· 331 279 } 332 280 }; 333 281 334 - // 3. Translate wake flags into jobs 335 - // 336 - // Each flag is checked independently — concurrent sources 337 - // (e.g. Timer + Display) all get handled, nothing swallowed. 338 - // 339 - // Button and Timer both poll input: button because the user 340 - // pressed something, timer because ADC-based buttons are 341 - // sampled on the tick. 342 282 if wake.has_input() { 343 - // Power button GPIO interrupt — snap to fast timer immediately. 344 - // ADC buttons snap back via is_debouncing() in PollInput, 345 - // giving ~130ms worst-case latency (100ms + 30ms debounce). 346 283 if wake.button && timer_is_slow { 347 284 set_timer_period(ACTIVE_TIMER_MS); 348 285 timer_is_slow = false; ··· 359 296 } 360 297 } 361 298 362 - // Display BUSY interrupt completion — currently no-op. 363 - // Will be used when BUSY pin drives a GPIO interrupt 364 - // to signal end-of-refresh without busy-waiting. 365 - if wake.display { 366 - // future: signal display-done to unblock render 367 - } 299 + if wake.display {} 368 300 } 369 301 } 370 302
+9 -14
src/board/button.rs
··· 1 - //! Button definitions and ADC decoding for XTEink X4 2 - //! 3 - //! The X4 uses resistance ladder circuits for most buttons. 4 - //! Each ladder is read via ADC and decoded by comparing the 5 - //! millivolt reading against known thresholds. 1 + // Button definitions and ADC resistance ladder decoding 2 + // 3 + // The X4 has two ADC ladders (Row1 on GPIO1, Row2 on GPIO2) and one 4 + // discrete power button on GPIO3. Each ladder encodes multiple buttons 5 + // as distinct voltage levels via a resistor network. 6 + // 7 + // Threshold table format: (center_mv, tolerance_mv, Button) 6 8 7 - /// All physical buttons on the device. 8 9 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 9 10 pub enum Button { 10 - // Navigation cluster (Row 1 - GPIO1) 11 11 Right, 12 12 Left, 13 13 Confirm, 14 14 Back, 15 - // Volume buttons (Row 2 - GPIO2) 16 15 VolUp, 17 16 VolDown, 18 - // Discrete digital button 19 17 Power, 20 18 } 21 19 ··· 39 37 } 40 38 } 41 39 42 - // ADC Threshold Tables 43 - // Each entry: (center_mv, tolerance_mv, button) 44 - // A reading matches if: center - tolerance <= reading <= center + tolerance 45 40 pub const DEFAULT_TOLERANCE: u16 = 150; 46 41 47 42 pub const ROW1_THRESHOLDS: &[(u16, u16, Button)] = &[ 48 - (3, 50, Button::Right), // Near ground 43 + (3, 50, Button::Right), 49 44 (1113, DEFAULT_TOLERANCE, Button::Left), 50 45 (1984, DEFAULT_TOLERANCE, Button::Back), 51 46 (2556, DEFAULT_TOLERANCE, Button::Confirm), 52 47 ]; 53 48 54 49 pub const ROW2_THRESHOLDS: &[(u16, u16, Button)] = &[ 55 - (3, 50, Button::VolDown), // Near ground 50 + (3, 50, Button::VolDown), 56 51 (1659, DEFAULT_TOLERANCE, Button::VolUp), 57 52 ]; 58 53
+15 -498
src/board/display.rs
··· 1 - //! SSD1677 E-Paper Display Driver for XTEink X4 2 - //! 3 - //! Based on GxEPD2_426_GDEQ0426T82.cpp by Jean-Marc Zingg 4 - //! <https://github.com/ZinggJM/GxEPD2> 1 + // SSD1677 e-paper driver for GDEQ0426T82 (800x480). 2 + // No framebuffer in SRAM; pixels are streamed to the controller 3 + // through a 4KB StripBuffer. Both BW and RED RAM planes must be 4 + // written for differential waveform updates. 5 + 5 6 use embedded_graphics_core::geometry::{OriginDimensions, Size}; 6 7 use embedded_hal::digital::{InputPin, OutputPin}; 7 8 use embedded_hal::spi::SpiDevice; ··· 9 10 10 11 use super::strip::{STRIP_COUNT, StripBuffer}; 11 12 12 - // Display dimensions (physical) 13 13 pub const WIDTH: u16 = 800; 14 14 pub const HEIGHT: u16 = 480; 15 15 16 - // SPI frequency 17 16 pub const SPI_FREQ_MHZ: u32 = 20; 18 17 19 - // Timing constants from GxEPD2 20 18 #[allow(dead_code)] 21 19 const POWER_ON_TIME_MS: u32 = 100; 22 20 const POWER_OFF_TIME_MS: u32 = 200; 23 21 const FULL_REFRESH_TIME_MS: u32 = 1600; 24 22 const PARTIAL_REFRESH_TIME_MS: u32 = 600; 25 23 26 - /// Display rotation 27 24 #[derive(Clone, Copy, Debug, Default, PartialEq)] 28 25 pub enum Rotation { 29 26 #[default] ··· 33 30 Deg270, 34 31 } 35 32 36 - // SSD1677 Commands (matching GxEPD2 33 + // SSD1677 command table 37 34 mod cmd { 38 35 pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; 39 36 pub const BOOSTER_SOFT_START: u8 = 0x0C; ··· 46 43 pub const MASTER_ACTIVATION: u8 = 0x20; 47 44 pub const DISPLAY_UPDATE_CONTROL_1: u8 = 0x21; 48 45 pub const DISPLAY_UPDATE_CONTROL_2: u8 = 0x22; 49 - pub const WRITE_RAM_BW: u8 = 0x24; // Current/New buffer 50 - pub const WRITE_RAM_RED: u8 = 0x26; // Previous buffer (for differential) 46 + pub const WRITE_RAM_BW: u8 = 0x24; // current/new buffer 47 + pub const WRITE_RAM_RED: u8 = 0x26; // previous buffer (differential) 51 48 pub const BORDER_WAVEFORM: u8 = 0x3C; 52 49 pub const SET_RAM_X_RANGE: u8 = 0x44; 53 50 pub const SET_RAM_Y_RANGE: u8 = 0x45; ··· 55 52 pub const SET_RAM_Y_COUNTER: u8 = 0x4F; 56 53 } 57 54 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. 61 55 pub struct DisplayDriver<SPI, DC, RST, BUSY> { 62 56 spi: SPI, 63 57 dc: DC, ··· 76 70 RST: OutputPin, 77 71 BUSY: InputPin, 78 72 { 79 - // Create a new display driver 80 73 pub fn new(spi: SPI, dc: DC, rst: RST, busy: BUSY) -> Self { 81 74 Self { 82 75 spi, ··· 133 126 134 127 delay.delay_millis(1); 135 128 136 - // Write to both display RAM buffers via strips 137 129 for &ram_cmd in &[cmd::WRITE_RAM_RED, cmd::WRITE_RAM_BW] { 138 130 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 139 131 self.send_command(ram_cmd); ··· 150 142 self.initial_refresh = false; 151 143 } 152 144 153 - /// Render a partial region and do a partial refresh. 154 145 pub fn render_partial<F>( 155 146 &mut self, 156 147 strip: &mut StripBuffer, ··· 163 154 ) where 164 155 F: Fn(&mut StripBuffer), 165 156 { 166 - // Initial refresh must be full 167 157 if self.initial_refresh { 168 158 return self.render_full(strip, delay, draw); 169 159 } ··· 172 162 self.init_display(delay); 173 163 } 174 164 175 - // Transform logical region to physical region 176 165 let (px, py, pw, ph) = self.transform_region(x, y, w, h); 177 166 178 - // Ensure x and w are multiples of 8 (byte boundary requirement) 179 167 let px_aligned = px & !7; 180 168 let extra = px - px_aligned; 181 169 let mut pw = pw + extra; ··· 183 171 pw += 8 - (pw % 8); 184 172 } 185 173 186 - // Clamp to screen bounds 187 174 let px = px_aligned.min(WIDTH); 188 175 let py = py.min(HEIGHT); 189 176 let pw = pw.min(WIDTH - px); ··· 204 191 self.power_off(); 205 192 } 206 193 207 - // Power off the display 208 194 pub fn power_off(&mut self) { 209 195 if self.power_is_on { 210 196 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); ··· 215 201 } 216 202 } 217 203 218 - /// Put display into deep sleep (minimum power, needs reset to wake) 219 204 pub fn hibernate(&mut self) { 220 205 self.power_off(); 221 206 self.send_command(cmd::DEEP_SLEEP); ··· 223 208 self.init_done = false; 224 209 } 225 210 226 - /// Write a physical region to display RAM using strip iteration. 227 - /// Handles regions larger than the strip buffer by splitting into 228 - /// multiple passes with as many rows as fit. 229 211 fn write_region_strips<F>( 230 212 &mut self, 231 213 strip: &mut StripBuffer, ··· 254 236 } 255 237 256 238 fn init_display(&mut self, delay: &mut Delay) { 257 - // Software reset 258 239 self.send_command(cmd::SW_RESET); 259 240 delay.delay_millis(10); 260 241 261 - // Temperature sensor: internal 262 242 self.send_command(cmd::TEMPERATURE_SENSOR); 263 243 self.send_data(&[0x80]); 264 244 265 - // Booster soft start 266 245 self.send_command(cmd::BOOSTER_SOFT_START); 267 246 self.send_data(&[0xAE, 0xC7, 0xC3, 0xC0, 0x80]); 268 247 269 - // Driver output control 270 248 self.send_command(cmd::DRIVER_OUTPUT_CONTROL); 271 - self.send_data(&[ 272 - ((HEIGHT - 1) & 0xFF) as u8, // A[7:0] 273 - ((HEIGHT - 1) >> 8) as u8, // A[9:8] 274 - 0x02, // SM = interlaced 275 - ]); 249 + self.send_data(&[((HEIGHT - 1) & 0xFF) as u8, ((HEIGHT - 1) >> 8) as u8, 0x02]); 276 250 277 - // Border waveform 278 251 self.send_command(cmd::BORDER_WAVEFORM); 279 252 self.send_data(&[0x01]); 280 253 281 - // Set initial RAM area 282 254 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 283 255 284 256 self.init_done = true; 285 257 } 286 258 287 - // Transform logical region to physical region based on rotation 288 259 fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 289 260 match self.rotation { 290 261 Rotation::Deg0 => (x, y, w, h), 291 - Rotation::Deg90 => { 292 - // Logical (x,y,w,h) in 480x800 → Physical in 800x480 293 - // Logical top-left (x,y) → Physical (WIDTH-1-y, x) 294 - // But we need the physical top-left of the region 295 - (WIDTH - y - h, x, h, w) 296 - } 262 + Rotation::Deg90 => (WIDTH - y - h, x, h, w), 297 263 Rotation::Deg180 => (WIDTH - x - w, HEIGHT - y - h, w, h), 298 264 Rotation::Deg270 => (y, HEIGHT - x - w, h, w), 299 265 } 300 266 } 301 267 268 + // Gates are wired in reverse on this panel; Y must be flipped. 302 269 fn set_partial_ram_area(&mut self, x: u16, y: u16, w: u16, h: u16) { 303 - // Gates are reversed on this display - flip Y 304 270 let y_flipped = HEIGHT - y - h; 305 271 306 - // Data entry mode: X increase, Y decrease (for gate reversal) 272 + // X increment, Y decrement (compensates gate reversal) 307 273 self.send_command(cmd::DATA_ENTRY_MODE); 308 274 self.send_data(&[0x01]); 309 275 310 - // Set RAM X address start/end 311 276 self.send_command(cmd::SET_RAM_X_RANGE); 312 277 self.send_data(&[ 313 278 (x & 0xFF) as u8, ··· 316 281 ((x + w - 1) >> 8) as u8, 317 282 ]); 318 283 319 - // Set RAM Y address start/end (reversed) 320 284 self.send_command(cmd::SET_RAM_Y_RANGE); 321 285 self.send_data(&[ 322 286 ((y_flipped + h - 1) & 0xFF) as u8, ··· 325 289 (y_flipped >> 8) as u8, 326 290 ]); 327 291 328 - // Set RAM X counter 329 292 self.send_command(cmd::SET_RAM_X_COUNTER); 330 293 self.send_data(&[(x & 0xFF) as u8, (x >> 8) as u8]); 331 294 332 - // Set RAM Y counter 333 295 self.send_command(cmd::SET_RAM_Y_COUNTER); 334 296 self.send_data(&[ 335 297 ((y_flipped + h - 1) & 0xFF) as u8, ··· 342 304 self.init_display(delay); 343 305 } 344 306 345 - // write white to both buffers 346 307 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 347 308 self.write_screen_buffer(cmd::WRITE_RAM_RED, 0xFF); 348 309 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); ··· 354 315 355 316 fn write_screen_buffer(&mut self, command: u8, value: u8) { 356 317 self.send_command(command); 357 - // Write in chunks to avoid watchdog issues 358 318 let total = (WIDTH as u32 * HEIGHT as u32 / 8) as usize; 359 319 let chunk_size = 256; 360 320 let chunk = [value; 256]; ··· 382 342 self.send_data(&[0x00, 0x00]); 383 343 384 344 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 385 - self.send_data(&[0xFC]); // Partial refresh (uses OTP LUT) 345 + self.send_data(&[0xFC]); 386 346 387 347 self.send_command(cmd::MASTER_ACTIVATION); 388 348 self.wait_busy(PARTIAL_REFRESH_TIME_MS); ··· 390 350 self.power_is_on = true; 391 351 } 392 352 393 - /// Wait for the display controller to finish an operation. 394 - /// 395 - /// The BUSY pin is HIGH while the controller is working and goes 396 - /// LOW when idle. Instead of polling in a tight loop, we execute 397 - /// WFI to sleep the CPU between checks. The BUSY pin's falling- 398 - /// edge GPIO interrupt (armed in Board::init) wakes us immediately 399 - /// on completion; the 10ms timer interrupt provides a backstop. 400 - /// 401 - /// Power savings: ~35mA avoided during each refresh cycle 402 - /// (600ms partial, 1600ms full) by sleeping instead of spinning. 353 + // Sleeps via WFI between polls. BUSY falling edge interrupt 354 + // wakes immediately; 10ms timer tick is the backstop. 403 355 fn wait_busy(&mut self, timeout_ms: u32) { 404 356 use esp_hal::time::{Duration, Instant}; 405 357 ··· 429 381 let _ = self.spi.write(data); 430 382 } 431 383 } 432 - 433 - // embedded-graphics integration 434 - // NOTE: size queriies only, drawing goes through StripBuffer 435 384 436 385 impl<SPI, DC, RST, BUSY, E> OriginDimensions for DisplayDriver<SPI, DC, RST, BUSY> 437 386 where ··· 447 396 } 448 397 } 449 398 } 450 - // //! SSD1677 E-Paper Display Driver for XTEink X4 451 - // //! 452 - // //! Based on GxEPD2_426_GDEQ0426T82.cpp by Jean-Marc Zingg 453 - // //! <https://github.com/ZinggJM/GxEPD2> 454 - // use embedded_graphics_core::geometry::{OriginDimensions, Size}; 455 - // use embedded_hal::digital::{InputPin, OutputPin}; 456 - // use embedded_hal::spi::SpiDevice; 457 - // use esp_hal::delay::Delay; 458 - // 459 - // use super::strip::{STRIP_COUNT, StripBuffer}; 460 - // 461 - // // Display dimensions (physical) 462 - // pub const WIDTH: u16 = 800; 463 - // pub const HEIGHT: u16 = 480; 464 - // 465 - // // SPI frequency 466 - // pub const SPI_FREQ_MHZ: u32 = 20; 467 - // 468 - // // Timing constants from GxEPD2 469 - // #[allow(dead_code)] 470 - // const POWER_ON_TIME_MS: u32 = 100; 471 - // const POWER_OFF_TIME_MS: u32 = 200; 472 - // const FULL_REFRESH_TIME_MS: u32 = 1600; 473 - // const PARTIAL_REFRESH_TIME_MS: u32 = 600; 474 - // 475 - // /// Display rotation 476 - // #[derive(Clone, Copy, Debug, Default, PartialEq)] 477 - // pub enum Rotation { 478 - // #[default] 479 - // Deg0, 480 - // Deg90, 481 - // Deg180, 482 - // Deg270, 483 - // } 484 - // 485 - // // SSD1677 Commands (matching GxEPD2 486 - // mod cmd { 487 - // pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; 488 - // pub const BOOSTER_SOFT_START: u8 = 0x0C; 489 - // pub const DEEP_SLEEP: u8 = 0x10; 490 - // pub const DATA_ENTRY_MODE: u8 = 0x11; 491 - // pub const SW_RESET: u8 = 0x12; 492 - // pub const TEMPERATURE_SENSOR: u8 = 0x18; 493 - // #[allow(dead_code)] 494 - // pub const WRITE_TEMP_REGISTER: u8 = 0x1A; 495 - // pub const MASTER_ACTIVATION: u8 = 0x20; 496 - // pub const DISPLAY_UPDATE_CONTROL_1: u8 = 0x21; 497 - // pub const DISPLAY_UPDATE_CONTROL_2: u8 = 0x22; 498 - // pub const WRITE_RAM_BW: u8 = 0x24; // Current/New buffer 499 - // pub const WRITE_RAM_RED: u8 = 0x26; // Previous buffer (for differential) 500 - // pub const BORDER_WAVEFORM: u8 = 0x3C; 501 - // pub const SET_RAM_X_RANGE: u8 = 0x44; 502 - // pub const SET_RAM_Y_RANGE: u8 = 0x45; 503 - // pub const SET_RAM_X_COUNTER: u8 = 0x4E; 504 - // pub const SET_RAM_Y_COUNTER: u8 = 0x4F; 505 - // } 506 - // 507 - // // Display driver for SSD1677-based e-paper (GDEQ0426T82) 508 - // // No framebuffer — rendering is done through StripBuffer. 509 - // // The display controller has its own 48KB RAM; we stream into it. 510 - // pub struct DisplayDriver<SPI, DC, RST, BUSY> { 511 - // spi: SPI, 512 - // dc: DC, 513 - // rst: RST, 514 - // busy: BUSY, 515 - // rotation: Rotation, 516 - // power_is_on: bool, 517 - // init_done: bool, 518 - // initial_refresh: bool, 519 - // } 520 - // 521 - // impl<SPI, DC, RST, BUSY, E> DisplayDriver<SPI, DC, RST, BUSY> 522 - // where 523 - // SPI: SpiDevice<Error = E>, 524 - // DC: OutputPin, 525 - // RST: OutputPin, 526 - // BUSY: InputPin, 527 - // { 528 - // // Create a new display driver 529 - // pub fn new(spi: SPI, dc: DC, rst: RST, busy: BUSY) -> Self { 530 - // Self { 531 - // spi, 532 - // dc, 533 - // rst, 534 - // busy, 535 - // rotation: Rotation::Deg270, 536 - // power_is_on: false, 537 - // init_done: false, 538 - // initial_refresh: true, 539 - // } 540 - // } 541 - // 542 - // pub fn set_rotation(&mut self, rotation: Rotation) { 543 - // self.rotation = rotation; 544 - // } 545 - // 546 - // pub fn rotation(&self) -> Rotation { 547 - // self.rotation 548 - // } 549 - // 550 - // pub fn size(&self) -> Size { 551 - // match self.rotation { 552 - // Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), 553 - // Rotation::Deg90 | Rotation::Deg270 => Size::new(HEIGHT as u32, WIDTH as u32), 554 - // } 555 - // } 556 - // 557 - // pub fn reset(&mut self, delay: &mut Delay) { 558 - // let _ = self.rst.set_high(); 559 - // delay.delay_millis(20); 560 - // let _ = self.rst.set_low(); 561 - // delay.delay_millis(2); 562 - // let _ = self.rst.set_high(); 563 - // delay.delay_millis(20); 564 - // } 565 - // 566 - // pub fn init(&mut self, delay: &mut Delay) { 567 - // self.reset(delay); 568 - // self.init_display(delay); 569 - // } 570 - // 571 - // pub fn clear(&mut self, delay: &mut Delay) { 572 - // self.clear_screen(delay); 573 - // } 574 - // 575 - // pub fn render_full<F>(&mut self, strip: &mut StripBuffer, delay: &mut Delay, draw: F) 576 - // where 577 - // F: Fn(&mut StripBuffer), 578 - // { 579 - // if !self.init_done { 580 - // self.init_display(delay); 581 - // } 582 - // 583 - // delay.delay_millis(1); 584 - // 585 - // // Write to both display RAM buffers via strips 586 - // for &ram_cmd in &[cmd::WRITE_RAM_RED, cmd::WRITE_RAM_BW] { 587 - // self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 588 - // self.send_command(ram_cmd); 589 - // delay.delay_millis(1); 590 - // 591 - // for i in 0..STRIP_COUNT { 592 - // strip.begin_strip(self.rotation, i); 593 - // draw(strip); 594 - // self.send_data(strip.data()); 595 - // } 596 - // } 597 - // 598 - // self.update_full(delay); 599 - // self.initial_refresh = false; 600 - // } 601 - // 602 - // /// Render a partial region and do a partial refresh. 603 - // pub fn render_partial<F>( 604 - // &mut self, 605 - // strip: &mut StripBuffer, 606 - // x: u16, 607 - // y: u16, 608 - // w: u16, 609 - // h: u16, 610 - // delay: &mut Delay, 611 - // draw: F, 612 - // ) where 613 - // F: Fn(&mut StripBuffer), 614 - // { 615 - // // Initial refresh must be full 616 - // if self.initial_refresh { 617 - // return self.render_full(strip, delay, draw); 618 - // } 619 - // 620 - // if !self.init_done { 621 - // self.init_display(delay); 622 - // } 623 - // 624 - // // Transform logical region to physical region 625 - // let (px, py, pw, ph) = self.transform_region(x, y, w, h); 626 - // 627 - // // Ensure x and w are multiples of 8 (byte boundary requirement) 628 - // let px_aligned = px & !7; 629 - // let extra = px - px_aligned; 630 - // let mut pw = pw + extra; 631 - // if pw % 8 > 0 { 632 - // pw += 8 - (pw % 8); 633 - // } 634 - // 635 - // // Clamp to screen bounds 636 - // let px = px_aligned.min(WIDTH); 637 - // let py = py.min(HEIGHT); 638 - // let pw = pw.min(WIDTH - px); 639 - // let ph = ph.min(HEIGHT - py); 640 - // 641 - // if pw == 0 || ph == 0 { 642 - // return; 643 - // } 644 - // 645 - // self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 646 - // 647 - // self.set_partial_ram_area(px, py, pw, ph); 648 - // self.update_partial(delay); 649 - // 650 - // self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_RED, &draw); 651 - // self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 652 - // 653 - // self.power_off(delay); 654 - // } 655 - // 656 - // // Power off the display 657 - // pub fn power_off(&mut self, delay: &mut Delay) { 658 - // if self.power_is_on { 659 - // self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 660 - // self.send_data(&[0x83]); 661 - // self.send_command(cmd::MASTER_ACTIVATION); 662 - // self.wait_busy(delay, POWER_OFF_TIME_MS); 663 - // self.power_is_on = false; 664 - // } 665 - // } 666 - // 667 - // /// Put display into deep sleep (minimum power, needs reset to wake) 668 - // pub fn hibernate(&mut self, delay: &mut Delay) { 669 - // self.power_off(delay); 670 - // self.send_command(cmd::DEEP_SLEEP); 671 - // self.send_data(&[0x01]); 672 - // self.init_done = false; 673 - // } 674 - // 675 - // /// Write a physical region to display RAM using strip iteration. 676 - // /// Handles regions larger than the strip buffer by splitting into 677 - // /// multiple passes with as many rows as fit. 678 - // fn write_region_strips<F>( 679 - // &mut self, 680 - // strip: &mut StripBuffer, 681 - // px: u16, 682 - // py: u16, 683 - // pw: u16, 684 - // ph: u16, 685 - // ram_cmd: u8, 686 - // draw: &F, 687 - // ) where 688 - // F: Fn(&mut StripBuffer), 689 - // { 690 - // let max_rows = StripBuffer::max_rows_for_width(pw); 691 - // 692 - // self.set_partial_ram_area(px, py, pw, ph); 693 - // self.send_command(ram_cmd); 694 - // 695 - // let mut y = py; 696 - // while y < py + ph { 697 - // let rows = max_rows.min(py + ph - y); 698 - // strip.begin_window(self.rotation, px, y, pw, rows); 699 - // draw(strip); 700 - // self.send_data(strip.data()); 701 - // y += rows; 702 - // } 703 - // } 704 - // 705 - // fn init_display(&mut self, delay: &mut Delay) { 706 - // // Software reset 707 - // self.send_command(cmd::SW_RESET); 708 - // delay.delay_millis(10); 709 - // 710 - // // Temperature sensor: internal 711 - // self.send_command(cmd::TEMPERATURE_SENSOR); 712 - // self.send_data(&[0x80]); 713 - // 714 - // // Booster soft start 715 - // self.send_command(cmd::BOOSTER_SOFT_START); 716 - // self.send_data(&[0xAE, 0xC7, 0xC3, 0xC0, 0x80]); 717 - // 718 - // // Driver output control 719 - // self.send_command(cmd::DRIVER_OUTPUT_CONTROL); 720 - // self.send_data(&[ 721 - // ((HEIGHT - 1) & 0xFF) as u8, // A[7:0] 722 - // ((HEIGHT - 1) >> 8) as u8, // A[9:8] 723 - // 0x02, // SM = interlaced 724 - // ]); 725 - // 726 - // // Border waveform 727 - // self.send_command(cmd::BORDER_WAVEFORM); 728 - // self.send_data(&[0x01]); 729 - // 730 - // // Set initial RAM area 731 - // self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 732 - // 733 - // self.init_done = true; 734 - // } 735 - // 736 - // // Transform logical region to physical region based on rotation 737 - // fn transform_region(&self, x: u16, y: u16, w: u16, h: u16) -> (u16, u16, u16, u16) { 738 - // match self.rotation { 739 - // Rotation::Deg0 => (x, y, w, h), 740 - // Rotation::Deg90 => { 741 - // // Logical (x,y,w,h) in 480x800 → Physical in 800x480 742 - // // Logical top-left (x,y) → Physical (WIDTH-1-y, x) 743 - // // But we need the physical top-left of the region 744 - // (WIDTH - y - h, x, h, w) 745 - // } 746 - // Rotation::Deg180 => (WIDTH - x - w, HEIGHT - y - h, w, h), 747 - // Rotation::Deg270 => (y, HEIGHT - x - w, h, w), 748 - // } 749 - // } 750 - // 751 - // fn set_partial_ram_area(&mut self, x: u16, y: u16, w: u16, h: u16) { 752 - // // Gates are reversed on this display - flip Y 753 - // let y_flipped = HEIGHT - y - h; 754 - // 755 - // // Data entry mode: X increase, Y decrease (for gate reversal) 756 - // self.send_command(cmd::DATA_ENTRY_MODE); 757 - // self.send_data(&[0x01]); 758 - // 759 - // // Set RAM X address start/end 760 - // self.send_command(cmd::SET_RAM_X_RANGE); 761 - // self.send_data(&[ 762 - // (x & 0xFF) as u8, 763 - // (x >> 8) as u8, 764 - // ((x + w - 1) & 0xFF) as u8, 765 - // ((x + w - 1) >> 8) as u8, 766 - // ]); 767 - // 768 - // // Set RAM Y address start/end (reversed) 769 - // self.send_command(cmd::SET_RAM_Y_RANGE); 770 - // self.send_data(&[ 771 - // ((y_flipped + h - 1) & 0xFF) as u8, 772 - // ((y_flipped + h - 1) >> 8) as u8, 773 - // (y_flipped & 0xFF) as u8, 774 - // (y_flipped >> 8) as u8, 775 - // ]); 776 - // 777 - // // Set RAM X counter 778 - // self.send_command(cmd::SET_RAM_X_COUNTER); 779 - // self.send_data(&[(x & 0xFF) as u8, (x >> 8) as u8]); 780 - // 781 - // // Set RAM Y counter 782 - // self.send_command(cmd::SET_RAM_Y_COUNTER); 783 - // self.send_data(&[ 784 - // ((y_flipped + h - 1) & 0xFF) as u8, 785 - // ((y_flipped + h - 1) >> 8) as u8, 786 - // ]); 787 - // } 788 - // 789 - // fn clear_screen(&mut self, delay: &mut Delay) { 790 - // if !self.init_done { 791 - // self.init_display(delay); 792 - // } 793 - // 794 - // // write white to both buffers 795 - // self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 796 - // self.write_screen_buffer(cmd::WRITE_RAM_RED, 0xFF); 797 - // self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 798 - // self.write_screen_buffer(cmd::WRITE_RAM_BW, 0xFF); 799 - // 800 - // self.update_full(delay); 801 - // self.initial_refresh = false; 802 - // } 803 - // 804 - // fn write_screen_buffer(&mut self, command: u8, value: u8) { 805 - // self.send_command(command); 806 - // // Write in chunks to avoid watchdog issues 807 - // let total = (WIDTH as u32 * HEIGHT as u32 / 8) as usize; 808 - // let chunk_size = 256; 809 - // let chunk = [value; 256]; 810 - // for i in (0..total).step_by(chunk_size) { 811 - // let len = (total - i).min(chunk_size); 812 - // self.send_data(&chunk[..len]); 813 - // } 814 - // } 815 - // 816 - // fn update_full(&mut self, delay: &mut Delay) { 817 - // self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 818 - // self.send_data(&[0x40, 0x00]); 819 - // 820 - // self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 821 - // self.send_data(&[0xF7]); 822 - // 823 - // self.send_command(cmd::MASTER_ACTIVATION); 824 - // self.wait_busy(delay, FULL_REFRESH_TIME_MS); 825 - // 826 - // self.power_is_on = false; 827 - // } 828 - // 829 - // fn update_partial(&mut self, delay: &mut Delay) { 830 - // self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 831 - // self.send_data(&[0x00, 0x00]); 832 - // 833 - // self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 834 - // self.send_data(&[0xFC]); // Partial refresh (uses OTP LUT) 835 - // 836 - // self.send_command(cmd::MASTER_ACTIVATION); 837 - // self.wait_busy(delay, PARTIAL_REFRESH_TIME_MS); 838 - // 839 - // self.power_is_on = true; 840 - // } 841 - // 842 - // fn wait_busy(&mut self, delay: &mut Delay, timeout_ms: u32) { 843 - // let mut elapsed = 0u32; 844 - // while elapsed < timeout_ms { 845 - // if self.busy.is_low().unwrap_or(true) { 846 - // return; 847 - // } 848 - // delay.delay_millis(1); 849 - // elapsed += 1; 850 - // } 851 - // } 852 - // 853 - // fn send_command(&mut self, cmd: u8) { 854 - // let _ = self.dc.set_low(); 855 - // let _ = self.spi.write(&[cmd]); 856 - // let _ = self.dc.set_high(); 857 - // } 858 - // 859 - // fn send_data(&mut self, data: &[u8]) { 860 - // let _ = self.dc.set_high(); 861 - // let _ = self.spi.write(data); 862 - // } 863 - // } 864 - // 865 - // // embedded-graphics integration 866 - // // NOTE: size queriies only, drawing goes through StripBuffer 867 - // 868 - // impl<SPI, DC, RST, BUSY, E> OriginDimensions for DisplayDriver<SPI, DC, RST, BUSY> 869 - // where 870 - // SPI: SpiDevice<Error = E>, 871 - // DC: OutputPin, 872 - // RST: OutputPin, 873 - // BUSY: InputPin, 874 - // { 875 - // fn size(&self) -> Size { 876 - // match self.rotation { 877 - // Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), 878 - // Rotation::Deg90 | Rotation::Deg270 => Size::new(HEIGHT as u32, WIDTH as u32), 879 - // } 880 - // } 881 - // }
+17 -302
src/board/mod.rs
··· 1 - //! XTEink X4 Board Support Package (BSP) 2 - //! 3 - //! ## SPI Bus Sharing 4 - //! 5 - //! The e-paper display and SD card share SPI2 (SCK=GPIO8, MOSI=GPIO10). 6 - //! SD also uses MISO=GPIO7 (display is write-only, ignores MISO). 7 - //! Bus arbitration uses `RefCellDevice` from embedded-hal-bus — safe 8 - //! because we're single-threaded bare-metal and ISRs don't touch SPI. 1 + // XTEink X4 board support package 2 + // 3 + // ESP32C3, SSD1677 800x480 epaper, SD card over shared SPI2 bus. 4 + // ADC resistance ladders for buttons, GPIO3 power button with interrupt. 5 + // SPI bus arbitrated via RefCellDevice (single threaded, no ISR access). 9 6 10 7 pub mod button; 11 8 pub mod display; ··· 37 34 38 35 use crate::kernel::wake; 39 36 40 - // Type Aliases 41 37 pub type SpiBus = spi::master::Spi<'static, Blocking>; 42 38 pub type SharedSpiDevice = RefCellDevice<'static, SpiBus, Output<'static>, Delay>; 43 39 pub type SdSpiDevice = RefCellDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>; 44 40 pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>; 45 41 46 - // Static SPI bus — shared between display and SD card. 47 42 static SPI_BUS: StaticCell<RefCell<SpiBus>> = StaticCell::new(); 48 43 49 - // ── Power button interrupt ───────────────────────────────────── 50 - // 51 - // The power button (GPIO3, active low) is driven by a hardware GPIO 52 - // interrupt instead of timer-based polling. On a falling edge the ISR 53 - // sets the WAKE_BUTTON flag so the CPU wakes from WFI immediately, 54 - // eliminating the 0–10 ms timer latency for this button. 55 - // 56 - // The `Input` lives in a static so the ISR can clear the interrupt 57 - // and the InputDriver can still read the pin level for debouncing. 58 - 44 + // power button lives in a static so the ISR can clear its interrupt 45 + // and InputDriver can read pin level for debounce 59 46 static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None)); 60 47 61 - /// GPIO interrupt handler — shared by all GPIO pins. 62 - /// 63 - /// Handles two interrupt sources: 64 - /// - **Power button (GPIO3)**: Pin is in a static, checked via esp-hal API. 65 - /// - **Display BUSY (GPIO6)**: Pin is owned by DisplayDriver, so the ISR 66 - /// checks and clears via raw register access. Falling edge signals that 67 - /// a display refresh has completed. 48 + // shared GPIO ISR: handles power button (GPIO3) and display BUSY (GPIO6) 68 49 #[esp_hal::handler] 69 50 fn gpio_handler() { 70 - // Power button (GPIO3) — pin in static, use esp-hal API 51 + // power button -- pin in static, cleared via esp_hal API 71 52 critical_section::with(|cs| { 72 53 if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut() { 73 54 if btn.is_interrupt_set() { ··· 77 58 } 78 59 }); 79 60 80 - // Display BUSY (GPIO6) — pin owned by DisplayDriver, check/clear 81 - // via raw register access. Falling edge = refresh complete. 82 - // 83 - // ESP32-C3 GPIO registers: 84 - // STATUS (0x6000_4044): read pending interrupt bits 85 - // STATUS_W1TC (0x6000_404C): write-1-to-clear pending bits 61 + // display BUSY (GPIO6) -- owned by DisplayDriver, so check/clear 62 + // via raw register access since we cannot reach the pin from here 86 63 const GPIO_STATUS: *const u32 = 0x6000_4044 as *const u32; 87 64 const GPIO_STATUS_W1TC: *mut u32 = 0x6000_404C as *mut u32; 88 65 const GPIO6_MASK: u32 = 1 << 6; 89 66 90 - // Safety: single-core ISR context, no concurrent access to these registers. 91 67 unsafe { 92 68 if GPIO_STATUS.read_volatile() & GPIO6_MASK != 0 { 93 69 GPIO_STATUS_W1TC.write_volatile(GPIO6_MASK); ··· 96 72 } 97 73 } 98 74 99 - /// Read the power button level from the ISR-shared static. 100 - /// 101 - /// Returns `true` if the button is physically pressed (pin LOW). 102 - /// Called by `InputDriver::read_raw()` on every input poll cycle. 103 75 pub fn power_button_is_low() -> bool { 104 76 critical_section::with(|cs| { 105 77 POWER_BTN ··· 110 82 }) 111 83 } 112 84 113 - // Hardware Bundles 114 - 115 - /// Input subsystem: ADC for button ladders + battery. 116 - /// 117 - /// The power button is NOT included here — it lives in the 118 - /// `POWER_BTN` static and is read via `power_button_is_low()`. 119 85 pub struct InputHw { 120 86 pub adc: Adc<'static, ADC1<'static>, Blocking>, 121 87 pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, ··· 123 89 pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 124 90 } 125 91 126 - /// Display subsystem hardware. 127 92 pub struct DisplayHw { 128 93 pub epd: Epd, 129 94 } 130 95 131 - /// SD card storage hardware. 132 96 pub struct StorageHw { 133 97 pub sd: SdStorage<SdSpiDevice>, 134 98 } 135 99 136 - /// Complete board hardware. 137 100 pub struct Board { 138 101 pub input: InputHw, 139 102 pub display: DisplayHw, ··· 171 134 172 135 let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg); 173 136 174 - // ── Power button: GPIO interrupt on falling edge ─────── 175 137 let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() }); 176 138 io.set_interrupt_handler(gpio_handler); 177 139 ··· 194 156 } 195 157 } 196 158 197 - /// Initialize SPI bus and all SPI peripherals (display + SD card). 198 - /// 199 - /// Three-phase init: 200 - /// 1. Create bus at 400kHz, send 74-clock preamble 201 - /// 2. Create SD device, probe card (triggers SD init at 400kHz) 202 - /// 3. Speed up to 20MHz, create display device 159 + // three phase SPI init: 400kHz bus -> SD probe -> speed up to 20MHz 203 160 fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) { 204 - // Display GPIO 205 161 let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default()); 206 162 let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default()); 207 163 let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default()); 208 164 209 - // BUSY pin (GPIO6): arm falling-edge interrupt BEFORE passing 210 - // to DisplayDriver. The hardware interrupt stays configured after 211 - // the pin is moved — gpio_handler checks GPIO6 via raw registers 212 - // since it can't access the pin through DisplayDriver. 165 + // arm BUSY falling edge interrupt before handing pin to DisplayDriver; 166 + // the hardware config survives the move, ISR reads GPIO6 via registers 213 167 let mut busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 214 168 busy.listen(Event::FallingEdge); 215 169 info!("display BUSY: GPIO6 interrupt armed (FallingEdge)"); 216 170 217 - // SD card CS on GPIO12 (SPIHD). The X4 uses DIO flash mode so 218 - // GPIO12 is physically free, but esp-hal doesn't expose GPIO12-17 219 - // for ESP32-C3. Drive it via direct register access. 171 + // GPIO12 (flash SPIHD) is free in DIO mode but esp_hal does not 172 + // expose GPIO12-17 on ESP32C3, so we drive CS via raw registers 220 173 let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) }; 221 174 222 - // Phase 1: SPI bus at 400kHz for SD card identification. 223 175 let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400)); 224 176 225 177 let mut spi_bus = spi::master::Spi::new(p.SPI2, slow_cfg) ··· 228 180 .with_mosi(p.GPIO10) 229 181 .with_miso(p.GPIO7); 230 182 231 - // 74+ clock cycles with CS deasserted (SD spec requirement). 232 - // 10 bytes × 8 bits = 80 clocks. 183 + // 10 bytes = 80 clocks with CS high (SD spec init requirement) 233 184 let _ = spi_bus.write(&[0xFF; 10]); 234 185 235 - // Place bus in static RefCell for shared access. 236 186 let spi_ref: &'static RefCell<SpiBus> = SPI_BUS.init(RefCell::new(spi_bus)); 237 187 238 - // Phase 2: SD card init at 400kHz. 239 - // RefCellDevice::new() returns Result<_, Infallible>, always safe. 240 188 let sd_spi = RefCellDevice::new(spi_ref, sd_cs, Delay::new()).unwrap(); 241 - // SdStorage::new() probes the card internally (calls num_bytes()). 242 189 let sd = SdStorage::new(sd_spi); 243 190 244 - // Phase 3: Speed up to 20MHz for display + normal SD operations. 245 191 let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 246 192 spi_ref.borrow_mut().apply_config(&fast_cfg).unwrap(); 247 193 info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ); 248 194 249 - // Create display device on the shared bus. 250 195 let epd_spi = RefCellDevice::new(spi_ref, epd_cs, Delay::new()).unwrap(); 251 196 let epd = DisplayDriver::new(epd_spi, dc, rst, busy); 252 197 253 198 (DisplayHw { epd }, StorageHw { sd }) 254 199 } 255 200 } 256 - // //! XTEink X4 Board Support Package (BSP) 257 - // //! 258 - // //! ## SPI Bus Sharing 259 - // //! 260 - // //! The e-paper display and SD card share SPI2 (SCK=GPIO8, MOSI=GPIO10). 261 - // //! SD also uses MISO=GPIO7 (display is write-only, ignores MISO). 262 - // //! Bus arbitration uses `RefCellDevice` from embedded-hal-bus — safe 263 - // //! because we're single-threaded bare-metal and ISRs don't touch SPI. 264 - // 265 - // pub mod button; 266 - // pub mod display; 267 - // pub mod pins; 268 - // pub mod raw_gpio; 269 - // pub mod sdcard; 270 - // pub mod strip; 271 - // 272 - // pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder}; 273 - // pub use display::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH}; 274 - // pub use sdcard::SdStorage; 275 - // pub use strip::StripBuffer; 276 - // 277 - // use core::cell::RefCell; 278 - // 279 - // use critical_section::Mutex; 280 - // use embedded_hal_bus::spi::RefCellDevice; 281 - // use esp_hal::{ 282 - // Blocking, 283 - // analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation}, 284 - // delay::Delay, 285 - // gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull}, 286 - // peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals}, 287 - // spi, 288 - // time::Rate, 289 - // }; 290 - // use log::info; 291 - // use static_cell::StaticCell; 292 - // 293 - // use crate::kernel::wake; 294 - // 295 - // // Type Aliases 296 - // pub type SpiBus = spi::master::Spi<'static, Blocking>; 297 - // pub type SharedSpiDevice = RefCellDevice<'static, SpiBus, Output<'static>, Delay>; 298 - // pub type SdSpiDevice = RefCellDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>; 299 - // pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>; 300 - // 301 - // // Static SPI bus — shared between display and SD card. 302 - // static SPI_BUS: StaticCell<RefCell<SpiBus>> = StaticCell::new(); 303 - // 304 - // // ── Power button interrupt ───────────────────────────────────── 305 - // // 306 - // // The power button (GPIO3, active low) is driven by a hardware GPIO 307 - // // interrupt instead of timer-based polling. On a falling edge the ISR 308 - // // sets the WAKE_BUTTON flag so the CPU wakes from WFI immediately, 309 - // // eliminating the 0–10 ms timer latency for this button. 310 - // // 311 - // // The `Input` lives in a static so the ISR can clear the interrupt 312 - // // and the InputDriver can still read the pin level for debouncing. 313 - // 314 - // static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None)); 315 - // 316 - // /// GPIO interrupt handler — shared by all GPIO pins. 317 - // /// 318 - // /// Only the power button is currently wired. The handler checks 319 - // /// `is_interrupt_set()` to confirm GPIO3 fired, clears the status, 320 - // /// and signals the wake system. The interrupt stays armed (we call 321 - // /// `clear_interrupt`, not `unlisten`) so subsequent edges fire too. 322 - // #[esp_hal::handler] 323 - // fn gpio_handler() { 324 - // critical_section::with(|cs| { 325 - // if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut() { 326 - // if btn.is_interrupt_set() { 327 - // btn.clear_interrupt(); 328 - // wake::signal_button(); 329 - // } 330 - // } 331 - // }); 332 - // } 333 - // 334 - // /// Read the power button level from the ISR-shared static. 335 - // /// 336 - // /// Returns `true` if the button is physically pressed (pin LOW). 337 - // /// Called by `InputDriver::read_raw()` on every input poll cycle. 338 - // pub fn power_button_is_low() -> bool { 339 - // critical_section::with(|cs| { 340 - // POWER_BTN 341 - // .borrow_ref_mut(cs) 342 - // .as_mut() 343 - // .map(|btn| btn.is_low()) 344 - // .unwrap_or(false) 345 - // }) 346 - // } 347 - // 348 - // // Hardware Bundles 349 - // 350 - // /// Input subsystem: ADC for button ladders + battery. 351 - // /// 352 - // /// The power button is NOT included here — it lives in the 353 - // /// `POWER_BTN` static and is read via `power_button_is_low()`. 354 - // pub struct InputHw { 355 - // pub adc: Adc<'static, ADC1<'static>, Blocking>, 356 - // pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 357 - // pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 358 - // pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 359 - // } 360 - // 361 - // /// Display subsystem hardware. 362 - // pub struct DisplayHw { 363 - // pub epd: Epd, 364 - // } 365 - // 366 - // /// SD card storage hardware. 367 - // pub struct StorageHw { 368 - // pub sd: SdStorage<SdSpiDevice>, 369 - // } 370 - // 371 - // /// Complete board hardware. 372 - // pub struct Board { 373 - // pub input: InputHw, 374 - // pub display: DisplayHw, 375 - // pub storage: StorageHw, 376 - // } 377 - // 378 - // impl Board { 379 - // pub fn init(p: Peripherals) -> Self { 380 - // let input = Self::init_input(&p); 381 - // let (display, storage) = Self::init_spi_peripherals(p); 382 - // Board { 383 - // input, 384 - // display, 385 - // storage, 386 - // } 387 - // } 388 - // 389 - // fn init_input(p: &Peripherals) -> InputHw { 390 - // let mut adc_cfg = AdcConfig::new(); 391 - // 392 - // let row1 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 393 - // unsafe { p.GPIO1.clone_unchecked() }, 394 - // Attenuation::_11dB, 395 - // ); 396 - // 397 - // let row2 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 398 - // unsafe { p.GPIO2.clone_unchecked() }, 399 - // Attenuation::_11dB, 400 - // ); 401 - // 402 - // let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 403 - // unsafe { p.GPIO0.clone_unchecked() }, 404 - // Attenuation::_11dB, 405 - // ); 406 - // 407 - // let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg); 408 - // 409 - // // ── Power button: GPIO interrupt on falling edge ─────── 410 - // let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() }); 411 - // io.set_interrupt_handler(gpio_handler); 412 - // 413 - // let mut power = Input::new( 414 - // unsafe { p.GPIO3.clone_unchecked() }, 415 - // InputConfig::default().with_pull(Pull::Up), 416 - // ); 417 - // power.listen(Event::FallingEdge); 418 - // 419 - // critical_section::with(|cs| { 420 - // POWER_BTN.borrow_ref_mut(cs).replace(power); 421 - // }); 422 - // info!("power button: GPIO3 interrupt armed (FallingEdge)"); 423 - // 424 - // InputHw { 425 - // adc, 426 - // row1, 427 - // row2, 428 - // battery, 429 - // } 430 - // } 431 - // 432 - // /// Initialize SPI bus and all SPI peripherals (display + SD card). 433 - // /// 434 - // /// Three-phase init: 435 - // /// 1. Create bus at 400kHz, send 74-clock preamble 436 - // /// 2. Create SD device, probe card (triggers SD init at 400kHz) 437 - // /// 3. Speed up to 20MHz, create display device 438 - // fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) { 439 - // // Display GPIO 440 - // let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default()); 441 - // let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default()); 442 - // let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default()); 443 - // let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 444 - // 445 - // // SD card CS on GPIO12 (SPIHD). The X4 uses DIO flash mode so 446 - // // GPIO12 is physically free, but esp-hal doesn't expose GPIO12-17 447 - // // for ESP32-C3. Drive it via direct register access. 448 - // let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) }; 449 - // 450 - // // Phase 1: SPI bus at 400kHz for SD card identification. 451 - // let slow_cfg = spi::master::Config::default() 452 - // .with_frequency(Rate::from_khz(400)); 453 - // 454 - // let mut spi_bus = spi::master::Spi::new(p.SPI2, slow_cfg) 455 - // .unwrap() 456 - // .with_sck(p.GPIO8) 457 - // .with_mosi(p.GPIO10) 458 - // .with_miso(p.GPIO7); 459 - // 460 - // // 74+ clock cycles with CS deasserted (SD spec requirement). 461 - // // 10 bytes × 8 bits = 80 clocks. 462 - // let _ = spi_bus.write(&[0xFF; 10]); 463 - // 464 - // // Place bus in static RefCell for shared access. 465 - // let spi_ref: &'static RefCell<SpiBus> = SPI_BUS.init(RefCell::new(spi_bus)); 466 - // 467 - // // Phase 2: SD card init at 400kHz. 468 - // // RefCellDevice::new() returns Result<_, Infallible>, always safe. 469 - // let sd_spi = RefCellDevice::new(spi_ref, sd_cs, Delay::new()).unwrap(); 470 - // // SdStorage::new() probes the card internally (calls num_bytes()). 471 - // let sd = SdStorage::new(sd_spi); 472 - // 473 - // // Phase 3: Speed up to 20MHz for display + normal SD operations. 474 - // let fast_cfg = spi::master::Config::default() 475 - // .with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 476 - // spi_ref.borrow_mut().apply_config(&fast_cfg).unwrap(); 477 - // info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ); 478 - // 479 - // // Create display device on the shared bus. 480 - // let epd_spi = RefCellDevice::new(spi_ref, epd_cs, Delay::new()).unwrap(); 481 - // let epd = DisplayDriver::new(epd_spi, dc, rst, busy); 482 - // 483 - // (DisplayHw { epd }, StorageHw { sd }) 484 - // } 485 - // }
+21 -26
src/board/pins.rs
··· 1 - //! GPIO | Function | Notes 2 - //! -----+-----------------+---------------------------------- 3 - //! 0 | ADC - Battery | Battery voltage sense (if wired) 4 - //! 1 | ADC1 - Button 2 | Resistance ladder: Right/Left/Confirm/Back 5 - //! 2 | ADC2 - Button 1 | Resistance ladder: Volume Up/Down 6 - //! 3 | Digital - Power | Active LOW, internal pullup 7 - //! 4 | EPD DC | Data/Command select 8 - //! 5 | EPD RST | Reset (active low) 9 - //! 6 | EPD BUSY | Busy signal from display 10 - //! 7 | SPI2 MISO | SD card data in (display is write-only) 11 - //! 8 | SPI2 SCK | Shared SPI clock 12 - //! 10 | SPI2 MOSI | Shared SPI data out 13 - //! 12 | SD CS | SD card chip select (flash pin, DIO mode frees it) 14 - //! 21 | EPD CS | Display chip select 1 + // GPIO pin assignments for XTEink X4 (ESP32-C3) 2 + // 3 + // GPIO Function Notes 4 + // 0 ADC battery voltage divider, 100K/100K 5 + // 1 ADC row1 resistance ladder: Right/Left/Confirm/Back 6 + // 2 ADC row2 resistance ladder: VolUp/VolDown 7 + // 3 power button active low, internal pullup 8 + // 4 EPD DC data/command select 9 + // 5 EPD RST active low 10 + // 6 EPD BUSY high while controller is working 11 + // 7 SPI MISO SD card only, display is write only 12 + // 8 SPI SCK shared clock 13 + // 10 SPI MOSI shared data out 14 + // 12 SD CS flash pin SPIHD, free in DIO mode 15 + // 21 EPD CS display chip select 15 16 16 - // ----- E-Paper Display ----- 17 17 pub const EPD_CS: u8 = 21; 18 18 pub const EPD_DC: u8 = 4; 19 19 pub const EPD_RST: u8 = 5; 20 20 pub const EPD_BUSY: u8 = 6; 21 21 22 - // ----- SPI Bus (shared) ----- 23 22 pub const SPI_SCK: u8 = 8; 24 23 pub const SPI_MOSI: u8 = 10; 25 - pub const SPI_MISO: u8 = 7; // SD card only; display ignores MISO 24 + pub const SPI_MISO: u8 = 7; 26 25 27 - // ----- SD Card ----- 28 - pub const SD_CS: u8 = 12; // GPIO12 — flash SPIHD pin, free in DIO mode 26 + pub const SD_CS: u8 = 12; 29 27 30 - // ----- Battery ADC ----- 31 - pub const BAT_ADC: u8 = 0; // GPIO0 - Battery voltage (via divider) 28 + pub const BAT_ADC: u8 = 0; 32 29 33 - // ----- Buttons (ADC) ----- 34 - pub const BTN_ROW1_ADC: u8 = 1; // GPIO1 - Right/Left/Confirm/Back 35 - pub const BTN_ROW2_ADC: u8 = 2; // GPIO2 - Vol Up/Down 30 + pub const BTN_ROW1_ADC: u8 = 1; 31 + pub const BTN_ROW2_ADC: u8 = 2; 36 32 37 - // ----- Power Button ----- 38 - pub const BTN_POWER: u8 = 3; // Digital, active LOW 33 + pub const BTN_POWER: u8 = 3;
+15 -31
src/board/raw_gpio.rs
··· 1 - //! Raw GPIO output for pins not exposed by esp-hal (e.g. flash pins on ESP32-C3). 2 - //! 3 - //! The XTEink X4 uses DIO flash mode, freeing GPIO12 (SPIHD) and GPIO13 (SPIWP) 4 - //! for general use. esp-hal 1.0 doesn't generate peripheral types for GPIO12-17 5 - //! on ESP32-C3, so we drive the pin via direct register writes. 6 - //! 7 - //! Only implements `embedded_hal::digital::OutputPin` — enough for SPI CS. 1 + // Direct register GPIO for pins esp-hal does not expose 2 + // 3 + // ESP32-C3 in DIO flash mode frees GPIO12 (SPIHD) and GPIO13 (SPIWP). 4 + // esp-hal 1.0 has no peripheral types for GPIO12..17 on this chip, 5 + // so we bang the registers ourselves. Only OutputPin is implemented. 8 6 9 - const GPIO_OUT_W1TS: u32 = 0x6000_4008; // Set output high (write-1-to-set) 10 - const GPIO_OUT_W1TC: u32 = 0x6000_400C; // Set output low (write-1-to-clear) 11 - const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // Enable output (write-1-to-set) 12 - const IO_MUX_BASE: u32 = 0x6000_9000; // IO_MUX register base 13 - const IO_MUX_PIN_STRIDE: u32 = 0x04; // Each pin has a 4-byte register 7 + const GPIO_OUT_W1TS: u32 = 0x6000_4008; // write 1 to set output high 8 + const GPIO_OUT_W1TC: u32 = 0x6000_400C; // write 1 to set output low 9 + const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // write 1 to enable output 10 + const IO_MUX_BASE: u32 = 0x6000_9000; 11 + const IO_MUX_PIN_STRIDE: u32 = 0x04; 14 12 15 - /// Minimal output-only GPIO driver using direct register access. 16 - /// 17 - /// Implements `OutputPin` + `ErrorType` from embedded-hal so it can 18 - /// be used as a chip-select pin with `RefCellDevice` / `ExclusiveDevice`. 19 13 pub struct RawOutputPin { 20 - mask: u32, // Bit mask for this pin (1 << pin_number) 14 + mask: u32, 21 15 } 22 16 23 17 impl RawOutputPin { 24 - /// Configure a GPIO as push-pull output, initially HIGH. 25 - /// 26 - /// # Safety 27 - /// Caller must ensure: 28 - /// - The pin is physically available (not connected to active flash lines) 29 - /// - No other driver is controlling the same pin 18 + // Pin must not be in active use by flash or another driver. 30 19 pub unsafe fn new(pin: u8) -> Self { 31 20 let mask = 1u32 << pin; 32 21 33 - // Configure IO_MUX: select GPIO function (function 1), enable output 34 22 let mux_reg = (IO_MUX_BASE + pin as u32 * IO_MUX_PIN_STRIDE) as *mut u32; 35 23 36 - // Read-modify-write to preserve reserved bits, set function to GPIO. 37 - // Bits [2:0] = MCU_SEL = 1 (GPIO function) 38 24 unsafe { 25 + // IO_MUX: MCU_SEL[2:0] = 1 selects GPIO function 39 26 let val = mux_reg.read_volatile(); 40 27 let val = (val & !0b111) | 1; 41 28 mux_reg.write_volatile(val); 42 29 43 - // Configure GPIO matrix: enable output for this pin 44 - // GPIO_FUNCn_OUT_SEL_CFG register (base 0x60004554, stride 4) 30 + // GPIO_FUNCn_OUT_SEL_CFG: 0x80 = simple GPIO output 45 31 let out_sel = (0x6000_4554 + pin as u32 * 4) as *mut u32; 46 - out_sel.write_volatile(0x80); // SIG_OUT = 128 (simple GPIO output) 32 + out_sel.write_volatile(0x80); 47 33 48 - // Enable output 49 34 (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask); 50 35 51 - // Drive HIGH initially (CS deasserted) 52 36 (GPIO_OUT_W1TS as *mut u32).write_volatile(mask); 53 37 } 54 38
+4 -13
src/board/sdcard.rs
··· 1 - //! SD Card support for XTEink X4 2 - //! 3 - //! The SD card shares the SPI2 bus with the e-paper display. 4 - //! Bus arbitration is handled at the board level using RefCellDevice. 1 + // SD card over SPI with FAT volume manager 2 + // No RTC on board; timestamps are fixed to 2025-01-01. 3 + 5 4 use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeManager}; 6 5 use log::info; 7 6 8 - // Dummy time source for FAT timestamps (X4 has no RTC). 9 7 #[derive(Default, Clone, Copy)] 10 8 pub struct DummyTimeSource; 11 9 12 10 impl TimeSource for DummyTimeSource { 13 11 fn get_timestamp(&self) -> Timestamp { 14 12 Timestamp { 15 - year_since_1970: 55, // 2025 13 + year_since_1970: 55, 16 14 zero_indexed_month: 0, 17 15 zero_indexed_day: 0, 18 16 hours: 0, ··· 22 20 } 23 21 } 24 22 25 - // sd card initialization frequency (Hz). 26 23 pub const SD_INIT_FREQ_HZ: u32 = 400_000; 27 24 28 - // Normal operating frequency after init 29 - // TODO: Put this somewhere else? 30 25 pub const SD_NORMAL_FREQ_HZ: u32 = 20_000_000; 31 26 32 - // Wrapper that holds the SdCard + VolumeManager together. 33 27 pub struct SdStorage<SPI> 34 28 where 35 29 SPI: embedded_hal::spi::SpiDevice, ··· 41 35 where 42 36 SPI: embedded_hal::spi::SpiDevice, 43 37 { 44 - // Create SD storage, probing the card during construction. 45 38 pub fn new(spi: SPI) -> Self { 46 39 let sdcard = SdCard::new(spi, esp_hal::delay::Delay::new()); 47 40 48 - // Probe card before handing ownership to VolumeManager. 49 - // This triggers the SD init sequence (CMD0, CMD8, ACMD41, etc). 50 41 match sdcard.num_bytes() { 51 42 Ok(bytes) => info!("SD card: {} bytes ({} MB)", bytes, bytes / 1024 / 1024), 52 43 Err(e) => info!("SD card probe failed: {:?}", e),
+12 -47
src/board/strip.rs
··· 1 - // Strip-based rendering buffer 1 + // Strip based rendering buffer for e-paper 2 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 3 + // Renders through a 4KB strip instead of a full 48KB framebuffer. 4 + // The display is split into horizontal bands; each band is cleared, 5 + // drawn into by all visible widgets, then sent over SPI. Widgets 6 + // always draw to full logical screen coords; clipping happens here. 7 + // 8 + // Two modes: 9 + // begin_strip() -- full width, fixed height (full page refresh) 10 + // begin_window() -- arbitrary rect (partial refresh) 12 11 13 12 use embedded_graphics_core::{ 14 13 Pixel, ··· 19 18 20 19 use super::display::{HEIGHT, Rotation, WIDTH}; 21 20 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 21 + pub const STRIP_ROWS: u16 = 40; // 800/8 * 40 = 4000B per strip 22 + pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8; 26 23 27 24 pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000 28 25 pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12 29 26 30 - // A small rendering buffer that covers a physical rectangle of the display. 31 - // 32 - // Operates in two modes: 33 - // - Full-width strips: For full-page rendering. Covers 800×40 physical 34 - // pixels (4000 bytes). Iterate 12 strips top-to-bottom. 35 - // - Windowed: For partial refresh of small UI regions. Covers an 36 - // arbitrary physical rectangle that fits within STRIP_BUF_SIZE bytes. 37 27 pub struct StripBuffer { 38 28 buf: [u8; STRIP_BUF_SIZE], 39 29 rotation: Rotation, 40 - // Physical window this strip covers 41 30 win_x: u16, 42 31 win_y: u16, 43 32 win_w: u16, 44 33 win_h: u16, 45 - // Derived from win_w for indexing 46 34 row_bytes: u16, 47 35 } 48 36 49 37 impl StripBuffer { 50 38 pub const fn new() -> Self { 51 39 Self { 52 - buf: [0xFF; STRIP_BUF_SIZE], // White 40 + buf: [0xFF; STRIP_BUF_SIZE], 53 41 rotation: Rotation::Deg270, 54 42 win_x: 0, 55 43 win_y: 0, ··· 59 47 } 60 48 } 61 49 62 - // Configure for full-width strip rendering at the given strip index. 63 - // Clears the buffer to white. 64 - // 65 - // strip_idx: 0..STRIP_COUNT (physical row bands top-to-bottom) 66 50 pub fn begin_strip(&mut self, rotation: Rotation, strip_idx: u16) { 67 51 self.rotation = rotation; 68 52 self.win_x = 0; ··· 71 55 self.win_h = STRIP_ROWS; 72 56 self.row_bytes = PHYS_BYTES_PER_ROW as u16; 73 57 74 - // Clear to white 75 58 self.buf[..STRIP_BUF_SIZE].fill(0xFF); 76 59 } 77 60 78 - // Configure for an arbitrary physical window (partial refresh mode). 79 - // Region must be byte-aligned (x and w multiples of 8). 80 - // NOTE: Panics if the window doesn't fit in STRIP_BUF_SIZE. 81 61 pub fn begin_window(&mut self, rotation: Rotation, x: u16, y: u16, w: u16, h: u16) { 82 62 let rb = (w / 8) as usize; 83 63 let total = rb * h as usize; ··· 100 80 self.buf[..total].fill(0xFF); 101 81 } 102 82 103 - // Get the valid data bytes for SPI transfer. 104 - // Only the bytes covering the current window are returned. 105 83 pub fn data(&self) -> &[u8] { 106 84 let total = self.row_bytes as usize * self.win_h as usize; 107 85 &self.buf[..total] 108 86 } 109 87 110 - // Current window's physical origin and size. 111 88 pub fn window(&self) -> (u16, u16, u16, u16) { 112 89 (self.win_x, self.win_y, self.win_w, self.win_h) 113 90 } ··· 116 93 STRIP_COUNT 117 94 } 118 95 119 - // Max rows that fit in the buffer at a given window width. 120 96 pub fn max_rows_for_width(width: u16) -> u16 { 121 97 let rb = (width / 8) as usize; 122 98 if rb == 0 { ··· 125 101 (STRIP_BUF_SIZE / rb) as u16 126 102 } 127 103 128 - // Transform logical coordinates to physical based on rotation. 129 104 #[inline] 130 105 fn to_physical(&self, lx: u16, ly: u16) -> (u16, u16) { 131 106 match self.rotation { ··· 136 111 } 137 112 } 138 113 139 - // set a pixel in the buffer using physical coordinates. 140 - // silently clips if outside current window. 141 114 #[inline] 142 115 fn set_pixel_physical(&mut self, px: u16, py: u16, black: bool) { 143 - // clip to window 144 116 if px < self.win_x || px >= self.win_x + self.win_w { 145 117 return; 146 118 } ··· 167 139 } 168 140 } 169 141 170 - // embedded-graphics integration 171 - 172 142 impl OriginDimensions for StripBuffer { 173 - // Report FULL logical display size. 174 - // Widgets think they're drawing to the entire screen; 175 - // the strip clips at the physical level. 176 143 fn size(&self) -> Size { 177 144 match self.rotation { 178 145 Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32), ··· 194 161 let log_h = size.height as i32; 195 162 196 163 for Pixel(coord, color) in pixels { 197 - // Bounds check against logical dimensions 198 164 if coord.x < 0 || coord.x >= log_w || coord.y < 0 || coord.y >= log_h { 199 165 continue; 200 166 } 201 167 202 - // Transform logical → physical, then clip to current strip 203 168 let (px, py) = self.to_physical(coord.x as u16, coord.y as u16); 204 169 self.set_pixel_physical(px, py, color == BinaryColor::On); 205 170 }
+5 -9
src/drivers/battery.rs
··· 1 - //! Battery monitoring for XTEink X4 2 - //! 3 - //! GPIO0 reads battery voltage through an on-board voltage divider (1:1, 100K/100K). 4 - //! ADC with 11dB attenuation reads 0-2500mV; multiply by 2 for actual battery voltage. 5 - //! Li-ion cell: 4200mV = 100%, 3000mV = 0%. 1 + // Li-ion battery voltage estimation 2 + // 3 + // GPIO0 reads through a 100K/100K divider (2:1). ADC with 11dB 4 + // attenuation gives 0..2500mV; multiply by 2 for actual cell voltage. 5 + // Linear approximation: 4200mV = 100%, 3000mV = 0%. 6 6 7 - /// Voltage divider ratio (100K/100K = 2:1) 8 7 const DIVIDER_MULT: u32 = 2; 9 8 10 - /// Li-ion voltage bounds in millivolts 11 9 const VBAT_FULL_MV: u32 = 4200; 12 10 const VBAT_EMPTY_MV: u32 = 3000; 13 11 14 - /// Convert ADC millivolts (post-calibration) to actual battery millivolts. 15 12 pub fn adc_to_battery_mv(adc_mv: u16) -> u16 { 16 13 (adc_mv as u32 * DIVIDER_MULT) as u16 17 14 } 18 15 19 - /// Battery voltage to charge percentage (0-100), linear approximation. 20 16 pub fn battery_percentage(battery_mv: u16) -> u8 { 21 17 let mv = battery_mv as u32; 22 18 if mv >= VBAT_FULL_MV {
+7 -27
src/drivers/input.rs
··· 1 - // Input event driver for xteink x4 1 + // Debounced input from ADC ladders and power button 2 + // 3 + // Three sources, one button at a time (hardware limitation of ladders): 4 + // Row1 ADC (GPIO1): Right, Left, Confirm, Back 5 + // Row2 ADC (GPIO2): VolUp, VolDown 6 + // Power (GPIO3): interrupt driven, read via board::power_button_is_low() 2 7 // 3 - // The X4 has three physical input sources that all funnel into a 4 - // single "one button at a time" deal: 5 - // - Row 1 ADC (GPIO1): Right, Left, Confirm, Back via resistance ladder 6 - // - Row 2 ADC (GPIO2): Volume Up/Down via resistance ladder 7 - // - Power button (GPIO3): Interrupt-driven, read via board::power_button_is_low() 8 - // NOTE: Because each resistance ladder can only report one press at a time, 9 - // we collapse everything into `Option<Button>` per poll cycle. 8 + // 30ms debounce, 1s long press, 150ms repeat. 10 9 11 10 use esp_hal::time::{Duration, Instant}; 12 11 ··· 25 24 Repeat(Button), 26 25 } 27 26 28 - // Small fixed-size event queue for buffering multiple events per poll. 29 27 struct EventQueue { 30 28 buf: [Option<Event>; 2], 31 29 read: u8, ··· 46 44 return; 47 45 } 48 46 } 49 - // If both slots are full, something is wrong with our logic. 50 47 } 51 48 52 49 fn pop(&mut self) -> Option<Event> { ··· 57 54 return Some(ev); 58 55 } 59 56 } 60 - // Reset for next cycle 61 57 self.read = 0; 62 58 None 63 59 } ··· 67 63 } 68 64 } 69 65 70 - // debounce, long-press, and repeat support. 71 66 pub struct InputDriver { 72 67 hw: InputHw, 73 68 stable: Option<Button>, ··· 94 89 } 95 90 } 96 91 97 - // poll for the next input event. 98 92 pub fn poll(&mut self) -> Option<Event> { 99 - // drain any buffd events first 100 93 if !self.queue.is_empty() { 101 94 return self.queue.pop(); 102 95 } ··· 115 108 self.stable 116 109 }; 117 110 118 - // normal press 119 111 if debounced != self.stable { 120 112 if let Some(old) = self.stable { 121 113 self.queue.push(Event::Release(old)); ··· 130 122 return self.queue.pop(); 131 123 } 132 124 133 - // long press and repeat 134 125 if let Some(btn) = self.stable { 135 126 let held = now - self.press_since; 136 127 ··· 140 131 return Some(Event::LongPress(btn)); 141 132 } 142 133 143 - // Fire repeat events at interval 144 134 if self.long_press_fired && (now - self.last_repeat) >= Duration::from_millis(REPEAT_MS) 145 135 { 146 136 self.last_repeat = now; ··· 151 141 None 152 142 } 153 143 154 - /// Read raw button state from hardware (before debouncing). 155 144 fn read_raw(&mut self) -> Option<Button> { 156 - // Power button: interrupt-driven, read level from shared static. 157 - // The GPIO interrupt already woke us via signal_button(); 158 - // here we just need the current pin state for debounce. 159 145 if crate::board::power_button_is_low() { 160 146 return Some(Button::Power); 161 147 } 162 148 163 - // read adc channels & decode 164 149 let mv1: u16 = nb::block!(self.hw.adc.read_oneshot(&mut self.hw.row1)).unwrap(); 165 150 let mv2: u16 = nb::block!(self.hw.adc.read_oneshot(&mut self.hw.row2)).unwrap(); 166 151 167 152 decode_ladder(mv1, ROW1_THRESHOLDS).or_else(|| decode_ladder(mv2, ROW2_THRESHOLDS)) 168 153 } 169 154 170 - /// Read battery voltage in millivolts (ADC-calibrated, before divider correction). 171 - /// The X4 has a voltage divider on GPIO0 — multiply by 2 for actual battery mV. 172 155 pub fn read_battery_mv(&mut self) -> u16 { 173 156 nb::block!(self.hw.adc.read_oneshot(&mut self.hw.battery)).unwrap() 174 157 } 175 158 176 - /// True when raw button activity was detected but debounce hasn't 177 - /// confirmed it yet. Used by the idle timer to snap back to fast 178 - /// polling so the confirmation arrives in ~10ms, not ~100ms. 179 159 pub fn is_debouncing(&self) -> bool { 180 160 self.candidate.is_some() && self.candidate != self.stable 181 161 }
+2 -1
src/drivers/mod.rs
··· 1 + // Hardware abstraction for battery, input, and SD storage 2 + 1 3 pub mod battery; 2 4 pub mod input; 3 5 pub mod storage; 4 - // pub mod display_driver;
+5 -49
src/drivers/storage.rs
··· 1 - //! High-level file operations for the SD card. 2 - //! 3 - //! Uses embedded-sdmmc 0.9's RAII handles (Volume, Directory, File) 4 - //! which close automatically on drop. 1 + // SD card file operations and directory cache 2 + // 3 + // FAT directory iteration has no seek; every listing scans from entry 0. 4 + // DirCache reads all entries once into RAM and serves pages from there. 5 + // 128 entries * 20 bytes = 2.5KB of SRAM. 5 6 6 7 use embedded_sdmmc::{Mode, VolumeIdx}; 7 8 8 9 use crate::board::sdcard::SdStorage; 9 10 10 - /// A single directory entry, small enough to keep a page on the stack. 11 11 #[derive(Clone, Copy)] 12 12 pub struct DirEntry { 13 13 pub name: [u8; 13], ··· 29 29 } 30 30 } 31 31 32 - /// Result of a paginated directory listing. 33 32 pub struct DirPage { 34 33 pub total: usize, 35 34 pub count: usize, 36 35 } 37 36 38 - // ── Directory cache ──────────────────────────────────────────── 39 - // 40 - // FAT directory iteration has no seek — every list_page() must scan 41 - // from the first entry and skip. For scroll position 40, that's 42 - // 40 entries read and discarded. With 100ms+ per SD transaction, 43 - // scrolling feels sluggish. 44 - // 45 - // The cache reads ALL entries once and serves pages from RAM. 46 - // Subsequent scrolls are pure memory copies — instant. 47 - // 48 - // Memory: 128 entries × 20 bytes = 2.5KB (of 400KB SRAM). 49 - 50 - /// Maximum directory entries we'll cache. 51 37 pub const MAX_DIR_ENTRIES: usize = 128; 52 38 53 - /// In-memory cache of a directory's entries. 54 - /// 55 - /// Created once in main.rs, lives for the lifetime of the program. 56 - /// `ensure_loaded()` fills it from SD on first access; `page()` 57 - /// serves slices without touching hardware. 58 39 pub struct DirCache { 59 40 entries: [DirEntry; MAX_DIR_ENTRIES], 60 41 count: usize, ··· 70 51 } 71 52 } 72 53 73 - /// Load all entries from the root directory if not already cached. 74 - /// Returns Ok(()) if cache is warm (already valid), or after a 75 - /// successful SD read. Returns Err only on SD failure. 76 54 pub fn ensure_loaded<SPI>(&mut self, sd: &SdStorage<SPI>) -> Result<(), &'static str> 77 55 where 78 56 SPI: embedded_hal::spi::SpiDevice, ··· 111 89 Ok(()) 112 90 } 113 91 114 - /// Copy a page of entries into `buf`, starting at `skip`. 115 - /// Pure memory operation — no SD access. 116 92 pub fn page(&self, skip: usize, buf: &mut [DirEntry]) -> DirPage { 117 93 let available = self.count.saturating_sub(skip); 118 94 let count = available.min(buf.len()); ··· 125 101 } 126 102 } 127 103 128 - /// Mark cache as stale. Next `ensure_loaded()` will re-read from SD. 129 104 pub fn invalidate(&mut self) { 130 105 self.valid = false; 131 106 } 132 107 133 - /// Total cached entries (0 if not loaded). 134 108 pub fn total(&self) -> usize { 135 109 self.count 136 110 } 137 111 138 - /// Whether the cache has been loaded. 139 112 pub fn is_valid(&self) -> bool { 140 113 self.valid 141 114 } 142 115 } 143 116 144 - /// List one page of entries from root directory. 145 - /// 146 - /// Skips the first `skip` entries, then fills `buf` with up to `buf.len()` entries. 147 - /// Returns total entry count and how many were written to buf. 148 117 pub fn list_page<SPI>( 149 118 sd: &SdStorage<SPI>, 150 119 skip: usize, ··· 164 133 let page_size = buf.len(); 165 134 166 135 root.iterate_dir(|entry| { 167 - // Skip dot entries 168 136 if entry.name.base_name()[0] == b'.' { 169 137 return; 170 138 } ··· 190 158 }) 191 159 } 192 160 193 - /// List files in the root directory, calling `cb` for each entry. 194 - /// Returns the number of entries found. 195 161 pub fn list_root_dir<SPI>( 196 162 sd: &SdStorage<SPI>, 197 163 mut cb: impl FnMut(&str, bool, u32), ··· 219 185 Ok(count) 220 186 } 221 187 222 - /// Get the size of a file in the root directory. 223 188 pub fn file_size<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<u32, &'static str> 224 189 where 225 190 SPI: embedded_hal::spi::SpiDevice, ··· 236 201 Ok(file.length()) 237 202 } 238 203 239 - /// Read an entire file (or up to buf.len() bytes) into a buffer. 240 - /// Returns the number of bytes read. 241 204 pub fn read_file<SPI>( 242 205 sd: &SdStorage<SPI>, 243 206 name: &str, ··· 267 230 Ok(total) 268 231 } 269 232 270 - /// Read a chunk of a file starting at `offset`. 271 - /// Returns the number of bytes read. 272 233 pub fn read_file_chunk<SPI>( 273 234 sd: &SdStorage<SPI>, 274 235 name: &str, ··· 301 262 Ok(total) 302 263 } 303 264 304 - /// Write data to a file (create or truncate). 305 265 pub fn write_file<SPI>(sd: &SdStorage<SPI>, name: &str, data: &[u8]) -> Result<(), &'static str> 306 266 where 307 267 SPI: embedded_hal::spi::SpiDevice, ··· 321 281 Ok(()) 322 282 } 323 283 324 - /// Format a ShortFileName (8.3) into a human-readable "NAME.EXT" string. 325 - /// Returns the number of bytes written to `out`. 326 284 fn format_83_name(sfn: &embedded_sdmmc::ShortFileName, out: &mut [u8; 13]) -> usize { 327 285 let base = sfn.base_name(); 328 286 let ext = sfn.extension(); 329 287 330 288 let mut pos = 0; 331 289 332 - // Copy base name, trimming trailing spaces 333 290 for &b in base.iter() { 334 291 if b == b' ' { 335 292 break; ··· 338 295 pos += 1; 339 296 } 340 297 341 - // Add extension if non-empty 342 298 let ext_trimmed: &[u8] = &ext[..ext.iter().position(|&b| b == b' ').unwrap_or(ext.len())]; 343 299 if !ext_trimmed.is_empty() { 344 300 out[pos] = b'.';
+2 -1
src/kernel/mod.rs
··· 1 - //! Minimal kernel for pulp-os 1 + // Cooperative scheduler and wake/sleep primitives 2 + // Single core, no preemption. WFI idles the CPU between events. 2 3 3 4 pub mod scheduler; 4 5 pub mod wake;
+10 -37
src/kernel/scheduler.rs
··· 1 - // Priority-based job scheduler for cooperative multitasking. 1 + // Priority job scheduler, cooperative 2 2 // 3 - // Jobs are signals, not data carriers. State lives in the app/driver 4 - // that handles the job. The scheduler only decides execution order. 3 + // Jobs are signals, not data carriers. State lives in the subsystem 4 + // that handles the job. Three priority tiers, FIFO within each. 5 + // Fixed size ring buffers, no allocation. 5 6 // 6 - // No dynamic allocation — fixed-size ring buffer queues per priority tier. 7 + // High: PollInput, Render 8 + // Normal: AppWork, UpdateStatusBar 9 + // Low: (reserved) 10 + 7 11 use core::fmt; 8 12 9 - /// Schedulable units of work. 10 - /// 11 - /// Jobs carry no payload. The handler reads state from wherever it 12 - /// lives (app struct, driver, context) when the job executes. 13 13 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 14 pub enum Job { 15 - // ── High priority: interactive, latency-sensitive ────────── 16 - /// Poll the input driver for debounced button events. 17 15 PollInput, 18 - /// Flush pending redraw (full or partial) to the display. 19 16 Render, 20 - 21 - // ── Normal priority: responsive but deferrable ───────────── 22 - /// Run the active app's `on_work()` with OS services. 23 - /// Generic — the kernel doesn't know what the app will do. 24 - /// Replaces per-app job variants (no new jobs when adding apps). 25 17 AppWork, 26 - /// Sample battery ADC and refresh the status bar text. 27 18 UpdateStatusBar, 28 - 29 - // ── Low priority: speculative / background ───────────────── 30 - // (Reserved for future work: prefetch, layout, cache) 31 19 } 32 20 33 21 impl fmt::Display for Job { ··· 41 29 } 42 30 } 43 31 44 - /// Job priority levels (lower numeric value = higher priority). 45 32 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 46 33 pub enum Priority { 47 34 High = 0, ··· 60 47 61 48 #[derive(Debug, Clone, Copy)] 62 49 pub enum PushError { 63 - /// Queue for this priority level is full; contains the rejected job. 64 50 Full(Job), 65 51 } 66 52 ··· 72 58 } 73 59 } 74 60 75 - // ── Ring buffer ──────────────────────────────────────────────── 76 - 77 61 struct JobQueue<const N: usize> { 78 62 buf: [Option<Job>; N], 79 - head: usize, // next to read 80 - tail: usize, // next to write 63 + head: usize, 64 + tail: usize, 81 65 len: usize, 82 66 } 83 67 ··· 132 116 } 133 117 } 134 118 135 - // ── Scheduler ────────────────────────────────────────────────── 136 - 137 - /// Priority-based job scheduler. 138 - /// 139 - /// `pop()` always returns the highest-priority pending job (FIFO 140 - /// within each tier). Jobs can enqueue follow-on work during 141 - /// execution — the drain loop picks it up immediately. 142 119 pub struct Scheduler { 143 120 high: JobQueue<4>, 144 121 normal: JobQueue<8>, ··· 154 131 } 155 132 } 156 133 157 - /// Push a job; returns error if the priority queue is full. 158 134 pub fn push(&mut self, job: Job) -> Result<(), PushError> { 159 135 let result = match job.priority() { 160 136 Priority::High => self.high.push(job), ··· 164 140 result.map_err(PushError::Full) 165 141 } 166 142 167 - /// Schedule a job only if it's not already queued (dedup). 168 - /// Primary method for enqueuing — prevents duplicate work. 169 143 pub fn push_unique(&mut self, job: Job) -> Result<(), PushError> { 170 144 match job.priority() { 171 145 Priority::High => { ··· 189 163 } 190 164 } 191 165 192 - /// Pop the highest-priority pending job. 193 166 pub fn pop(&mut self) -> Option<Job> { 194 167 self.high 195 168 .pop()
+13 -32
src/kernel/wake.rs
··· 1 + // Wake flag signaling between ISRs and the main loop 2 + // 3 + // ISRs set atomic flags; the main loop consumes them via try_wake(). 4 + // Flags are independent so concurrent sources (button + timer + display) 5 + // never swallow each other. All cleared atomically inside a critical 6 + // section to prevent races on riscv32imc (no hardware atomic RMW). 7 + // 8 + // Uptime is tracked in 10ms base ticks regardless of actual timer 9 + // period. When the timer slows to 100ms during idle, TICK_WEIGHT 10 + // is set to 10 so the counter stays in consistent units. 11 + 1 12 use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; 2 13 3 - // Wake source flags (set by ISR, cleared by main loop) 4 14 static WAKE_BUTTON: AtomicBool = AtomicBool::new(false); 5 15 static WAKE_DISPLAY: AtomicBool = AtomicBool::new(false); 6 16 static WAKE_TIMER: AtomicBool = AtomicBool::new(false); 7 17 8 - // How many base ticks (10ms) each timer interrupt represents. 9 - // Normally 1. When the timer is slowed to 100ms, set to 10 10 - // so uptime_ticks() stays in consistent 10ms units. 18 + // how many 10ms base ticks each timer interrupt represents (1 or 10) 11 19 static TICK_WEIGHT: AtomicU32 = AtomicU32::new(1); 12 20 13 - // Uptime in base ticks (10ms each), regardless of actual timer period. 14 - // Protected by critical section because riscv32imc lacks atomic RMW. 21 + // critical section because riscv32imc has no atomic add 15 22 static UPTIME_TICKS: critical_section::Mutex<core::cell::Cell<u32>> = 16 23 critical_section::Mutex::new(core::cell::Cell::new(0)); 17 24 18 - /// Which wake sources fired since the last check. 19 - /// 20 - /// Each flag is independent — multiple sources can fire between 21 - /// checks and none are lost. The main loop tests each flag it 22 - /// cares about and dispatches accordingly. 23 25 #[derive(Debug, Clone, Copy)] 24 26 pub struct WakeFlags { 25 27 pub button: bool, ··· 28 30 } 29 31 30 32 impl WakeFlags { 31 - /// True if any input-related source fired (button or timer). 32 - /// Timer wakes always poll input because the ADC-based buttons 33 - /// are sampled on the timer tick. 34 33 #[inline] 35 34 pub fn has_input(&self) -> bool { 36 35 self.button || self.timer 37 36 } 38 37 } 39 38 40 - /// Atomically read and clear all pending wake flags. 41 - /// Returns None if nothing fired. 42 39 fn take_wake_flags() -> Option<WakeFlags> { 43 40 critical_section::with(|_| { 44 41 let button = WAKE_BUTTON.load(Ordering::Relaxed); ··· 49 46 return None; 50 47 } 51 48 52 - // Clear only the flags we observed 53 49 if button { 54 50 WAKE_BUTTON.store(false, Ordering::Relaxed); 55 51 } ··· 68 64 }) 69 65 } 70 66 71 - // power button was pressed. 72 67 #[inline] 73 68 pub fn signal_button() { 74 69 WAKE_BUTTON.store(true, Ordering::Release); 75 70 } 76 71 77 - // signal that the display finished refreshing. 78 72 #[inline] 79 73 pub fn signal_display() { 80 74 WAKE_DISPLAY.store(true, Ordering::Release); 81 75 } 82 76 83 - // signal a timer tick. 84 77 #[inline] 85 78 pub fn signal_timer() { 86 79 WAKE_TIMER.store(true, Ordering::Release); ··· 91 84 }); 92 85 } 93 86 94 - /// Set the tick weight — how many base ticks (10ms) each timer 95 - /// interrupt represents. Called when the timer period changes. 96 87 pub fn set_tick_weight(weight: u32) { 97 88 TICK_WEIGHT.store(weight, Ordering::Release); 98 89 } 99 90 100 - /// Uptime in base ticks (10ms each) since boot. 101 - /// Stays consistent regardless of actual timer period. 102 91 pub fn uptime_ticks() -> u32 { 103 92 critical_section::with(|cs| UPTIME_TICKS.borrow(cs).get()) 104 93 } 105 94 106 - /// Uptime in seconds since boot. 107 95 pub fn uptime_secs() -> u32 { 108 96 uptime_ticks() / 100 109 97 } ··· 115 103 core::arch::asm!("wfi", options(nomem, nostack)); 116 104 } 117 105 118 - // For testing on host 119 106 #[cfg(not(target_arch = "riscv32"))] 120 107 { 121 - // On host, just yield to simulate 122 108 std::thread::yield_now(); 123 109 } 124 110 } 125 111 126 - /// Block until a wake event occurs. 127 - /// Used for deep sleep / idle patterns where the caller 128 - /// wants to hand off control entirely until something happens. 129 112 pub fn sleep_until_wake() -> WakeFlags { 130 113 loop { 131 114 if let Some(flags) = take_wake_flags() { ··· 136 119 } 137 120 } 138 121 139 - /// Non-blocking wake check. Returns the pending wake flags 140 - /// (consuming them) or None if nothing fired. 141 122 pub fn try_wake() -> Option<WakeFlags> { 142 123 take_wake_flags() 143 124 }
+2
src/lib.rs
··· 1 + // "operating system" for the XTEink X4 (ESP32-C3, e-paper) 2 + 1 3 #![no_std] 2 4 3 5 pub mod apps;
+2 -16
src/ui/button.rs
··· 1 - //! Button widget for interactive UI elements 1 + // Interactive button widget with outline, fill, and rounded styles 2 + // Inverts colors when pressed. 2 3 3 4 use embedded_graphics::{ 4 5 mono_font::MonoFont, ··· 11 12 12 13 use super::widget::{Alignment, Region, Widget, WidgetState}; 13 14 14 - /// Button visual style 15 15 #[derive(Clone, Copy, Debug, Default, PartialEq)] 16 16 pub enum ButtonStyle { 17 - /// Simple rectangle outline 18 17 #[default] 19 18 Outlined, 20 - /// Filled rectangle 21 19 Filled, 22 - /// Rounded corners (specify radius) 23 20 Rounded(u32), 24 21 } 25 22 26 - /// A clickable button widget 27 23 pub struct Button<'a> { 28 24 region: Region, 29 25 label: &'a str, ··· 66 62 self.state = WidgetState::Dirty; 67 63 } 68 64 69 - /// Check if a point is within this button's bounds 70 65 pub fn contains(&self, point: Point) -> bool { 71 66 self.region.contains(point) 72 67 } ··· 88 83 where 89 84 D: DrawTarget<Color = BinaryColor>, 90 85 { 91 - // When pressed, invert colors 92 86 let (bg, fg) = if self.pressed { 93 87 (BinaryColor::On, BinaryColor::Off) 94 88 } else { ··· 97 91 98 92 let rect = self.region.to_rect(); 99 93 100 - // Draw button background/border based on style 101 94 match self.style { 102 95 ButtonStyle::Outlined => { 103 - // Clear background 104 96 rect.into_styled(PrimitiveStyle::with_fill(bg)) 105 97 .draw(display)?; 106 - // Draw border 107 98 rect.into_styled(PrimitiveStyle::with_stroke(fg, 2)) 108 99 .draw(display)?; 109 100 } ··· 114 105 ButtonStyle::Rounded(radius) => { 115 106 let rounded = 116 107 RoundedRectangle::new(rect, CornerRadii::new(Size::new(radius, radius))); 117 - // Clear background 118 108 rounded 119 109 .into_styled(PrimitiveStyle::with_fill(bg)) 120 110 .draw(display)?; 121 - // Draw border 122 111 rounded 123 112 .into_styled(PrimitiveStyle::with_stroke(fg, 2)) 124 113 .draw(display)?; 125 114 } 126 115 } 127 116 128 - // Calculate centered text position 129 117 let text_size = self.text_size(); 130 118 let mut pos = Alignment::Center.position(self.region, text_size); 131 119 pos.y += self.font.character_size.height as i32; 132 120 133 - // For filled style, text color is inverted 134 121 let text_color = match self.style { 135 122 ButtonStyle::Filled => bg, 136 123 _ => fg, 137 124 }; 138 125 139 - // Draw label 140 126 let style = MonoTextStyle::new(self.font, text_color); 141 127 Text::new(self.label, pos, style).draw(display)?; 142 128
+4 -19
src/ui/label.rs
··· 1 + // Static and dynamic text labels for e-paper 2 + // Label borrows its text; DynamicLabel<N> owns a fixed buffer 3 + // and implements core::fmt::Write for formatted output. 4 + 1 5 use embedded_graphics::{ 2 6 mono_font::{MonoFont, MonoTextStyle}, 3 7 pixelcolor::BinaryColor, ··· 8 12 9 13 use super::widget::{Alignment, Region, Widget, WidgetState}; 10 14 11 - /// A text label widget 12 - /// Automatically handles background clearing on redraw. 13 15 pub struct Label<'a> { 14 16 region: Region, 15 17 text: &'a str, ··· 55 57 } 56 58 } 57 59 58 - /// Calculate text size based on font metrics 59 60 fn text_size(&self) -> Size { 60 61 let char_width = self.font.character_size.width + self.font.character_spacing; 61 62 let width = self.text.len() as u32 * char_width; ··· 79 80 (BinaryColor::Off, BinaryColor::On) 80 81 }; 81 82 82 - // Clear background 83 83 self.region 84 84 .to_rect() 85 85 .into_styled(PrimitiveStyle::with_fill(bg)) 86 86 .draw(display)?; 87 87 88 - // Calculate text position 89 88 let text_size = self.text_size(); 90 89 let mut pos = self.alignment.position(self.region, text_size); 91 90 92 - // Adjust for text baseline (embedded-graphics draws from baseline) 93 91 pos.y += self.font.character_size.height as i32; 94 92 95 - // Draw text 96 93 let style = MonoTextStyle::new(self.font, fg); 97 94 Text::new(self.text, pos, style).draw(display)?; 98 95 ··· 112 109 } 113 110 } 114 111 115 - /// A label that owns its text (for dynamic content) 116 112 pub struct DynamicLabel<const N: usize> { 117 113 region: Region, 118 114 buffer: [u8; N], ··· 154 150 self.state = WidgetState::Dirty; 155 151 } 156 152 157 - /// Clear the text buffer 158 153 pub fn clear_text(&mut self) { 159 154 self.len = 0; 160 155 self.state = WidgetState::Dirty; ··· 194 189 (BinaryColor::Off, BinaryColor::On) 195 190 }; 196 191 197 - // Clear background 198 192 self.region 199 193 .to_rect() 200 194 .into_styled(PrimitiveStyle::with_fill(bg)) 201 195 .draw(display)?; 202 196 203 - // Calculate text position 204 197 let text_size = self.text_size(); 205 198 let mut pos = self.alignment.position(self.region, text_size); 206 199 pos.y += self.font.character_size.height as i32; 207 200 208 - // Draw text 209 201 let style = MonoTextStyle::new(self.font, fg); 210 202 Text::new(self.text(), pos, style).draw(display)?; 211 203 ··· 225 217 } 226 218 } 227 219 228 - /// Write formatted text to a DynamicLabel 229 - /// 230 - /// Usage: 231 - /// ```ignore 232 - /// use core::fmt::Write; 233 - /// write!(label, "Count: {}", 42).ok(); 234 - /// ``` 235 220 impl<const N: usize> core::fmt::Write for DynamicLabel<N> { 236 221 fn write_str(&mut self, s: &str) -> core::fmt::Result { 237 222 let bytes = s.as_bytes();
+2 -2
src/ui/mod.rs
··· 1 - //! UI primitives for e-paper displays 1 + // Widget toolkit for 1-bit e-paper displays 2 + // Region based layout, dirty tracking, strip-buffered rendering. 2 3 3 4 mod button; 4 5 mod label; ··· 12 13 13 14 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*}; 14 15 15 - /// Extension trait for drawing widgets 16 16 pub trait WidgetExt<D> 17 17 where 18 18 D: DrawTarget<Color = BinaryColor>,
+2 -31
src/ui/statusbar.rs
··· 1 - //! Status bar — persistent system info strip. 2 - //! 3 - //! Drawn by main.rs on every render, outside of app control. 4 - //! Shows battery, uptime, heap, stack, and SD status. 1 + // Persistent status bar at top of screen 2 + // Shows battery, uptime, heap, stack, and SD card state. 5 3 6 4 use core::fmt::Write; 7 5 ··· 14 12 15 13 use super::widget::Region; 16 14 17 - /// Height of the status bar in pixels. 18 15 pub const BAR_HEIGHT: u16 = 18; 19 16 20 - /// Y coordinate where app content should start (below the status bar). 21 17 pub const CONTENT_TOP: u16 = BAR_HEIGHT; 22 18 23 - /// Full-width status bar region (top of screen, 480px wide in landscape). 24 19 pub const BAR_REGION: Region = Region::new(0, 0, 480, BAR_HEIGHT); 25 20 26 - /// System snapshot passed to the status bar each frame. 27 21 pub struct SystemStatus { 28 - /// Uptime in seconds since boot. 29 22 pub uptime_secs: u32, 30 - /// Battery voltage in mV (0 = not available). 31 23 pub battery_mv: u16, 32 - /// Battery charge percentage (0-100). 33 24 pub battery_pct: u8, 34 - /// Heap bytes currently allocated. 35 25 pub heap_used: usize, 36 - /// Heap total bytes. 37 26 pub heap_total: usize, 38 - /// Approximate free stack in bytes. 39 27 pub stack_free: usize, 40 - /// Whether SD card is present. 41 28 pub sd_ok: bool, 42 29 } 43 30 ··· 54 41 } 55 42 } 56 43 57 - /// Update the status bar text from a system snapshot. 58 44 pub fn update(&mut self, s: &SystemStatus) { 59 45 self.len = 0; 60 46 ··· 67 53 pos: 0, 68 54 }; 69 55 70 - // Battery 71 56 if s.battery_mv > 0 { 72 57 let _ = write!( 73 58 w, ··· 80 65 let _ = write!(w, "BAT --"); 81 66 } 82 67 83 - // Uptime 84 68 if hrs > 0 { 85 69 let _ = write!(w, " {}:{:02}:{:02}", hrs, mins, secs); 86 70 } else { 87 71 let _ = write!(w, " {:02}:{:02}", mins, secs); 88 72 } 89 73 90 - // Heap 91 74 if s.heap_total > 0 { 92 75 let _ = write!(w, " H:{}/{}K", s.heap_used / 1024, s.heap_total / 1024); 93 76 } 94 77 95 - // Stack free 96 78 if s.stack_free > 0 { 97 79 let _ = write!(w, " S:{}K", s.stack_free / 1024); 98 80 } 99 81 100 - // SD 101 82 let _ = write!(w, " SD:{}", if s.sd_ok { "OK" } else { "--" }); 102 83 103 84 self.len = w.pos; ··· 107 88 core::str::from_utf8(&self.buf[..self.len]).unwrap_or("") 108 89 } 109 90 110 - /// Draw the status bar. 111 91 pub fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 112 92 where 113 93 D: DrawTarget<Color = BinaryColor>, 114 94 { 115 - // Dark background 116 95 BAR_REGION 117 96 .to_rect() 118 97 .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 119 98 .draw(display)?; 120 99 121 - // White text 122 100 let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off); 123 101 Text::new(self.text(), Point::new(4, 14), style).draw(display)?; 124 102 ··· 130 108 } 131 109 } 132 110 133 - /// Read approximate free stack space. 134 - /// 135 - /// ESP32-C3 stack grows downward from top of DRAM. 136 - /// Returns distance from current SP to DRAM base — a rough 137 - /// measure of how much SRAM headroom remains below the stack. 138 111 pub fn free_stack_bytes() -> usize { 139 112 let sp: usize; 140 113 #[cfg(target_arch = "riscv32")] ··· 147 120 } 148 121 149 122 // ESP32-C3 DRAM: 0x3FC80000..0x3FCE0000 (400KB) 150 - // SP sits near the top; distance to base ≈ free headroom. 151 123 const DRAM_BASE: usize = 0x3FC8_0000; 152 124 if sp > DRAM_BASE { sp - DRAM_BASE } else { 0 } 153 125 } 154 126 155 - /// Tiny no-alloc write helper. 156 127 struct BufWriter<'a> { 157 128 buf: &'a mut [u8], 158 129 pos: usize,
+8 -37
src/ui/widget.rs
··· 1 - //! Widgets are self-contained UI elements that know their bounds and can 2 - //! draw themselves. They work in logical coordinates (rotation-aware). 1 + // Region geometry, alignment, and base widget trait 2 + // All coordinates are logical (rotation aware). x/w should be 8 aligned 3 + // for partial refresh to avoid byte boundary fixups on the controller. 4 + 3 5 use embedded_graphics::{ 4 6 pixelcolor::BinaryColor, 5 7 prelude::*, 6 8 primitives::{PrimitiveStyle, Rectangle}, 7 9 }; 8 10 9 - /// A rectangular region in logical coordinates. 10 11 #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 11 12 pub struct Region { 12 13 pub x: u16, ··· 16 17 } 17 18 18 19 impl Region { 19 - /// Create a new region. X and W should be 8-pixel aligned for best performance. 20 20 pub const fn new(x: u16, y: u16, w: u16, h: u16) -> Self { 21 21 Self { x, y, w, h } 22 22 } 23 23 24 - /// Create from embedded-graphics Rectangle 25 24 pub fn from_rect(rect: Rectangle) -> Self { 26 25 Self { 27 26 x: rect.top_left.x.max(0) as u16, ··· 31 30 } 32 31 } 33 32 34 - /// Convert to embedded-graphics Rectangle 35 33 pub fn to_rect(self) -> Rectangle { 36 34 Rectangle::new( 37 35 Point::new(self.x as i32, self.y as i32), ··· 47 45 Point::new((self.x + self.w / 2) as i32, (self.y + self.h / 2) as i32) 48 46 } 49 47 50 - /// Align X to 8-pixel boundary (required for partial refresh) 51 48 pub fn align8(self) -> Self { 52 49 let aligned_x = (self.x / 8) * 8; 53 50 let extra = self.x - aligned_x; 54 51 Self { 55 52 x: aligned_x, 56 53 y: self.y, 57 - w: ((self.w + extra + 7) / 8) * 8, // Round up width to compensate 54 + w: ((self.w + extra + 7) / 8) * 8, 58 55 h: self.h, 59 56 } 60 57 } 61 58 62 - /// Bounding box union of two regions. 63 - /// The result is the smallest region that contains both inputs. 64 - /// May over-cover the gap between disjoint regions, but never 65 - /// under-covers either one. 66 59 pub fn union(self, other: Region) -> Self { 67 60 let x1 = self.x.min(other.x); 68 61 let y1 = self.y.min(other.y); ··· 102 95 } 103 96 } 104 97 105 - /// Text/content alignment within a widget 106 98 #[derive(Clone, Copy, Debug, Default, PartialEq)] 107 99 pub enum Alignment { 108 100 #[default] ··· 118 110 } 119 111 120 112 impl Alignment { 121 - /// Calculate position for content of given size within a region 122 113 pub fn position(self, region: Region, content_size: Size) -> Point { 123 114 let cw = content_size.width as i32; 124 115 let ch = content_size.height as i32; ··· 141 132 } 142 133 } 143 134 144 - /// Widget state for tracking if redraw is needed 145 135 #[derive(Clone, Copy, Debug, Default, PartialEq)] 146 136 pub enum WidgetState { 147 - /// Widget needs to be redrawn 148 137 #[default] 149 138 Dirty, 150 - /// Widget is up to date 151 139 Clean, 152 140 } 153 141 154 - /// Core widget trait for UI elements 155 - /// 156 - /// Widgets are self-contained UI components that: 157 - /// - Know their bounds (region) 158 - /// - Can draw themselves to any DrawTarget 159 - /// - Track dirty state for efficient updates 160 142 pub trait Widget { 161 - /// Get the widget's bounding region (in logical coordinates) 162 143 fn bounds(&self) -> Region; 163 144 164 - /// Draw the widget to a display 165 145 fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 166 146 where 167 147 D: DrawTarget<Color = BinaryColor>; 168 148 169 - /// Check if widget needs redraw 170 149 fn is_dirty(&self) -> bool { 171 - true // Default: always redraw 150 + true 172 151 } 173 152 174 - /// Mark widget as clean (called after draw) 175 - fn mark_clean(&mut self) { 176 - // Default: no-op 177 - } 153 + fn mark_clean(&mut self) {} 178 154 179 - /// Mark widget as needing redraw 180 - fn mark_dirty(&mut self) { 181 - // Default: no-op 182 - } 155 + fn mark_dirty(&mut self) {} 183 156 184 - /// Clear the widget's region to background color 185 157 fn clear<D>(&self, display: &mut D) -> Result<(), D::Error> 186 158 where 187 159 D: DrawTarget<Color = BinaryColor>, ··· 192 164 .draw(display) 193 165 } 194 166 195 - /// Get the 8-pixel aligned bounds for partial refresh 196 167 fn refresh_bounds(&self) -> Region { 197 168 self.bounds().align8() 198 169 }