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.

went a bit wild on this one: scheduler now used, added apps, battery, sd card support, etc, etc -- big changes

hansmrtn 757e5a2e e45589da

+2699 -715
+1 -1
Cargo.toml
··· 14 14 esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3", "log-04"] } 15 15 log = "0.4.27" 16 16 17 - esp-alloc = "0.9.0" 17 + esp-alloc = { version = "0.9.0", features = ["internal-heap-stats"] } 18 18 esp-backtrace = { version = "0.18.1", features = [ 19 19 "esp32c3", 20 20 "panic-handler",
+298
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. 23 + 24 + use embedded_graphics::mono_font::ascii::{FONT_10X20, FONT_6X13}; 25 + use embedded_graphics::prelude::Primitive; 26 + use embedded_graphics::Drawable; 27 + 28 + use crate::apps::{App, AppContext, AppId, Services, Transition}; 29 + use crate::board::button::Button as HwButton; 30 + use crate::board::strip::StripBuffer; 31 + use crate::drivers::input::Event; 32 + use crate::drivers::storage::DirEntry; 33 + use crate::ui::{Alignment, Button as UiButton, CONTENT_TOP, DynamicLabel, Label, Region, Widget}; 34 + 35 + /// How many entries fit on screen at once. 36 + const PAGE_SIZE: usize = 7; 37 + 38 + /// Layout — all Y values relative to CONTENT_TOP. 39 + const HEADER_REGION: Region = Region::new(16, CONTENT_TOP + 4, 300, 28); 40 + const STATUS_REGION: Region = Region::new(320, CONTENT_TOP + 4, 140, 28); 41 + 42 + const LIST_Y: u16 = CONTENT_TOP + 40; 43 + const ROW_H: u16 = 52; 44 + 45 + /// The scrollable file list region. 46 + const LIST_REGION: Region = Region::new(8, LIST_Y, 464, ROW_H * PAGE_SIZE as u16); 47 + 48 + fn row_region(index: usize) -> Region { 49 + Region::new(16, LIST_Y + index as u16 * ROW_H, 448, ROW_H - 4) 50 + } 51 + 52 + pub struct FilesApp { 53 + entries: [DirEntry; PAGE_SIZE], 54 + count: usize, 55 + total: usize, 56 + scroll: usize, 57 + selected: usize, 58 + 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 + stale_cache: bool, 62 + error: Option<&'static str>, 63 + } 64 + 65 + impl FilesApp { 66 + pub const fn new() -> Self { 67 + Self { 68 + entries: [DirEntry::EMPTY; PAGE_SIZE], 69 + count: 0, 70 + total: 0, 71 + scroll: 0, 72 + selected: 0, 73 + needs_load: false, 74 + stale_cache: false, 75 + error: None, 76 + } 77 + } 78 + 79 + pub fn selected_entry(&self) -> Option<&DirEntry> { 80 + if self.selected < self.count { 81 + Some(&self.entries[self.selected]) 82 + } else { 83 + None 84 + } 85 + } 86 + 87 + fn load_page(&mut self, entries: &[DirEntry], total: usize) { 88 + let n = entries.len().min(PAGE_SIZE); 89 + self.entries[..n].clone_from_slice(&entries[..n]); 90 + self.count = n; 91 + self.total = total; 92 + self.needs_load = false; 93 + self.error = None; 94 + if self.selected >= self.count && self.count > 0 { 95 + self.selected = self.count - 1; 96 + } 97 + } 98 + 99 + fn load_failed(&mut self, msg: &'static str) { 100 + self.needs_load = false; 101 + self.error = Some(msg); 102 + self.count = 0; 103 + } 104 + 105 + fn move_up(&mut self, ctx: &mut AppContext) { 106 + if self.selected > 0 { 107 + // Within-page: mark old and new rows dirty. 108 + ctx.mark_dirty(row_region(self.selected)); 109 + self.selected -= 1; 110 + ctx.mark_dirty(row_region(self.selected)); 111 + } else if self.scroll > 0 { 112 + // Page boundary: need fresh data from cache. 113 + // LoadDirectory owns the render — don't mark dirty here. 114 + self.scroll = self.scroll.saturating_sub(1); 115 + self.needs_load = true; 116 + } 117 + } 118 + 119 + fn move_down(&mut self, ctx: &mut AppContext) { 120 + if self.selected + 1 < self.count { 121 + // Within-page: mark old and new rows dirty. 122 + ctx.mark_dirty(row_region(self.selected)); 123 + self.selected += 1; 124 + ctx.mark_dirty(row_region(self.selected)); 125 + } 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 + self.scroll += 1; 129 + self.needs_load = true; 130 + } 131 + } 132 + } 133 + 134 + impl App for FilesApp { 135 + fn on_enter(&mut self, ctx: &mut AppContext) { 136 + self.scroll = 0; 137 + self.selected = 0; 138 + self.needs_load = true; 139 + self.stale_cache = true; // SD may have changed since last visit 140 + 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 + ctx.request_full_redraw(); 145 + } 146 + 147 + fn on_exit(&mut self) { 148 + self.count = 0; 149 + } 150 + 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 + } 156 + 157 + /// Returning from a child app. Entries are still cached, 158 + /// just repaint. No SD reload needed. 159 + fn on_resume(&mut self, ctx: &mut AppContext) { 160 + ctx.request_full_redraw(); 161 + } 162 + 163 + fn needs_work(&self) -> bool { 164 + self.needs_load 165 + } 166 + 167 + fn on_work<SPI: embedded_hal::spi::SpiDevice>( 168 + &mut self, 169 + svc: &mut Services<'_, SPI>, 170 + ctx: &mut AppContext, 171 + ) { 172 + if self.stale_cache { 173 + svc.invalidate_dir_cache(); 174 + self.stale_cache = false; 175 + } 176 + 177 + let mut buf = [DirEntry::EMPTY; PAGE_SIZE]; 178 + match svc.dir_page(self.scroll, &mut buf) { 179 + Ok(page) => { 180 + self.load_page(&buf[..page.count], page.total); 181 + } 182 + Err(e) => { 183 + log::info!("SD load failed: {}", e); 184 + self.load_failed(e); 185 + } 186 + } 187 + 188 + ctx.mark_dirty(LIST_REGION); 189 + } 190 + 191 + fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition { 192 + match event { 193 + Event::Press(HwButton::Back) => Transition::Pop, 194 + 195 + Event::Press(HwButton::Left | HwButton::VolUp) => { 196 + self.move_up(ctx); 197 + Transition::None 198 + } 199 + 200 + Event::Press(HwButton::Right | HwButton::VolDown) => { 201 + self.move_down(ctx); 202 + Transition::None 203 + } 204 + 205 + Event::Press(HwButton::Confirm) => { 206 + if let Some(entry) = self.selected_entry() { 207 + if entry.is_dir { 208 + Transition::None 209 + } else { 210 + ctx.set_message(entry.name_str().as_bytes()); 211 + Transition::Push(AppId::Reader) 212 + } 213 + } else { 214 + Transition::None 215 + } 216 + } 217 + 218 + Event::Repeat(HwButton::Left | HwButton::VolUp) => { 219 + self.move_up(ctx); 220 + Transition::None 221 + } 222 + Event::Repeat(HwButton::Right | HwButton::VolDown) => { 223 + self.move_down(ctx); 224 + Transition::None 225 + } 226 + 227 + _ => Transition::None, 228 + } 229 + } 230 + 231 + fn draw(&self, strip: &mut StripBuffer) { 232 + // Header 233 + Label::new(HEADER_REGION, "Files", &FONT_10X20) 234 + .alignment(Alignment::CenterLeft) 235 + .draw(strip) 236 + .unwrap(); 237 + 238 + // Status (page indicator) 239 + if self.total > 0 { 240 + let mut status = DynamicLabel::<20>::new(STATUS_REGION, &FONT_6X13) 241 + .alignment(Alignment::CenterRight); 242 + use core::fmt::Write; 243 + let _ = write!( 244 + status, 245 + "{}/{}", 246 + self.scroll + self.selected + 1, 247 + self.total 248 + ); 249 + status.draw(strip).unwrap(); 250 + } 251 + 252 + // Error state 253 + if let Some(msg) = self.error { 254 + Label::new(row_region(0), msg, &FONT_10X20) 255 + .alignment(Alignment::CenterLeft) 256 + .draw(strip) 257 + .unwrap(); 258 + return; 259 + } 260 + 261 + // Empty state 262 + if self.count == 0 && !self.needs_load { 263 + Label::new(row_region(0), "No files found", &FONT_10X20) 264 + .alignment(Alignment::CenterLeft) 265 + .draw(strip) 266 + .unwrap(); 267 + return; 268 + } 269 + 270 + // File list 271 + for i in 0..PAGE_SIZE { 272 + let region = row_region(i); 273 + 274 + if i < self.count { 275 + let entry = &self.entries[i]; 276 + let name = entry.name_str(); 277 + 278 + let mut btn = UiButton::new(region, name, &FONT_10X20); 279 + if i == self.selected { 280 + btn.set_pressed(true); 281 + } 282 + btn.draw(strip).unwrap(); 283 + } else { 284 + // Clear empty rows 285 + region 286 + .to_rect() 287 + .into_styled( 288 + embedded_graphics::primitives::PrimitiveStyle::with_fill( 289 + embedded_graphics::pixelcolor::BinaryColor::Off, 290 + ), 291 + ) 292 + .draw(strip) 293 + .unwrap(); 294 + } 295 + } 296 + } 297 + 298 + }
+92
src/apps/home.rs
··· 1 + //! Home screen — the app launcher. 2 + 3 + use embedded_graphics::mono_font::ascii::FONT_10X20; 4 + 5 + use crate::apps::{App, AppContext, AppId, Transition}; 6 + use crate::board::strip::StripBuffer; 7 + use crate::drivers::input::Event; 8 + use crate::board::button::Button as HwButton; 9 + use crate::ui::{Alignment, CONTENT_TOP, Label, Region, Widget}; 10 + 11 + const TITLE_REGION: Region = Region::new(16, CONTENT_TOP, 200, 32); 12 + 13 + const ITEM_Y: u16 = CONTENT_TOP + 48; 14 + const ITEM_H: u16 = 48; 15 + const ITEM_GAP: u16 = 16; 16 + const ITEM_STRIDE: u16 = ITEM_H + ITEM_GAP; 17 + 18 + struct MenuItem { 19 + region: Region, 20 + name: &'static str, 21 + app: AppId, 22 + } 23 + 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 }, 28 + ]; 29 + 30 + pub struct HomeApp { 31 + selected: usize, 32 + } 33 + 34 + impl HomeApp { 35 + pub const fn new() -> Self { 36 + Self { 37 + selected: 0, 38 + } 39 + } 40 + 41 + fn item_count(&self) -> usize { 42 + ITEMS.len() 43 + } 44 + 45 + fn move_selection(&mut self, delta: isize, ctx: &mut AppContext) { 46 + let count = self.item_count(); 47 + let new = (self.selected as isize + delta).rem_euclid(count as isize) as usize; 48 + if new != self.selected { 49 + ctx.mark_dirty(ITEMS[self.selected].region); 50 + self.selected = new; 51 + ctx.mark_dirty(ITEMS[self.selected].region); 52 + } 53 + } 54 + } 55 + 56 + impl App for HomeApp { 57 + fn on_enter(&mut self, ctx: &mut AppContext) { 58 + ctx.clear_message(); 59 + ctx.request_full_redraw(); 60 + } 61 + 62 + fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition { 63 + match event { 64 + Event::Press(HwButton::Right | HwButton::VolDown) => { 65 + self.move_selection(1, ctx); 66 + Transition::None 67 + } 68 + Event::Press(HwButton::Left | HwButton::VolUp) => { 69 + self.move_selection(-1, ctx); 70 + Transition::None 71 + } 72 + Event::Press(HwButton::Confirm) => { 73 + Transition::Push(ITEMS[self.selected].app) 74 + } 75 + _ => Transition::None, 76 + } 77 + } 78 + 79 + fn draw(&self, strip: &mut StripBuffer) { 80 + let title = Label::new(TITLE_REGION, "pulp-os", &FONT_10X20) 81 + .alignment(Alignment::CenterLeft); 82 + title.draw(strip).unwrap(); 83 + 84 + for (i, item) in ITEMS.iter().enumerate() { 85 + let mut btn = crate::ui::Button::new(item.region, item.name, &FONT_10X20); 86 + if i == self.selected { 87 + btn.set_pressed(true); 88 + } 89 + btn.draw(strip).unwrap(); 90 + } 91 + } 92 + }
+418
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 + //! ``` 44 + 45 + pub mod files; 46 + pub mod home; 47 + pub mod reader; 48 + pub mod settings; 49 + 50 + use crate::board::SdStorage; 51 + use crate::board::strip::StripBuffer; 52 + use crate::drivers::input::Event; 53 + use crate::drivers::storage::{self, DirCache, DirEntry, DirPage}; 54 + use crate::ui::Region; 55 + 56 + /// Identity of each app in the system. 57 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 58 + pub enum AppId { 59 + Home, 60 + Files, 61 + Reader, 62 + Settings, 63 + } 64 + 65 + /// What should happen after handling an event. 66 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 67 + pub enum Transition { 68 + /// Stay on current app, no action. 69 + None, 70 + /// Push a new app onto the stack (current stays underneath). 71 + Push(AppId), 72 + /// Pop current app, return to the one below. 73 + Pop, 74 + /// Replace current app entirely (no back navigation). 75 + Replace(AppId), 76 + /// Pop all the way back to Home. 77 + Home, 78 + } 79 + 80 + /// Redraw request from an app. 81 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 82 + pub enum Redraw { 83 + /// Nothing to draw. 84 + None, 85 + /// Partial refresh of a specific region. 86 + Partial(Region), 87 + /// Full screen refresh needed (e.g. on first enter). 88 + Full, 89 + } 90 + 91 + /// Small message buffer for passing data between apps. 92 + /// e.g. file browser sets a path, reader reads it on entry. 93 + const MSG_BUF_SIZE: usize = 64; 94 + 95 + /// Shared context passed to apps. Gives access to cross-app 96 + /// communication without apps needing to know about each other. 97 + pub struct AppContext { 98 + /// Message buffer for inter-app data (file path, etc.) 99 + msg_buf: [u8; MSG_BUF_SIZE], 100 + msg_len: usize, 101 + /// Redraw request set by the app 102 + redraw: Redraw, 103 + } 104 + 105 + impl AppContext { 106 + pub const fn new() -> Self { 107 + Self { 108 + msg_buf: [0u8; MSG_BUF_SIZE], 109 + msg_len: 0, 110 + redraw: Redraw::None, 111 + } 112 + } 113 + 114 + /// Set a message for the next app (e.g. a file path to open). 115 + pub fn set_message(&mut self, data: &[u8]) { 116 + let len = data.len().min(MSG_BUF_SIZE); 117 + self.msg_buf[..len].copy_from_slice(&data[..len]); 118 + self.msg_len = len; 119 + } 120 + 121 + /// Read the message left by the previous app. 122 + pub fn message(&self) -> &[u8] { 123 + &self.msg_buf[..self.msg_len] 124 + } 125 + 126 + /// Read message as UTF-8 string. 127 + pub fn message_str(&self) -> &str { 128 + core::str::from_utf8(self.message()).unwrap_or("") 129 + } 130 + 131 + /// Clear the message buffer. 132 + pub fn clear_message(&mut self) { 133 + self.msg_len = 0; 134 + } 135 + 136 + /// Request a full screen redraw. 137 + pub fn request_full_redraw(&mut self) { 138 + self.redraw = Redraw::Full; 139 + } 140 + 141 + /// Request a partial redraw of a region. 142 + /// If a partial is already pending, coalesces via bounding box union. 143 + pub fn request_partial_redraw(&mut self, region: Region) { 144 + match self.redraw { 145 + Redraw::Full => {} 146 + Redraw::Partial(existing) => { 147 + self.redraw = Redraw::Partial(existing.union(region)); 148 + } 149 + Redraw::None => self.redraw = Redraw::Partial(region), 150 + } 151 + } 152 + 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. 160 + #[inline] 161 + pub fn mark_dirty(&mut self, region: Region) { 162 + self.request_partial_redraw(region); 163 + } 164 + 165 + pub fn has_redraw(&self) -> bool { 166 + !matches!(self.redraw, Redraw::None) 167 + } 168 + 169 + /// Take the pending redraw request (resets to None). 170 + pub fn take_redraw(&mut self) -> Redraw { 171 + let r = self.redraw; 172 + self.redraw = Redraw::None; 173 + r 174 + } 175 + } 176 + 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 + pub struct Services<'a, SPI: embedded_hal::spi::SpiDevice> { 195 + dir_cache: &'a mut DirCache, 196 + sd: &'a SdStorage<SPI>, 197 + } 198 + 199 + impl<'a, SPI: embedded_hal::spi::SpiDevice> Services<'a, SPI> { 200 + /// Construct Services. Called by the kernel at the start of AppWork. 201 + pub fn new(dir_cache: &'a mut DirCache, sd: &'a SdStorage<SPI>) -> Self { 202 + Self { dir_cache, sd } 203 + } 204 + 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 + pub fn dir_page( 210 + &mut self, 211 + offset: usize, 212 + buf: &mut [DirEntry], 213 + ) -> Result<DirPage, &'static str> { 214 + self.dir_cache.ensure_loaded(self.sd)?; 215 + Ok(self.dir_cache.page(offset, buf)) 216 + } 217 + 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 + pub fn invalidate_dir_cache(&mut self) { 222 + self.dir_cache.invalidate(); 223 + } 224 + 225 + /// Read a chunk of a file starting at `offset`. 226 + /// Returns the number of bytes read into `buf`. 227 + pub fn read_file_chunk( 228 + &self, 229 + name: &str, 230 + offset: u32, 231 + buf: &mut [u8], 232 + ) -> Result<usize, &'static str> { 233 + storage::read_file_chunk(self.sd, name, offset, buf) 234 + } 235 + 236 + /// Get the size of a file in the root directory. 237 + pub fn file_size(&self, name: &str) -> Result<u32, &'static str> { 238 + storage::file_size(self.sd, name) 239 + } 240 + } 241 + 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 + 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 + 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`. 278 + fn on_suspend(&mut self) { 279 + self.on_exit(); 280 + } 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 + fn on_resume(&mut self, ctx: &mut AppContext) { 286 + self.on_enter(ctx); 287 + } 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 + fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition; 294 + 295 + /// Draw the app's UI into the strip buffer. 296 + /// Called once per strip during refresh — widgets clip automatically. 297 + fn draw(&self, strip: &mut StripBuffer); 298 + 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 + fn needs_work(&self) -> bool { 305 + false 306 + } 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 + fn on_work<SPI: embedded_hal::spi::SpiDevice>( 316 + &mut self, 317 + _services: &mut Services<'_, SPI>, 318 + _ctx: &mut AppContext, 319 + ) { 320 + // Default: no-op 321 + } 322 + } 323 + 324 + /// App navigation stack. Fixed-size, no heap. 325 + const MAX_STACK_DEPTH: usize = 4; 326 + 327 + /// Describes which lifecycle methods to call after a navigation. 328 + #[derive(Debug, Clone, Copy)] 329 + pub struct NavEvent { 330 + pub from: AppId, 331 + 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 + 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 + pub resume: bool, 338 + } 339 + 340 + pub struct Launcher { 341 + stack: [AppId; MAX_STACK_DEPTH], 342 + depth: usize, 343 + pub ctx: AppContext, 344 + } 345 + 346 + impl Launcher { 347 + pub const fn new() -> Self { 348 + Self { 349 + stack: [AppId::Home; MAX_STACK_DEPTH], 350 + depth: 1, // Start with Home 351 + ctx: AppContext::new(), 352 + } 353 + } 354 + 355 + /// Currently active app. 356 + pub fn active(&self) -> AppId { 357 + self.stack[self.depth - 1] 358 + } 359 + 360 + /// Apply a transition. Returns a `NavEvent` if an app switch 361 + /// occurred, so the main loop can call the correct lifecycle methods. 362 + pub fn apply(&mut self, transition: Transition) -> Option<NavEvent> { 363 + let old = self.active(); 364 + 365 + let (suspend, resume) = match transition { 366 + Transition::None => return None, 367 + 368 + Transition::Push(id) => { 369 + if self.depth >= MAX_STACK_DEPTH { 370 + // Stack full — can't preserve the old app, must replace. 371 + self.stack[self.depth - 1] = id; 372 + (false, false) // exit old, enter new 373 + } else { 374 + self.stack[self.depth] = id; 375 + self.depth += 1; 376 + (true, false) // suspend old, enter new 377 + } 378 + } 379 + 380 + Transition::Pop => { 381 + if self.depth > 1 { 382 + self.depth -= 1; 383 + (false, true) // exit old, resume parent 384 + } else { 385 + return None; // Can't pop below Home 386 + } 387 + } 388 + 389 + Transition::Replace(id) => { 390 + self.stack[self.depth - 1] = id; 391 + (false, false) // exit old, enter new 392 + } 393 + 394 + Transition::Home => { 395 + self.depth = 1; 396 + self.stack[0] = AppId::Home; 397 + (false, true) // exit old, resume Home 398 + } 399 + }; 400 + 401 + let new = self.active(); 402 + if new != old { 403 + Some(NavEvent { 404 + from: old, 405 + to: new, 406 + suspend, 407 + resume, 408 + }) 409 + } else { 410 + None 411 + } 412 + } 413 + 414 + /// Stack depth (for debug). 415 + pub fn depth(&self) -> usize { 416 + self.depth 417 + } 418 + }
+59
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. 5 + 6 + use embedded_graphics::mono_font::ascii::FONT_10X20; 7 + 8 + use crate::apps::{App, AppContext, Transition}; 9 + use crate::board::button::Button as HwButton; 10 + use crate::board::strip::StripBuffer; 11 + use crate::drivers::input::Event; 12 + use crate::ui::{CONTENT_TOP, Label, Region, Widget}; 13 + 14 + const TITLE_REGION: Region = Region::new(16, CONTENT_TOP, 300, 32); 15 + const INFO_REGION: Region = Region::new(16, CONTENT_TOP + 48, 440, 32); 16 + 17 + pub struct ReaderApp { 18 + filename: [u8; 32], 19 + filename_len: usize, 20 + } 21 + 22 + impl ReaderApp { 23 + pub const fn new() -> Self { 24 + Self { 25 + filename: [0u8; 32], 26 + filename_len: 0, 27 + } 28 + } 29 + } 30 + 31 + impl App for ReaderApp { 32 + fn on_enter(&mut self, ctx: &mut AppContext) { 33 + let msg = ctx.message(); 34 + let len = msg.len().min(32); 35 + self.filename[..len].copy_from_slice(&msg[..len]); 36 + self.filename_len = len; 37 + ctx.request_full_redraw(); 38 + } 39 + 40 + fn on_event(&mut self, event: Event, _ctx: &mut AppContext) -> Transition { 41 + match event { 42 + Event::Press(HwButton::Back) => Transition::Pop, 43 + _ => Transition::None, 44 + } 45 + } 46 + 47 + fn draw(&self, strip: &mut StripBuffer) { 48 + Label::new(TITLE_REGION, "Reader", &FONT_10X20) 49 + .draw(strip) 50 + .unwrap(); 51 + 52 + if self.filename_len > 0 { 53 + let name = core::str::from_utf8(&self.filename[..self.filename_len]).unwrap_or("???"); 54 + Label::new(INFO_REGION, name, &FONT_10X20) 55 + .draw(strip) 56 + .unwrap(); 57 + } 58 + } 59 + }
+38
src/apps/settings.rs
··· 1 + //! Settings app (stub). 2 + 3 + use embedded_graphics::mono_font::ascii::FONT_10X20; 4 + 5 + use crate::apps::{App, AppContext, Transition}; 6 + use crate::board::button::Button as HwButton; 7 + use crate::board::strip::StripBuffer; 8 + use crate::drivers::input::Event; 9 + use crate::ui::{CONTENT_TOP, Label, Region, Widget}; 10 + 11 + const TITLE_REGION: Region = Region::new(16, CONTENT_TOP, 300, 32); 12 + 13 + pub struct SettingsApp; 14 + 15 + impl SettingsApp { 16 + pub const fn new() -> Self { 17 + Self 18 + } 19 + } 20 + 21 + impl App for SettingsApp { 22 + fn on_enter(&mut self, ctx: &mut AppContext) { 23 + ctx.request_full_redraw(); 24 + } 25 + 26 + fn on_event(&mut self, event: Event, _ctx: &mut AppContext) -> Transition { 27 + match event { 28 + Event::Press(HwButton::Back) => Transition::Pop, 29 + _ => Transition::None, 30 + } 31 + } 32 + 33 + fn draw(&self, strip: &mut StripBuffer) { 34 + Label::new(TITLE_REGION, "Settings", &FONT_10X20) 35 + .draw(strip) 36 + .unwrap(); 37 + } 38 + }
+294 -180
src/bin/main.rs
··· 4 4 use esp_backtrace as _; 5 5 use esp_hal::clock::CpuClock; 6 6 use esp_hal::delay::Delay; 7 - use esp_hal::interrupt::Priority; 8 7 use esp_hal::time::Duration; 9 8 use esp_hal::timer::PeriodicTimer; 10 9 use esp_hal::timer::timg::TimerGroup; ··· 13 12 use core::cell::RefCell; 14 13 use critical_section::Mutex; 15 14 16 - use embedded_graphics::mono_font::ascii::FONT_10X20; 17 - 15 + use pulp_os::apps::{App, AppId, Launcher, Redraw, Services}; 16 + use pulp_os::apps::files::FilesApp; 17 + use pulp_os::apps::home::HomeApp; 18 + use pulp_os::apps::reader::ReaderApp; 19 + use pulp_os::apps::settings::SettingsApp; 18 20 use pulp_os::board::Board; 19 21 use pulp_os::board::StripBuffer; 20 - use pulp_os::board::button::Button as HwButton; 21 - use pulp_os::drivers::input::{Event, InputDriver}; 22 - use pulp_os::kernel::wake::{WakeReason, signal_timer, try_wake}; 23 - use pulp_os::kernel::{AdaptivePoller, Job, Scheduler}; 24 - use pulp_os::ui::{Button, Label, Region, Widget}; 22 + use pulp_os::drivers::battery; 23 + use pulp_os::drivers::input::InputDriver; 24 + use pulp_os::drivers::storage::DirCache; 25 + use pulp_os::kernel::wake::{self, signal_timer, try_wake}; 26 + use pulp_os::kernel::{Job, Scheduler}; 27 + use pulp_os::ui::{StatusBar, SystemStatus, free_stack_bytes, BAR_HEIGHT}; 25 28 26 29 extern crate alloc; 27 30 28 31 esp_bootloader_esp_idf::esp_app_desc!(); 29 32 30 - // timer interrupt setup 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. 47 + 48 + /// Base timer period (ms). Used during active interaction. 49 + const ACTIVE_TIMER_MS: u64 = 10; 50 + /// Slow timer period (ms). Used after idle threshold. 51 + 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 ──────────────────────────────────────────── 57 + 31 58 static TIMER0: Mutex<RefCell<Option<PeriodicTimer<'static, esp_hal::Blocking>>>> = 32 59 Mutex::new(RefCell::new(None)); 33 60 34 - #[esp_hal::handler(priority = Priority::Priority1)] 61 + #[esp_hal::handler(priority = esp_hal::interrupt::Priority::Priority1)] 35 62 fn timer0_handler() { 36 63 critical_section::with(|cs| { 37 64 if let Some(timer) = TIMER0.borrow_ref_mut(cs).as_mut() { ··· 41 68 signal_timer(); 42 69 } 43 70 44 - // test ui 45 - const TITLE: Region = Region::new(16, 16, 200, 32); 46 - const ITEM0: Region = Region::new(16, 80, 200, 48); 47 - const ITEM1: Region = Region::new(16, 144, 200, 48); 48 - const STATUS: Region = Region::new(16, 220, 300, 32); 71 + /// Change the timer period at runtime. Updates the tick weight so 72 + /// uptime_ticks() stays in consistent 10ms units. 73 + fn set_timer_period(ms: u64) { 74 + wake::set_tick_weight((ms / ACTIVE_TIMER_MS) as u32); 75 + critical_section::with(|cs| { 76 + if let Some(timer) = TIMER0.borrow_ref_mut(cs).as_mut() { 77 + let _ = timer.start(Duration::from_millis(ms)); 78 + } 79 + }); 80 + } 81 + 82 + /// Dispatch to the active app. Apps are stack-allocated — no dyn, no heap. 83 + macro_rules! with_app { 84 + ($id:expr, $home:expr, $files:expr, $reader:expr, $settings:expr, |$app:ident| $body:expr) => { 85 + 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 } 90 + } 91 + }; 92 + } 49 93 50 94 #[esp_hal::main] 51 95 fn main() -> ! { ··· 56 100 57 101 info!("booting..."); 58 102 59 - // timer init 10ms tick 103 + // Timer: 10ms tick 60 104 let timg0 = TimerGroup::new(unsafe { peripherals.TIMG0.clone_unchecked() }); 61 105 let mut timer0 = PeriodicTimer::new(timg0.timer0); 62 - 63 106 critical_section::with(|cs| { 64 107 timer0.set_interrupt_handler(timer0_handler); 65 108 timer0.start(Duration::from_millis(10)).unwrap(); 66 109 timer0.listen(); 67 110 TIMER0.borrow_ref_mut(cs).replace(timer0); 68 111 }); 69 - 70 112 info!("timer initialized."); 71 113 72 - // hardware init 114 + // Hardware 73 115 let mut board = Board::init(peripherals); 74 116 let mut delay = Delay::new(); 75 - 76 117 board.display.epd.init(&mut delay); 77 118 info!("hardware initialized."); 78 119 79 - // strip buffer — 4KB instead of 48KB framebuffer 80 120 let mut strip = StripBuffer::new(); 81 121 82 - // widgets 83 - let title = Label::new(TITLE, "pulp-os", &FONT_10X20); 84 - let mut item0 = Button::new(ITEM0, "Item 0", &FONT_10X20); 85 - let mut item1 = Button::new(ITEM1, "Item 1", &FONT_10X20); 86 - let mut status = Label::new(STATUS, "Ready", &FONT_10X20); 122 + // Status bar — persistent across all apps 123 + let mut statusbar = StatusBar::new(); 124 + let sd_ok = board 125 + .storage 126 + .sd 127 + .volume_mgr 128 + .open_volume(embedded_sdmmc::VolumeIdx(0)) 129 + .is_ok(); 130 + 131 + // Apps — all stack-allocated, zero heap 132 + let mut home = HomeApp::new(); 133 + let mut files = FilesApp::new(); 134 + let mut reader = ReaderApp::new(); 135 + let mut settings = SettingsApp::new(); 136 + 137 + // Launcher (owns navigation stack + inter-app context) 138 + let mut launcher = Launcher::new(); 87 139 88 - let mut selected: usize = 0; 89 - item0.set_pressed(true); 140 + // Scheduler + input 141 + let mut sched = Scheduler::new(); 142 + let mut input = InputDriver::new(board.input); 143 + let mut last_statusbar_ticks: u32 = 0; 144 + let mut idle_polls: u32 = 0; 145 + let mut timer_is_slow = false; 146 + let mut dir_cache = DirCache::new(); 90 147 148 + // ── Boot: explicit init, no scheduler ────────────────────── 149 + home.on_enter(&mut launcher.ctx); 150 + update_statusbar(&mut statusbar, &mut input, sd_ok); 91 151 board.display.epd.render_full(&mut strip, &mut delay, |s| { 92 - title.draw(s).unwrap(); 93 - item0.draw(s).unwrap(); 94 - item1.draw(s).unwrap(); 95 - status.draw(s).unwrap(); 152 + statusbar.draw(s).unwrap(); 153 + home.draw(s); 96 154 }); 97 - 98 155 info!("ui ready."); 99 - 100 - let mut scheduler = Scheduler::new(); 101 - let mut poller = AdaptivePoller::new(); 102 - let mut input = InputDriver::new(board.input); 103 - 104 156 info!("kernel ready."); 105 157 158 + // ── Main loop ────────────────────────────────────────────── 106 159 loop { 107 - // 1. drain pending jobs 108 - while let Some(job) = scheduler.pop() { 160 + // 1. Drain all pending jobs by priority 161 + while let Some(job) = sched.pop() { 109 162 match job { 110 - Job::RenderPage => { 111 - info!("Job: RenderPage"); 112 - } 113 - Job::PrefetchNext => { 114 - info!("Job: PrefetchNext"); 115 - } 116 - Job::PrefetchPrev => { 117 - info!("Job: PrefetchPrev"); 118 - } 119 - Job::LayoutChapter { chapter } => { 120 - info!("Job: LayoutChapter {}", chapter); 121 - } 122 - Job::CacheChapter { chapter } => { 123 - info!("Job: CacheChapter {}", chapter); 124 - } 125 - Job::HandleInput => {} 126 - } 127 - } 128 - 129 - // 2. Check wake events (non-blocking). 130 - // When nothing is pending, idle via WFI so we don't spin at full speed. 131 - let should_poll = match try_wake() { 132 - Some(WakeReason::Timer) | Some(WakeReason::Multiple) => poller.tick(), 163 + // ── PollInput (High) ─────────────────────────── 164 + Job::PollInput => { 165 + 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 + if timer_is_slow && input.is_debouncing() { 174 + set_timer_period(ACTIVE_TIMER_MS); 175 + timer_is_slow = false; 176 + idle_polls = 0; 177 + } 133 178 134 - Some(WakeReason::Button) => { 135 - poller.on_activity(); 136 - true 137 - } 179 + idle_polls += 1; 180 + if !timer_is_slow && idle_polls >= IDLE_THRESHOLD_POLLS { 181 + set_timer_period(IDLE_TIMER_MS); 182 + timer_is_slow = true; 183 + info!("timer: {}ms (idle)", IDLE_TIMER_MS); 184 + } 185 + continue; 186 + }; 138 187 139 - Some(WakeReason::Display) => { 140 - info!("display ready."); 141 - false 142 - } 188 + // Got input — snap back to fast timer 189 + if timer_is_slow { 190 + set_timer_period(ACTIVE_TIMER_MS); 191 + timer_is_slow = false; 192 + info!("timer: {}ms (active)", ACTIVE_TIMER_MS); 193 + } 194 + idle_polls = 0; 143 195 144 - None => { 145 - pulp_os::kernel::wake::wait_for_interrupt(); 146 - continue; 147 - } 148 - }; 196 + // Route to active app 197 + 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 + }); 149 202 150 - if !should_poll { 151 - continue; 152 - } 203 + // Apply navigation 204 + if let Some(nav) = launcher.apply(transition) { 205 + info!("app: {:?} -> {:?}", nav.from, nav.to); 153 206 154 - // 3. poll input and handle events 155 - let Some(event) = input.poll() else { 156 - poller.on_idle(); 157 - continue; 158 - }; 207 + // Departing app: suspend if staying on stack, exit if leaving 208 + if nav.suspend { 209 + with_app!(nav.from, home, files, reader, settings, |app| { 210 + app.on_suspend(); 211 + }); 212 + } else { 213 + with_app!(nav.from, home, files, reader, settings, |app| { 214 + app.on_exit(); 215 + }); 216 + } 159 217 160 - poller.on_activity(); 218 + // Arriving app: resume if returning, enter if fresh 219 + if nav.resume { 220 + with_app!(nav.to, home, files, reader, settings, |app| { 221 + app.on_resume(&mut launcher.ctx); 222 + }); 223 + } else { 224 + with_app!(nav.to, home, files, reader, settings, |app| { 225 + app.on_enter(&mut launcher.ctx); 226 + }); 227 + } 228 + } else { 229 + // No navigation — dirty regions (if any) were 230 + // already pushed into ctx by on_event via mark_dirty(). 231 + } 161 232 162 - match event { 163 - Event::Press(button) => { 164 - info!("[BTN] Press: {}", button.name()); 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. 243 + let active = launcher.active(); 244 + let needs = with_app!(active, home, files, reader, settings, |app| { 245 + app.needs_work() 246 + }); 247 + if needs { 248 + let _ = sched.push_unique(Job::AppWork); 249 + } else if launcher.ctx.has_redraw() { 250 + let _ = sched.push_unique(Job::Render); 251 + } 252 + } 165 253 166 - match button { 167 - HwButton::Right | HwButton::VolUp => { 168 - let old = selected; 169 - selected = (selected + 1) % 2; 170 - if old != selected { 171 - update_selection( 172 - selected, 173 - &mut item0, 174 - &mut item1, 175 - &mut board.display.epd, 176 - &mut strip, 177 - &mut delay, 178 - ); 254 + // ── Render (High) ────────────────────────────── 255 + Job::Render => { 256 + let active = launcher.active(); 257 + match launcher.ctx.take_redraw() { 258 + Redraw::Full => { 259 + update_statusbar(&mut statusbar, &mut input, sd_ok); 260 + 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 + ); 269 + }); 179 270 } 180 - scheduler.push_or_drop(Job::PrefetchNext); 181 - } 182 - HwButton::Left | HwButton::VolDown => { 183 - let old = selected; 184 - selected = if selected == 0 { 1 } else { 0 }; 185 - if old != selected { 186 - update_selection( 187 - selected, 188 - &mut item0, 189 - &mut item1, 190 - &mut board.display.epd, 191 - &mut strip, 192 - &mut delay, 193 - ); 271 + Redraw::Partial(r) => { 272 + let r = r.align8(); 273 + let bar_overlaps = r.y < BAR_HEIGHT; 274 + with_app!(active, home, files, reader, settings, |app| { 275 + board.display.epd.render_partial( 276 + &mut strip, 277 + r.x, 278 + r.y, 279 + r.w, 280 + r.h, 281 + &mut delay, 282 + |s| { 283 + if bar_overlaps { 284 + statusbar.draw(s).unwrap(); 285 + } 286 + app.draw(s); 287 + }, 288 + ); 289 + }); 194 290 } 195 - scheduler.push_or_drop(Job::PrefetchPrev); 291 + Redraw::None => {} // Race: already consumed 196 292 } 197 - HwButton::Confirm => { 198 - let msg = if selected == 0 { 199 - "Selected: Item 0" 200 - } else { 201 - "Selected: Item 1" 202 - }; 203 - status.set_text(msg); 204 - let r = status.refresh_bounds(); 205 - board.display.epd.render_partial( 206 - &mut strip, 207 - r.x, 208 - r.y, 209 - r.w, 210 - r.h, 211 - &mut delay, 212 - |s| { 213 - status.draw(s).unwrap(); 214 - }, 215 - ); 216 - scheduler.push_or_drop(Job::RenderPage); 217 - } 218 - HwButton::Power => { 219 - // TODO: sleep/shutdown 220 - info!("Power pressed"); 293 + } 294 + 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 + Job::AppWork => { 305 + let active = launcher.active(); 306 + let mut svc = Services::new(&mut dir_cache, &board.storage.sd); 307 + with_app!(active, home, files, reader, settings, |app| { 308 + app.on_work(&mut svc, &mut launcher.ctx); 309 + }); 310 + if launcher.ctx.has_redraw() { 311 + let _ = sched.push_unique(Job::Render); 221 312 } 222 - _ => {} 313 + } 314 + 315 + // ── UpdateStatusBar (Normal) ─────────────────── 316 + Job::UpdateStatusBar => { 317 + 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. 223 321 } 224 322 } 225 - Event::Release(button) => { 226 - info!("[BTN] Release: {}", button.name()); 323 + } 324 + 325 + // 2. Wait for wake events 326 + let wake = match try_wake() { 327 + Some(w) => w, 328 + None => { 329 + wake::wait_for_interrupt(); 330 + continue; 227 331 } 228 - Event::LongPress(button) => { 229 - info!("[BTN] LongPress: {}", button.name()); 230 - if button == HwButton::Power { 231 - status.set_text("Shutting down..."); 232 - let r = status.refresh_bounds(); 233 - board.display.epd.render_partial( 234 - &mut strip, 235 - r.x, 236 - r.y, 237 - r.w, 238 - r.h, 239 - &mut delay, 240 - |s| { 241 - status.draw(s).unwrap(); 242 - }, 243 - ); 244 - } 332 + }; 333 + 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 + 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 + if wake.button && timer_is_slow { 347 + set_timer_period(ACTIVE_TIMER_MS); 348 + timer_is_slow = false; 349 + idle_polls = 0; 350 + info!("timer: {}ms (button wake)", ACTIVE_TIMER_MS); 245 351 } 246 - Event::Repeat(button) => { 247 - info!("[BTN] Repeat: {}", button.name()); 352 + 353 + let _ = sched.push_unique(Job::PollInput); 354 + 355 + let ticks = wake::uptime_ticks(); 356 + if ticks.wrapping_sub(last_statusbar_ticks) >= STATUSBAR_INTERVAL_TICKS { 357 + last_statusbar_ticks = ticks; 358 + let _ = sched.push_unique(Job::UpdateStatusBar); 248 359 } 249 360 } 361 + 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 + } 250 368 } 251 369 } 252 370 253 - use pulp_os::board::Epd; 371 + fn update_statusbar(bar: &mut StatusBar, input: &mut InputDriver, sd_ok: bool) { 372 + const HEAP_TOTAL: usize = 66320; 373 + let stats = esp_alloc::HEAP.stats(); 254 374 255 - fn update_selection( 256 - selected: usize, 257 - item0: &mut Button, 258 - item1: &mut Button, 259 - display: &mut Epd, 260 - strip: &mut StripBuffer, 261 - delay: &mut Delay, 262 - ) { 263 - item0.set_pressed(selected == 0); 264 - item1.set_pressed(selected == 1); 375 + let adc_mv = input.read_battery_mv(); 376 + let bat_mv = battery::adc_to_battery_mv(adc_mv); 377 + let bat_pct = battery::battery_percentage(bat_mv); 265 378 266 - // Single partial refresh for both items 267 - let r = Region::new(16, 80, 200, 112).align8(); 268 - display.render_partial(strip, r.x, r.y, r.w, r.h, delay, |s| { 269 - item0.draw(s).unwrap(); 270 - item1.draw(s).unwrap(); 379 + bar.update(&SystemStatus { 380 + uptime_secs: wake::uptime_secs(), 381 + battery_mv: bat_mv, 382 + battery_pct: bat_pct, 383 + heap_used: stats.current_usage, 384 + heap_total: HEAP_TOTAL, 385 + stack_free: free_stack_bytes(), 386 + sd_ok, 271 387 }); 272 - 273 - info!("Selected: Item {}", selected); 274 388 }
+469 -36
src/board/display.rs
··· 7 7 use embedded_hal::spi::SpiDevice; 8 8 use esp_hal::delay::Delay; 9 9 10 - use super::strip::{StripBuffer, STRIP_BUF_SIZE, STRIP_COUNT}; 10 + use super::strip::{STRIP_COUNT, StripBuffer}; 11 11 12 12 // Display dimensions (physical) 13 13 pub const WIDTH: u16 = 800; ··· 33 33 Deg270, 34 34 } 35 35 36 - // SSD1677 Commands (matching GxEPD2 36 + // SSD1677 Commands (matching GxEPD2 37 37 mod cmd { 38 38 pub const DRIVER_OUTPUT_CONTROL: u8 = 0x01; 39 39 pub const BOOSTER_SOFT_START: u8 = 0x0C; ··· 146 146 } 147 147 } 148 148 149 - self.update_full(delay); 149 + self.update_full(); 150 150 self.initial_refresh = false; 151 151 } 152 152 ··· 196 196 self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 197 197 198 198 self.set_partial_ram_area(px, py, pw, ph); 199 - self.update_partial(delay); 199 + self.update_partial(); 200 200 201 201 self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_RED, &draw); 202 202 self.write_region_strips(strip, px, py, pw, ph, cmd::WRITE_RAM_BW, &draw); 203 203 204 - self.power_off(delay); 205 - } 206 - 207 - // partial refresh with a region tuple 208 - pub fn render_window<F>( 209 - &mut self, 210 - strip: &mut StripBuffer, 211 - x: u16, 212 - y: u16, 213 - w: u16, 214 - h: u16, 215 - delay: &mut Delay, 216 - draw: F, 217 - ) where 218 - F: Fn(&mut StripBuffer), 219 - { 220 - self.render_partial(strip, x, y, w, h, delay, draw); 204 + self.power_off(); 221 205 } 222 206 223 - // Power off the display 224 - pub fn power_off(&mut self, delay: &mut Delay) { 207 + // Power off the display 208 + pub fn power_off(&mut self) { 225 209 if self.power_is_on { 226 210 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_2); 227 211 self.send_data(&[0x83]); 228 212 self.send_command(cmd::MASTER_ACTIVATION); 229 - self.wait_busy(delay, POWER_OFF_TIME_MS); 213 + self.wait_busy(POWER_OFF_TIME_MS); 230 214 self.power_is_on = false; 231 215 } 232 216 } 233 217 234 218 /// Put display into deep sleep (minimum power, needs reset to wake) 235 - pub fn hibernate(&mut self, delay: &mut Delay) { 236 - self.power_off(delay); 219 + pub fn hibernate(&mut self) { 220 + self.power_off(); 237 221 self.send_command(cmd::DEEP_SLEEP); 238 222 self.send_data(&[0x01]); 239 223 self.init_done = false; ··· 364 348 self.set_partial_ram_area(0, 0, WIDTH, HEIGHT); 365 349 self.write_screen_buffer(cmd::WRITE_RAM_BW, 0xFF); 366 350 367 - self.update_full(delay); 351 + self.update_full(); 368 352 self.initial_refresh = false; 369 353 } 370 354 ··· 380 364 } 381 365 } 382 366 383 - fn update_full(&mut self, delay: &mut Delay) { 367 + fn update_full(&mut self) { 384 368 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 385 369 self.send_data(&[0x40, 0x00]); 386 370 ··· 388 372 self.send_data(&[0xF7]); 389 373 390 374 self.send_command(cmd::MASTER_ACTIVATION); 391 - self.wait_busy(delay, FULL_REFRESH_TIME_MS); 375 + self.wait_busy(FULL_REFRESH_TIME_MS); 392 376 393 377 self.power_is_on = false; 394 378 } 395 379 396 - fn update_partial(&mut self, delay: &mut Delay) { 380 + fn update_partial(&mut self) { 397 381 self.send_command(cmd::DISPLAY_UPDATE_CONTROL_1); 398 382 self.send_data(&[0x00, 0x00]); 399 383 ··· 401 385 self.send_data(&[0xFC]); // Partial refresh (uses OTP LUT) 402 386 403 387 self.send_command(cmd::MASTER_ACTIVATION); 404 - self.wait_busy(delay, PARTIAL_REFRESH_TIME_MS); 388 + self.wait_busy(PARTIAL_REFRESH_TIME_MS); 405 389 406 390 self.power_is_on = true; 407 391 } 408 392 409 - fn wait_busy(&mut self, delay: &mut Delay, timeout_ms: u32) { 410 - let mut elapsed = 0u32; 411 - while elapsed < timeout_ms { 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. 403 + fn wait_busy(&mut self, timeout_ms: u32) { 404 + use esp_hal::time::{Duration, Instant}; 405 + 406 + let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64); 407 + loop { 412 408 if self.busy.is_low().unwrap_or(true) { 413 409 return; 414 410 } 415 - delay.delay_millis(1); 416 - elapsed += 1; 411 + if Instant::now() >= deadline { 412 + return; 413 + } 414 + #[cfg(target_arch = "riscv32")] 415 + unsafe { 416 + core::arch::asm!("wfi", options(nomem, nostack)); 417 + } 417 418 } 418 419 } 419 420 ··· 446 447 } 447 448 } 448 449 } 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 + // }
+331 -11
src/board/mod.rs
··· 21 21 22 22 use core::cell::RefCell; 23 23 24 + use critical_section::Mutex; 24 25 use embedded_hal_bus::spi::RefCellDevice; 25 26 use esp_hal::{ 26 27 Blocking, 27 28 analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation}, 28 29 delay::Delay, 29 - gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull}, 30 - peripherals::{ADC1, GPIO1, GPIO2, Peripherals}, 30 + gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull}, 31 + peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals}, 31 32 spi, 32 33 time::Rate, 33 34 }; 34 35 use log::info; 35 36 use static_cell::StaticCell; 36 37 38 + use crate::kernel::wake; 39 + 37 40 // Type Aliases 38 41 pub type SpiBus = spi::master::Spi<'static, Blocking>; 39 42 pub type SharedSpiDevice = RefCellDevice<'static, SpiBus, Output<'static>, Delay>; ··· 43 46 // Static SPI bus — shared between display and SD card. 44 47 static SPI_BUS: StaticCell<RefCell<SpiBus>> = StaticCell::new(); 45 48 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 + 59 + static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None)); 60 + 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. 68 + #[esp_hal::handler] 69 + fn gpio_handler() { 70 + // Power button (GPIO3) — pin in static, use esp-hal API 71 + critical_section::with(|cs| { 72 + if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut() { 73 + if btn.is_interrupt_set() { 74 + btn.clear_interrupt(); 75 + wake::signal_button(); 76 + } 77 + } 78 + }); 79 + 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 86 + const GPIO_STATUS: *const u32 = 0x6000_4044 as *const u32; 87 + const GPIO_STATUS_W1TC: *mut u32 = 0x6000_404C as *mut u32; 88 + const GPIO6_MASK: u32 = 1 << 6; 89 + 90 + // Safety: single-core ISR context, no concurrent access to these registers. 91 + unsafe { 92 + if GPIO_STATUS.read_volatile() & GPIO6_MASK != 0 { 93 + GPIO_STATUS_W1TC.write_volatile(GPIO6_MASK); 94 + wake::signal_display(); 95 + } 96 + } 97 + } 98 + 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 + pub fn power_button_is_low() -> bool { 104 + critical_section::with(|cs| { 105 + POWER_BTN 106 + .borrow_ref_mut(cs) 107 + .as_mut() 108 + .map(|btn| btn.is_low()) 109 + .unwrap_or(false) 110 + }) 111 + } 112 + 46 113 // Hardware Bundles 47 114 48 - /// Input subsystem: ADC for button ladders + power button. 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()`. 49 119 pub struct InputHw { 50 120 pub adc: Adc<'static, ADC1<'static>, Blocking>, 51 121 pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 52 122 pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 53 - pub power: Input<'static>, 123 + pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>, 54 124 } 55 125 56 126 /// Display subsystem hardware. ··· 94 164 Attenuation::_11dB, 95 165 ); 96 166 167 + let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>( 168 + unsafe { p.GPIO0.clone_unchecked() }, 169 + Attenuation::_11dB, 170 + ); 171 + 97 172 let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg); 98 173 99 - let power = Input::new( 174 + // ── Power button: GPIO interrupt on falling edge ─────── 175 + let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() }); 176 + io.set_interrupt_handler(gpio_handler); 177 + 178 + let mut power = Input::new( 100 179 unsafe { p.GPIO3.clone_unchecked() }, 101 180 InputConfig::default().with_pull(Pull::Up), 102 181 ); 182 + power.listen(Event::FallingEdge); 183 + 184 + critical_section::with(|cs| { 185 + POWER_BTN.borrow_ref_mut(cs).replace(power); 186 + }); 187 + info!("power button: GPIO3 interrupt armed (FallingEdge)"); 103 188 104 189 InputHw { 105 190 adc, 106 191 row1, 107 192 row2, 108 - power, 193 + battery, 109 194 } 110 195 } 111 196 ··· 120 205 let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default()); 121 206 let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default()); 122 207 let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default()); 123 - let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 208 + 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. 213 + let mut busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None)); 214 + busy.listen(Event::FallingEdge); 215 + info!("display BUSY: GPIO6 interrupt armed (FallingEdge)"); 124 216 125 217 // SD card CS on GPIO12 (SPIHD). The X4 uses DIO flash mode so 126 218 // GPIO12 is physically free, but esp-hal doesn't expose GPIO12-17 ··· 128 220 let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) }; 129 221 130 222 // Phase 1: SPI bus at 400kHz for SD card identification. 131 - let slow_cfg = spi::master::Config::default() 132 - .with_frequency(Rate::from_khz(400)); 223 + let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400)); 133 224 134 225 let mut spi_bus = spi::master::Spi::new(p.SPI2, slow_cfg) 135 226 .unwrap() ··· 151 242 let sd = SdStorage::new(sd_spi); 152 243 153 244 // Phase 3: Speed up to 20MHz for display + normal SD operations. 154 - let fast_cfg = spi::master::Config::default() 155 - .with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 245 + let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ)); 156 246 spi_ref.borrow_mut().apply_config(&fast_cfg).unwrap(); 157 247 info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ); 158 248 ··· 163 253 (DisplayHw { epd }, StorageHw { sd }) 164 254 } 165 255 } 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 + // }
+11 -12
src/board/pins.rs
··· 1 1 //! GPIO | Function | Notes 2 2 //! -----+-----------------+---------------------------------- 3 - //! 0 | ADC - Battery | Voltage divider (2x10K), reads 1/2 actual voltage 3 + //! 0 | ADC - Battery | Battery voltage sense (if wired) 4 4 //! 1 | ADC1 - Button 2 | Resistance ladder: Right/Left/Confirm/Back 5 5 //! 2 | ADC2 - Button 1 | Resistance ladder: Volume Up/Down 6 6 //! 3 | Digital - Power | Active LOW, internal pullup 7 7 //! 4 | EPD DC | Data/Command select 8 8 //! 5 | EPD RST | Reset (active low) 9 9 //! 6 | EPD BUSY | Busy signal from display 10 - //! 7 | SPI2 MISO | SD card data out (display is write-only) 10 + //! 7 | SPI2 MISO | SD card data in (display is write-only) 11 11 //! 8 | SPI2 SCK | Shared SPI clock 12 12 //! 10 | SPI2 MOSI | Shared SPI data out 13 - //! 12 | SD CS | SD card chip select 14 - //! 20 | USB detect | UART0_RXD, can detect USB connection 13 + //! 12 | SD CS | SD card chip select (flash pin, DIO mode frees it) 15 14 //! 21 | EPD CS | Display chip select 16 15 17 16 // ----- E-Paper Display ----- ··· 19 18 pub const EPD_DC: u8 = 4; 20 19 pub const EPD_RST: u8 = 5; 21 20 pub const EPD_BUSY: u8 = 6; 21 + 22 + // ----- SPI Bus (shared) ----- 23 + pub const SPI_SCK: u8 = 8; 24 + pub const SPI_MOSI: u8 = 10; 25 + pub const SPI_MISO: u8 = 7; // SD card only; display ignores MISO 22 26 23 27 // ----- SD Card ----- 24 - pub const SD_CS: u8 = 12; 28 + pub const SD_CS: u8 = 12; // GPIO12 — flash SPIHD pin, free in DIO mode 25 29 26 - // ----- SPI Bus (shared: EPD + SD) ----- 27 - pub const SPI_SCK: u8 = 8; 28 - pub const SPI_MOSI: u8 = 10; 29 - pub const SPI_MISO: u8 = 7; // SD card read; display doesn't use MISO 30 + // ----- Battery ADC ----- 31 + pub const BAT_ADC: u8 = 0; // GPIO0 - Battery voltage (via divider) 30 32 31 33 // ----- Buttons (ADC) ----- 32 34 pub const BTN_ROW1_ADC: u8 = 1; // GPIO1 - Right/Left/Confirm/Back ··· 34 36 35 37 // ----- Power Button ----- 36 38 pub const BTN_POWER: u8 = 3; // Digital, active LOW 37 - 38 - // ----- Battery ----- 39 - pub const BATTERY_ADC: u8 = 0; // GPIO0 - voltage divider, 1/2 of battery voltage
+29 -23
src/board/raw_gpio.rs
··· 3 3 //! The XTEink X4 uses DIO flash mode, freeing GPIO12 (SPIHD) and GPIO13 (SPIWP) 4 4 //! for general use. esp-hal 1.0 doesn't generate peripheral types for GPIO12-17 5 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. 8 + 6 9 const GPIO_OUT_W1TS: u32 = 0x6000_4008; // Set output high (write-1-to-set) 7 10 const GPIO_OUT_W1TC: u32 = 0x6000_400C; // Set output low (write-1-to-clear) 8 11 const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // Enable output (write-1-to-set) 9 12 const IO_MUX_BASE: u32 = 0x6000_9000; // IO_MUX register base 10 13 const IO_MUX_PIN_STRIDE: u32 = 0x04; // Each pin has a 4-byte register 11 14 12 - // Minimal output-only GPIO driver using direct register access. 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`. 13 19 pub struct RawOutputPin { 14 20 mask: u32, // Bit mask for this pin (1 << pin_number) 15 21 } 16 22 17 23 impl RawOutputPin { 18 - // Configure a GPIO as push-pull output, initially HIGH. 19 - // 20 - // Safety: Caller must ensure 21 - // - The pin is physically available (not connected to active flash lines) 22 - // - No other driver is controlling the same pin 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 23 30 pub unsafe fn new(pin: u8) -> Self { 24 31 let mask = 1u32 << pin; 25 32 26 33 // Configure IO_MUX: select GPIO function (function 1), enable output 27 34 let mux_reg = (IO_MUX_BASE + pin as u32 * IO_MUX_PIN_STRIDE) as *mut u32; 28 - // Bits [14:12] = FUN_DRV (drive strength, default 2) 29 - // Bits [11:10] = 0 (no pull-up/down) 30 - // Bit [9] = FUN_IE (input enable) = 0 31 - // Bits [2:0] = MCU_SEL (function select) = 1 (GPIO) 32 - // 33 - // read-modify-write to preserve reserved bits, but set function to GPIO. 34 - let val = mux_reg.read_volatile(); 35 - let val = (val & !0b111) | 1; // MCU_SEL = 1 (GPIO function) 36 - mux_reg.write_volatile(val); 37 35 38 - // enable output for this pin 39 - // GPIO_FUNCn_OUT_SEL_CFG register (base 0x60004554, stride 4) 40 - let out_sel = (0x6000_4554 + pin as u32 * 4) as *mut u32; 41 - out_sel.write_volatile(0x80); // SIG_OUT = 128 (simple GPIO output) 36 + // Read-modify-write to preserve reserved bits, set function to GPIO. 37 + // Bits [2:0] = MCU_SEL = 1 (GPIO function) 38 + unsafe { 39 + let val = mux_reg.read_volatile(); 40 + let val = (val & !0b111) | 1; 41 + mux_reg.write_volatile(val); 42 42 43 - // Enable output 44 - (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask); 43 + // Configure GPIO matrix: enable output for this pin 44 + // GPIO_FUNCn_OUT_SEL_CFG register (base 0x60004554, stride 4) 45 + let out_sel = (0x6000_4554 + pin as u32 * 4) as *mut u32; 46 + out_sel.write_volatile(0x80); // SIG_OUT = 128 (simple GPIO output) 47 + 48 + // Enable output 49 + (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask); 45 50 46 - // Drive HIGH initially (CS deasserted) 47 - (GPIO_OUT_W1TS as *mut u32).write_volatile(mask); 51 + // Drive HIGH initially (CS deasserted) 52 + (GPIO_OUT_W1TS as *mut u32).write_volatile(mask); 53 + } 48 54 49 55 Self { mask } 50 56 }
+1 -1
src/board/sdcard.rs
··· 26 26 pub const SD_INIT_FREQ_HZ: u32 = 400_000; 27 27 28 28 // Normal operating frequency after init 29 - // TODO: Put this somewhere else? 29 + // TODO: Put this somewhere else? 30 30 pub const SD_NORMAL_FREQ_HZ: u32 = 20_000_000; 31 31 32 32 // Wrapper that holds the SdCard + VolumeManager together.
+3 -4
src/board/strip.rs
··· 1 - // Strip-based rendering buffer 1 + // Strip-based rendering buffer 2 2 // 3 3 // Instead of holding a full 48KB framebuffer in SRAM, we render 4 4 // through a small strip buffer (~4KB) and stream each strip to 5 - // the display controller via SPI. 5 + // the display controller via SPI. 6 6 // The display is divided into horizontal bands of physical rows. 7 7 // For each band: 8 8 // 1. Clear the strip buffer to white ··· 26 26 27 27 pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000 28 28 pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12 29 - 30 29 31 30 // A small rendering buffer that covers a physical rectangle of the display. 32 31 // ··· 168 167 } 169 168 } 170 169 171 - // embedded-graphics integration 170 + // embedded-graphics integration 172 171 173 172 impl OriginDimensions for StripBuffer { 174 173 // Report FULL logical display size.
+29
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%. 6 + 7 + /// Voltage divider ratio (100K/100K = 2:1) 8 + const DIVIDER_MULT: u32 = 2; 9 + 10 + /// Li-ion voltage bounds in millivolts 11 + const VBAT_FULL_MV: u32 = 4200; 12 + const VBAT_EMPTY_MV: u32 = 3000; 13 + 14 + /// Convert ADC millivolts (post-calibration) to actual battery millivolts. 15 + pub fn adc_to_battery_mv(adc_mv: u16) -> u16 { 16 + (adc_mv as u32 * DIVIDER_MULT) as u16 17 + } 18 + 19 + /// Battery voltage to charge percentage (0-100), linear approximation. 20 + pub fn battery_percentage(battery_mv: u16) -> u8 { 21 + let mv = battery_mv as u32; 22 + if mv >= VBAT_FULL_MV { 23 + 100 24 + } else if mv <= VBAT_EMPTY_MV { 25 + 0 26 + } else { 27 + ((mv - VBAT_EMPTY_MV) * 100 / (VBAT_FULL_MV - VBAT_EMPTY_MV)) as u8 28 + } 29 + }
+18 -3
src/drivers/input.rs
··· 4 4 // single "one button at a time" deal: 5 5 // - Row 1 ADC (GPIO1): Right, Left, Confirm, Back via resistance ladder 6 6 // - Row 2 ADC (GPIO2): Volume Up/Down via resistance ladder 7 - // - Power button (GPIO3): Digital input, active low 7 + // - Power button (GPIO3): Interrupt-driven, read via board::power_button_is_low() 8 8 // NOTE: Because each resistance ladder can only report one press at a time, 9 9 // we collapse everything into `Option<Button>` per poll cycle. 10 10 ··· 153 153 154 154 /// Read raw button state from hardware (before debouncing). 155 155 fn read_raw(&mut self) -> Option<Button> { 156 - // Power button has priority (digital, active low) 157 - if self.hw.power.is_low() { 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 + if crate::board::power_button_is_low() { 158 160 return Some(Button::Power); 159 161 } 160 162 ··· 163 165 let mv2: u16 = nb::block!(self.hw.adc.read_oneshot(&mut self.hw.row2)).unwrap(); 164 166 165 167 decode_ladder(mv1, ROW1_THRESHOLDS).or_else(|| decode_ladder(mv2, ROW2_THRESHOLDS)) 168 + } 169 + 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 + pub fn read_battery_mv(&mut self) -> u16 { 173 + nb::block!(self.hw.adc.read_oneshot(&mut self.hw.battery)).unwrap() 174 + } 175 + 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 + pub fn is_debouncing(&self) -> bool { 180 + self.candidate.is_some() && self.candidate != self.stable 166 181 } 167 182 }
+1
src/drivers/mod.rs
··· 1 + pub mod battery; 1 2 pub mod input; 2 3 pub mod storage; 3 4 // pub mod display_driver;
+286 -77
src/drivers/storage.rs
··· 1 - //! High-level storage operations for reading files from SD card. 2 - use embedded_sdmmc::{Error, Mode, SdCardError, VolumeIdx}; 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. 5 + 6 + use embedded_sdmmc::{Mode, VolumeIdx}; 3 7 4 8 use crate::board::sdcard::SdStorage; 5 9 6 - pub fn list_root_dir<SPI>( 7 - sd: &mut SdStorage<SPI>, 8 - mut f: impl FnMut(&str, u32, bool), 9 - ) -> Result<u32, Error<SdCardError>> 10 - where 11 - SPI: embedded_hal::spi::SpiDevice, 12 - { 13 - let volume = sd.volume_mgr.open_volume(VolumeIdx(0))?; 14 - let root = volume.open_root_dir()?; 10 + /// A single directory entry, small enough to keep a page on the stack. 11 + #[derive(Clone, Copy)] 12 + pub struct DirEntry { 13 + pub name: [u8; 13], 14 + pub name_len: u8, 15 + pub is_dir: bool, 16 + pub size: u32, 17 + } 18 + 19 + impl DirEntry { 20 + pub const EMPTY: Self = Self { 21 + name: [0u8; 13], 22 + name_len: 0, 23 + is_dir: false, 24 + size: 0, 25 + }; 26 + 27 + pub fn name_str(&self) -> &str { 28 + core::str::from_utf8(&self.name[..self.name_len as usize]).unwrap_or("?") 29 + } 30 + } 31 + 32 + /// Result of a paginated directory listing. 33 + pub struct DirPage { 34 + pub total: usize, 35 + pub count: usize, 36 + } 37 + 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 + pub const MAX_DIR_ENTRIES: usize = 128; 15 52 16 - let mut count = 0u32; 17 - root.iterate_dir(|entry| { 18 - let name = entry.name.base_name(); 19 - let ext = entry.name.extension(); 20 - let is_dir = entry.attributes.is_directory(); 21 - let size = entry.size; 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 + pub struct DirCache { 59 + entries: [DirEntry; MAX_DIR_ENTRIES], 60 + count: usize, 61 + valid: bool, 62 + } 22 63 23 - // Format "NAME.EXT" into a stack buffer (8.3 = max 12 chars) 24 - let mut buf = [0u8; 13]; 25 - let mut pos = 0; 64 + impl DirCache { 65 + pub const fn new() -> Self { 66 + Self { 67 + entries: [DirEntry::EMPTY; MAX_DIR_ENTRIES], 68 + count: 0, 69 + valid: false, 70 + } 71 + } 26 72 27 - for &b in name { 28 - if b == b' ' { 29 - break; 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 + pub fn ensure_loaded<SPI>(&mut self, sd: &SdStorage<SPI>) -> Result<(), &'static str> 77 + where 78 + SPI: embedded_hal::spi::SpiDevice, 79 + { 80 + if self.valid { 81 + return Ok(()); 82 + } 83 + 84 + let volume = sd 85 + .volume_mgr 86 + .open_volume(VolumeIdx(0)) 87 + .map_err(|_| "open volume failed")?; 88 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 89 + 90 + let mut count = 0usize; 91 + root.iterate_dir(|entry| { 92 + if entry.name.base_name()[0] == b'.' { 93 + return; 30 94 } 31 - if pos < buf.len() { 32 - buf[pos] = b; 33 - pos += 1; 95 + if count < MAX_DIR_ENTRIES { 96 + let mut name_buf = [0u8; 13]; 97 + let name_len = format_83_name(&entry.name, &mut name_buf); 98 + self.entries[count] = DirEntry { 99 + name: name_buf, 100 + name_len: name_len as u8, 101 + is_dir: entry.attributes.is_directory(), 102 + size: entry.size, 103 + }; 104 + count += 1; 34 105 } 106 + }) 107 + .map_err(|_| "iterate dir failed")?; 108 + 109 + self.count = count; 110 + self.valid = true; 111 + Ok(()) 112 + } 113 + 114 + /// Copy a page of entries into `buf`, starting at `skip`. 115 + /// Pure memory operation — no SD access. 116 + pub fn page(&self, skip: usize, buf: &mut [DirEntry]) -> DirPage { 117 + let available = self.count.saturating_sub(skip); 118 + let count = available.min(buf.len()); 119 + if count > 0 { 120 + buf[..count].copy_from_slice(&self.entries[skip..skip + count]); 35 121 } 122 + DirPage { 123 + total: self.count, 124 + count, 125 + } 126 + } 36 127 37 - if ext[0] != b' ' { 38 - if pos < buf.len() { 39 - buf[pos] = b'.'; 40 - pos += 1; 41 - } 42 - for &b in ext { 43 - if b == b' ' { 44 - break; 45 - } 46 - if pos < buf.len() { 47 - buf[pos] = b; 48 - pos += 1; 49 - } 50 - } 128 + /// Mark cache as stale. Next `ensure_loaded()` will re-read from SD. 129 + pub fn invalidate(&mut self) { 130 + self.valid = false; 131 + } 132 + 133 + /// Total cached entries (0 if not loaded). 134 + pub fn total(&self) -> usize { 135 + self.count 136 + } 137 + 138 + /// Whether the cache has been loaded. 139 + pub fn is_valid(&self) -> bool { 140 + self.valid 141 + } 142 + } 143 + 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 + pub fn list_page<SPI>( 149 + sd: &SdStorage<SPI>, 150 + skip: usize, 151 + buf: &mut [DirEntry], 152 + ) -> Result<DirPage, &'static str> 153 + where 154 + SPI: embedded_hal::spi::SpiDevice, 155 + { 156 + let volume = sd 157 + .volume_mgr 158 + .open_volume(VolumeIdx(0)) 159 + .map_err(|_| "open volume failed")?; 160 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 161 + 162 + let mut total = 0usize; 163 + let mut written = 0usize; 164 + let page_size = buf.len(); 165 + 166 + root.iterate_dir(|entry| { 167 + // Skip dot entries 168 + if entry.name.base_name()[0] == b'.' { 169 + return; 51 170 } 52 171 53 - if let Ok(formatted) = core::str::from_utf8(&buf[..pos]) { 54 - f(formatted, size, is_dir); 172 + if total >= skip && written < page_size { 173 + let mut name_buf = [0u8; 13]; 174 + let name_len = format_83_name(&entry.name, &mut name_buf); 175 + buf[written] = DirEntry { 176 + name: name_buf, 177 + name_len: name_len as u8, 178 + is_dir: entry.attributes.is_directory(), 179 + size: entry.size, 180 + }; 181 + written += 1; 55 182 } 183 + total += 1; 184 + }) 185 + .map_err(|_| "iterate dir failed")?; 56 186 57 - count += 1; 58 - })?; 187 + Ok(DirPage { 188 + total, 189 + count: written, 190 + }) 191 + } 192 + 193 + /// List files in the root directory, calling `cb` for each entry. 194 + /// Returns the number of entries found. 195 + pub fn list_root_dir<SPI>( 196 + sd: &SdStorage<SPI>, 197 + mut cb: impl FnMut(&str, bool, u32), 198 + ) -> Result<usize, &'static str> 199 + where 200 + SPI: embedded_hal::spi::SpiDevice, 201 + { 202 + let volume = sd 203 + .volume_mgr 204 + .open_volume(VolumeIdx(0)) 205 + .map_err(|_| "open volume failed")?; 206 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 207 + 208 + let mut count = 0usize; 209 + root.iterate_dir(|entry| { 210 + let mut name_buf = [0u8; 13]; 211 + let name_len = format_83_name(&entry.name, &mut name_buf); 212 + if let Ok(name) = core::str::from_utf8(&name_buf[..name_len]) { 213 + cb(name, entry.attributes.is_directory(), entry.size); 214 + count += 1; 215 + } 216 + }) 217 + .map_err(|_| "iterate dir failed")?; 59 218 60 219 Ok(count) 61 220 } 62 221 63 - pub fn file_size<SPI>( 64 - sd: &mut SdStorage<SPI>, 65 - name: &str, 66 - ) -> Result<u32, Error<SdCardError>> 222 + /// Get the size of a file in the root directory. 223 + pub fn file_size<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<u32, &'static str> 67 224 where 68 225 SPI: embedded_hal::spi::SpiDevice, 69 226 { 70 - let volume = sd.volume_mgr.open_volume(VolumeIdx(0))?; 71 - let root = volume.open_root_dir()?; 72 - let file = root.open_file_in_dir(name, Mode::ReadOnly)?; 227 + let volume = sd 228 + .volume_mgr 229 + .open_volume(VolumeIdx(0)) 230 + .map_err(|_| "open volume failed")?; 231 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 232 + let file = root 233 + .open_file_in_dir(name, Mode::ReadOnly) 234 + .map_err(|_| "open file failed")?; 235 + 73 236 Ok(file.length()) 74 237 } 75 238 76 - /// Read an entire file into a buffer. Returns bytes read. 239 + /// Read an entire file (or up to buf.len() bytes) into a buffer. 240 + /// Returns the number of bytes read. 77 241 pub fn read_file<SPI>( 78 - sd: &mut SdStorage<SPI>, 242 + sd: &SdStorage<SPI>, 79 243 name: &str, 80 244 buf: &mut [u8], 81 - ) -> Result<usize, Error<SdCardError>> 245 + ) -> Result<usize, &'static str> 82 246 where 83 247 SPI: embedded_hal::spi::SpiDevice, 84 248 { 85 - let volume = sd.volume_mgr.open_volume(VolumeIdx(0))?; 86 - let root = volume.open_root_dir()?; 87 - let file = root.open_file_in_dir(name, Mode::ReadOnly)?; 249 + let volume = sd 250 + .volume_mgr 251 + .open_volume(VolumeIdx(0)) 252 + .map_err(|_| "open volume failed")?; 253 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 254 + let mut file = root 255 + .open_file_in_dir(name, Mode::ReadOnly) 256 + .map_err(|_| "open file failed")?; 88 257 89 258 let mut total = 0; 90 259 while !file.is_eof() && total < buf.len() { 91 - let n = file.read(&mut buf[total..])?; 260 + let n = file.read(&mut buf[total..]).map_err(|_| "read failed")?; 92 261 if n == 0 { 93 262 break; 94 263 } ··· 98 267 Ok(total) 99 268 } 100 269 101 - /// Read a chunk of a file starting at `offset`. Returns bytes read. 270 + /// Read a chunk of a file starting at `offset`. 271 + /// Returns the number of bytes read. 102 272 pub fn read_file_chunk<SPI>( 103 - sd: &mut SdStorage<SPI>, 273 + sd: &SdStorage<SPI>, 104 274 name: &str, 105 275 offset: u32, 106 276 buf: &mut [u8], 107 - ) -> Result<usize, Error<SdCardError>> 277 + ) -> Result<usize, &'static str> 108 278 where 109 279 SPI: embedded_hal::spi::SpiDevice, 110 280 { 111 - let volume = sd.volume_mgr.open_volume(VolumeIdx(0))?; 112 - let root = volume.open_root_dir()?; 113 - let file = root.open_file_in_dir(name, Mode::ReadOnly)?; 114 - file.seek_from_start(offset)?; 281 + let volume = sd 282 + .volume_mgr 283 + .open_volume(VolumeIdx(0)) 284 + .map_err(|_| "open volume failed")?; 285 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 286 + let mut file = root 287 + .open_file_in_dir(name, Mode::ReadOnly) 288 + .map_err(|_| "open file failed")?; 289 + 290 + file.seek_from_start(offset).map_err(|_| "seek failed")?; 115 291 116 292 let mut total = 0; 117 293 while !file.is_eof() && total < buf.len() { 118 - let n = file.read(&mut buf[total..])?; 294 + let n = file.read(&mut buf[total..]).map_err(|_| "read failed")?; 119 295 if n == 0 { 120 296 break; 121 297 } ··· 126 302 } 127 303 128 304 /// Write data to a file (create or truncate). 129 - /// File name must be in 8.3 format. 130 - pub fn write_file<SPI>( 131 - sd: &mut SdStorage<SPI>, 132 - name: &str, 133 - data: &[u8], 134 - ) -> Result<(), Error<SdCardError>> 305 + pub fn write_file<SPI>(sd: &SdStorage<SPI>, name: &str, data: &[u8]) -> Result<(), &'static str> 135 306 where 136 307 SPI: embedded_hal::spi::SpiDevice, 137 308 { 138 - let volume = sd.volume_mgr.open_volume(VolumeIdx(0))?; 139 - let root = volume.open_root_dir()?; 140 - let file = root.open_file_in_dir(name, Mode::ReadWriteCreateOrTruncate)?; 141 - file.write(data)?; 142 - file.flush()?; 309 + let volume = sd 310 + .volume_mgr 311 + .open_volume(VolumeIdx(0)) 312 + .map_err(|_| "open volume failed")?; 313 + let root = volume.open_root_dir().map_err(|_| "open root dir failed")?; 314 + let mut file = root 315 + .open_file_in_dir(name, Mode::ReadWriteCreateOrTruncate) 316 + .map_err(|_| "open file for write failed")?; 317 + 318 + file.write(data).map_err(|_| "write failed")?; 319 + file.flush().map_err(|_| "flush failed")?; 320 + 143 321 Ok(()) 144 322 } 323 + 324 + /// Format a ShortFileName (8.3) into a human-readable "NAME.EXT" string. 325 + /// Returns the number of bytes written to `out`. 326 + fn format_83_name(sfn: &embedded_sdmmc::ShortFileName, out: &mut [u8; 13]) -> usize { 327 + let base = sfn.base_name(); 328 + let ext = sfn.extension(); 329 + 330 + let mut pos = 0; 331 + 332 + // Copy base name, trimming trailing spaces 333 + for &b in base.iter() { 334 + if b == b' ' { 335 + break; 336 + } 337 + out[pos] = b; 338 + pos += 1; 339 + } 340 + 341 + // Add extension if non-empty 342 + let ext_trimmed: &[u8] = &ext[..ext.iter().position(|&b| b == b' ').unwrap_or(ext.len())]; 343 + if !ext_trimmed.is_empty() { 344 + out[pos] = b'.'; 345 + pos += 1; 346 + for &b in ext_trimmed { 347 + out[pos] = b; 348 + pos += 1; 349 + } 350 + } 351 + 352 + pos 353 + }
+1 -9
src/kernel/mod.rs
··· 1 1 //! Minimal kernel for pulp-os 2 - //! 3 - //! Provides: 4 - //! - Job scheduler with priority queues 5 - //! - Adaptive polling for power efficiency 6 - //! - Sleep/wake primitives 7 2 8 - pub mod poll; 9 3 pub mod scheduler; 10 4 pub mod wake; 11 5 12 - pub use poll::{AdaptivePoller, BASE_TICK_MS, PollRate}; 13 - pub use scheduler::{Job, Priority, PushError, Scheduler}; 14 - pub use wake::{WakeReason, signal_button, signal_display, signal_timer, sleep_until_wake}; 6 + pub use scheduler::{Job, Scheduler};
-136
src/kernel/poll.rs
··· 1 - // "Adaptive" polling for power-efficient input handling 2 - // NOTE: Instead of polling attempt to adjust based on activity: 3 - // - active input: poll fast (10ms) for responsive debouncing 4 - // - recently active: poll moderate (50ms) 5 - // - idle: smoll poll (100ms) to save power 6 - 7 - use core::fmt; 8 - 9 - // Base timer tick interval (ms) 10 - pub const BASE_TICK_MS: u32 = 10; 11 - 12 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 13 - pub enum PollRate { 14 - #[default] 15 - Fast, 16 - Normal, 17 - Slow, 18 - } 19 - 20 - impl PollRate { 21 - // How many base ticks between polls at this rate 22 - pub const fn divisor(self) -> u32 { 23 - match self { 24 - PollRate::Fast => 1, 25 - PollRate::Normal => 5, 26 - PollRate::Slow => 10, 27 - } 28 - } 29 - 30 - // Effective polling interval in milliseconds 31 - pub const fn interval_ms(self) -> u32 { 32 - self.divisor() * BASE_TICK_MS 33 - } 34 - } 35 - 36 - impl fmt::Display for PollRate { 37 - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 - match self { 39 - PollRate::Fast => write!(f, "Fast({}ms)", self.interval_ms()), 40 - PollRate::Normal => write!(f, "Normal({}ms)", self.interval_ms()), 41 - PollRate::Slow => write!(f, "Slow({}ms)", self.interval_ms()), 42 - } 43 - } 44 - } 45 - 46 - // Thresholds for rate transitions 47 - mod thresholds { 48 - pub const FAST_TO_NORMAL: u32 = 20; // 20 × 10ms = 200ms 49 - pub const NORMAL_TO_SLOW: u32 = 20; // 20 × 50ms = 1000ms 50 - } 51 - 52 - // Tracks activity and adjusts polling rate accordingly(?) 53 - pub struct AdaptivePoller { 54 - rate: PollRate, 55 - // since last poll 56 - tick_count: u32, 57 - // consecutive idle polls 58 - idle_count: u32, 59 - } 60 - 61 - impl AdaptivePoller { 62 - pub const fn new() -> Self { 63 - Self { 64 - rate: PollRate::Fast, // default to blazingly fast 65 - tick_count: 0, 66 - idle_count: 0, 67 - } 68 - } 69 - 70 - pub fn tick(&mut self) -> bool { 71 - self.tick_count += 1; 72 - 73 - if self.tick_count >= self.rate.divisor() { 74 - self.tick_count = 0; 75 - true 76 - } else { 77 - false 78 - } 79 - } 80 - 81 - pub fn on_activity(&mut self) { 82 - self.rate = PollRate::Fast; 83 - self.idle_count = 0; 84 - } 85 - 86 - pub fn on_idle(&mut self) { 87 - self.idle_count += 1; 88 - 89 - match self.rate { 90 - PollRate::Fast => { 91 - if self.idle_count >= thresholds::FAST_TO_NORMAL { 92 - self.rate = PollRate::Normal; 93 - self.idle_count = 0; 94 - } 95 - } 96 - PollRate::Normal => { 97 - if self.idle_count >= thresholds::NORMAL_TO_SLOW { 98 - self.rate = PollRate::Slow; 99 - // Keep incrementing idle_count but rate won't change further 100 - } 101 - } 102 - PollRate::Slow => { 103 - // Already at slowest rate, nothing to do 104 - } 105 - } 106 - } 107 - 108 - pub fn rate(&self) -> PollRate { 109 - self.rate 110 - } 111 - 112 - pub fn interval_ms(&self) -> u32 { 113 - self.rate.interval_ms() 114 - } 115 - 116 - pub fn idle_count(&self) -> u32 { 117 - self.idle_count 118 - } 119 - 120 - // force a specific rate 121 - pub fn set_rate(&mut self, rate: PollRate) { 122 - self.rate = rate; 123 - self.idle_count = 0; 124 - self.tick_count = 0; 125 - } 126 - 127 - pub fn reset(&mut self) { 128 - *self = Self::new(); 129 - } 130 - } 131 - 132 - impl Default for AdaptivePoller { 133 - fn default() -> Self { 134 - Self::new() 135 - } 136 - }
+50 -125
src/kernel/scheduler.rs
··· 1 - // A simple priority-based job scheduler for cooperative multitasking 2 - // NOTE: No dynamic allocation and uses fixed-size queues 1 + // Priority-based job scheduler for cooperative multitasking. 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. 5 + // 6 + // No dynamic allocation — fixed-size ring buffer queues per priority tier. 3 7 use core::fmt; 4 8 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. 5 13 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 6 14 pub enum Job { 7 - HandleInput, 8 - RenderPage, 15 + // ── High priority: interactive, latency-sensitive ────────── 16 + /// Poll the input driver for debounced button events. 17 + PollInput, 18 + /// Flush pending redraw (full or partial) to the display. 19 + Render, 9 20 10 - PrefetchNext, 11 - PrefetchPrev, 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 + AppWork, 26 + /// Sample battery ADC and refresh the status bar text. 27 + UpdateStatusBar, 12 28 13 - LayoutChapter { chapter: u16 }, 14 - CacheChapter { chapter: u16 }, 29 + // ── Low priority: speculative / background ───────────────── 30 + // (Reserved for future work: prefetch, layout, cache) 15 31 } 16 32 17 33 impl fmt::Display for Job { 18 34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 19 35 match self { 20 - Job::HandleInput => write!(f, "HandleInput"), 21 - Job::RenderPage => write!(f, "RenderPage"), 22 - Job::PrefetchNext => write!(f, "PrefetchNext"), 23 - Job::PrefetchPrev => write!(f, "PrefetchPrev"), 24 - Job::LayoutChapter { chapter } => write!(f, "LayoutChapter({})", chapter), 25 - Job::CacheChapter { chapter } => write!(f, "CacheChapter({})", chapter), 36 + Job::PollInput => write!(f, "PollInput"), 37 + Job::Render => write!(f, "Render"), 38 + Job::AppWork => write!(f, "AppWork"), 39 + Job::UpdateStatusBar => write!(f, "UpdateStatusBar"), 26 40 } 27 41 } 28 42 } 29 43 30 - /// Job priority levels 44 + /// Job priority levels (lower numeric value = higher priority). 31 45 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 32 46 pub enum Priority { 33 47 High = 0, ··· 38 52 impl Job { 39 53 pub const fn priority(&self) -> Priority { 40 54 match self { 41 - Job::HandleInput | Job::RenderPage => Priority::High, 42 - Job::PrefetchNext | Job::PrefetchPrev => Priority::Normal, 43 - Job::LayoutChapter { .. } | Job::CacheChapter { .. } => Priority::Low, 55 + Job::PollInput | Job::Render => Priority::High, 56 + Job::AppWork | Job::UpdateStatusBar => Priority::Normal, 44 57 } 45 58 } 46 59 } 47 60 48 61 #[derive(Debug, Clone, Copy)] 49 62 pub enum PushError { 50 - /// Queue for this priority level is full, contains the rejected job 63 + /// Queue for this priority level is full; contains the rejected job. 51 64 Full(Job), 52 65 } 53 66 ··· 59 72 } 60 73 } 61 74 62 - // ring buffer for jobs 63 - pub struct JobQueue<const N: usize> { 75 + // ── Ring buffer ──────────────────────────────────────────────── 76 + 77 + struct JobQueue<const N: usize> { 64 78 buf: [Option<Job>; N], 65 79 head: usize, // next to read 66 80 tail: usize, // next to write ··· 68 82 } 69 83 70 84 impl<const N: usize> JobQueue<N> { 71 - pub const fn new() -> Self { 85 + const fn new() -> Self { 72 86 Self { 73 87 buf: [None; N], 74 88 head: 0, ··· 77 91 } 78 92 } 79 93 80 - pub fn push(&mut self, job: Job) -> Result<(), Job> { 94 + fn push(&mut self, job: Job) -> Result<(), Job> { 81 95 if self.len >= N { 82 96 return Err(job); 83 97 } ··· 87 101 Ok(()) 88 102 } 89 103 90 - pub fn pop(&mut self) -> Option<Job> { 104 + fn pop(&mut self) -> Option<Job> { 91 105 if self.len == 0 { 92 106 return None; 93 107 } ··· 97 111 job 98 112 } 99 113 100 - pub fn peek(&self) -> Option<&Job> { 101 - if self.len == 0 { 102 - None 103 - } else { 104 - self.buf[self.head].as_ref() 105 - } 106 - } 107 - 108 - pub fn is_empty(&self) -> bool { 114 + fn is_empty(&self) -> bool { 109 115 self.len == 0 110 116 } 111 117 112 - pub fn is_full(&self) -> bool { 113 - self.len >= N 114 - } 115 - 116 - pub fn len(&self) -> usize { 117 - self.len 118 - } 119 - 120 - pub const fn capacity(&self) -> usize { 121 - N 122 - } 123 - 124 - pub fn clear(&mut self) { 125 - while self.pop().is_some() {} 126 - } 127 - 128 - pub fn contains(&self, job: &Job) -> bool { 118 + fn contains(&self, job: &Job) -> bool { 129 119 if self.len == 0 { 130 120 return false; 131 121 } ··· 142 132 } 143 133 } 144 134 145 - impl<const N: usize> Default for JobQueue<N> { 146 - fn default() -> Self { 147 - Self::new() 148 - } 149 - } 135 + // ── Scheduler ────────────────────────────────────────────────── 150 136 151 - // The job scheduler 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. 152 142 pub struct Scheduler { 153 143 high: JobQueue<4>, 154 144 normal: JobQueue<8>, ··· 164 154 } 165 155 } 166 156 167 - // push a job and returns error with the job if queue is full 157 + /// Push a job; returns error if the priority queue is full. 168 158 pub fn push(&mut self, job: Job) -> Result<(), PushError> { 169 159 let result = match job.priority() { 170 160 Priority::High => self.high.push(job), ··· 174 164 result.map_err(PushError::Full) 175 165 } 176 166 177 - // push a job dropping silently if queue is full 178 - pub fn push_or_drop(&mut self, job: Job) -> bool { 179 - self.push(job).is_ok() 180 - } 181 - 182 - // push a'job but if queue is full, drop the oldest job of same priority 183 - pub fn push_replacing(&mut self, job: Job) { 184 - match job.priority() { 185 - Priority::High => { 186 - if self.high.is_full() { 187 - self.high.pop(); 188 - } 189 - let _ = self.high.push(job); 190 - } 191 - Priority::Normal => { 192 - if self.normal.is_full() { 193 - self.normal.pop(); 194 - } 195 - let _ = self.normal.push(job); 196 - } 197 - Priority::Low => { 198 - if self.low.is_full() { 199 - self.low.pop(); 200 - } 201 - let _ = self.low.push(job); 202 - } 203 - } 204 - } 205 - 206 - // Schedule a job only if it's not already queued (dedup that queue). 167 + /// Schedule a job only if it's not already queued (dedup). 168 + /// Primary method for enqueuing — prevents duplicate work. 207 169 pub fn push_unique(&mut self, job: Job) -> Result<(), PushError> { 208 170 match job.priority() { 209 171 Priority::High => { ··· 227 189 } 228 190 } 229 191 230 - // the next job to execute 192 + /// Pop the highest-priority pending job. 231 193 pub fn pop(&mut self) -> Option<Job> { 232 194 self.high 233 195 .pop() 234 196 .or_else(|| self.normal.pop()) 235 197 .or_else(|| self.low.pop()) 236 - } 237 - 238 - pub fn peek(&self) -> Option<&Job> { 239 - self.high 240 - .peek() 241 - .or_else(|| self.normal.peek()) 242 - .or_else(|| self.low.peek()) 243 - } 244 - 245 - pub fn is_empty(&self) -> bool { 246 - self.high.is_empty() && self.normal.is_empty() && self.low.is_empty() 247 - } 248 - 249 - pub fn pending(&self) -> usize { 250 - self.high.len() + self.normal.len() + self.low.len() 251 - } 252 - 253 - pub fn pending_by_priority(&self, priority: Priority) -> usize { 254 - match priority { 255 - Priority::High => self.high.len(), 256 - Priority::Normal => self.normal.len(), 257 - Priority::Low => self.low.len(), 258 - } 259 - } 260 - 261 - pub fn clear(&mut self) { 262 - self.high.clear(); 263 - self.normal.clear(); 264 - self.low.clear(); 265 - } 266 - 267 - pub fn clear_priority(&mut self, priority: Priority) { 268 - match priority { 269 - Priority::High => self.high.clear(), 270 - Priority::Normal => self.normal.clear(), 271 - Priority::Low => self.low.clear(), 272 - } 273 198 } 274 199 } 275 200
+77 -59
src/kernel/wake.rs
··· 1 - use core::sync::atomic::{AtomicBool, Ordering}; 1 + use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; 2 2 3 - // Wake source flags (set by ISR and cleared by main loop) 3 + // Wake source flags (set by ISR, cleared by main loop) 4 4 static WAKE_BUTTON: AtomicBool = AtomicBool::new(false); 5 5 static WAKE_DISPLAY: AtomicBool = AtomicBool::new(false); 6 6 static WAKE_TIMER: AtomicBool = AtomicBool::new(false); 7 7 8 - // who done woke us up from sleep 9 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 10 - pub enum WakeReason { 11 - Button, 12 - Display, 13 - Timer, 14 - Multiple, 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. 11 + static TICK_WEIGHT: AtomicU32 = AtomicU32::new(1); 12 + 13 + // Uptime in base ticks (10ms each), regardless of actual timer period. 14 + // Protected by critical section because riscv32imc lacks atomic RMW. 15 + static UPTIME_TICKS: critical_section::Mutex<core::cell::Cell<u32>> = 16 + critical_section::Mutex::new(core::cell::Cell::new(0)); 17 + 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 + #[derive(Debug, Clone, Copy)] 24 + pub struct WakeFlags { 25 + pub button: bool, 26 + pub display: bool, 27 + pub timer: bool, 15 28 } 16 29 17 - pub fn take_wake_reason() -> Option<WakeReason> { 30 + 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 + #[inline] 35 + pub fn has_input(&self) -> bool { 36 + self.button || self.timer 37 + } 38 + } 39 + 40 + /// Atomically read and clear all pending wake flags. 41 + /// Returns None if nothing fired. 42 + fn take_wake_flags() -> Option<WakeFlags> { 18 43 critical_section::with(|_| { 19 44 let button = WAKE_BUTTON.load(Ordering::Relaxed); 20 45 let display = WAKE_DISPLAY.load(Ordering::Relaxed); 21 46 let timer = WAKE_TIMER.load(Ordering::Relaxed); 22 47 23 - // Clear flags we read 48 + if !button && !display && !timer { 49 + return None; 50 + } 51 + 52 + // Clear only the flags we observed 24 53 if button { 25 54 WAKE_BUTTON.store(false, Ordering::Relaxed); 26 55 } ··· 31 60 WAKE_TIMER.store(false, Ordering::Relaxed); 32 61 } 33 62 34 - match (button, display, timer) { 35 - (false, false, false) => None, 36 - (true, false, false) => Some(WakeReason::Button), 37 - (false, true, false) => Some(WakeReason::Display), 38 - (false, false, true) => Some(WakeReason::Timer), 39 - _ => Some(WakeReason::Multiple), 40 - } 63 + Some(WakeFlags { 64 + button, 65 + display, 66 + timer, 67 + }) 41 68 }) 42 69 } 43 70 44 - // Check pending waits (without clearing) 45 - pub fn has_pending_wake() -> bool { 46 - WAKE_BUTTON.load(Ordering::Acquire) 47 - || WAKE_DISPLAY.load(Ordering::Acquire) 48 - || WAKE_TIMER.load(Ordering::Acquire) 49 - } 50 - 51 - // Check individual wake sources (without clearing) 52 - pub fn is_button_pending() -> bool { 53 - WAKE_BUTTON.load(Ordering::Acquire) 54 - } 55 - 56 - pub fn is_display_pending() -> bool { 57 - WAKE_DISPLAY.load(Ordering::Acquire) 58 - } 59 - 60 - pub fn is_timer_pending() -> bool { 61 - WAKE_TIMER.load(Ordering::Acquire) 62 - } 63 - 64 71 // power button was pressed. 65 72 #[inline] 66 73 pub fn signal_button() { ··· 77 84 #[inline] 78 85 pub fn signal_timer() { 79 86 WAKE_TIMER.store(true, Ordering::Release); 87 + let weight = TICK_WEIGHT.load(Ordering::Relaxed); 88 + critical_section::with(|cs| { 89 + let ticks = UPTIME_TICKS.borrow(cs); 90 + ticks.set(ticks.get().wrapping_add(weight)); 91 + }); 92 + } 93 + 94 + /// Set the tick weight — how many base ticks (10ms) each timer 95 + /// interrupt represents. Called when the timer period changes. 96 + pub fn set_tick_weight(weight: u32) { 97 + TICK_WEIGHT.store(weight, Ordering::Release); 98 + } 99 + 100 + /// Uptime in base ticks (10ms each) since boot. 101 + /// Stays consistent regardless of actual timer period. 102 + pub fn uptime_ticks() -> u32 { 103 + critical_section::with(|cs| UPTIME_TICKS.borrow(cs).get()) 104 + } 105 + 106 + /// Uptime in seconds since boot. 107 + pub fn uptime_secs() -> u32 { 108 + uptime_ticks() / 100 80 109 } 81 110 82 111 #[inline] ··· 94 123 } 95 124 } 96 125 97 - pub fn sleep_until_wake() -> WakeReason { 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 + pub fn sleep_until_wake() -> WakeFlags { 98 130 loop { 99 - if let Some(reason) = take_wake_reason() { 100 - return reason; 131 + if let Some(flags) = take_wake_flags() { 132 + return flags; 101 133 } 102 134 103 135 wait_for_interrupt(); 104 136 } 105 137 } 106 138 107 - // Non-blocking wake check. 108 - // If there's a pending wake reason, return it. 109 - pub fn try_wake() -> Option<WakeReason> { 110 - take_wake_reason() 111 - } 112 - 113 - pub fn clear_all_flags() { 114 - WAKE_BUTTON.store(false, Ordering::Release); 115 - WAKE_DISPLAY.store(false, Ordering::Release); 116 - WAKE_TIMER.store(false, Ordering::Release); 117 - } 118 - 119 - pub fn pending_flags() -> (bool, bool, bool) { 120 - ( 121 - WAKE_BUTTON.load(Ordering::Acquire), 122 - WAKE_DISPLAY.load(Ordering::Acquire), 123 - WAKE_TIMER.load(Ordering::Acquire), 124 - ) 139 + /// Non-blocking wake check. Returns the pending wake flags 140 + /// (consuming them) or None if nothing fired. 141 + pub fn try_wake() -> Option<WakeFlags> { 142 + take_wake_flags() 125 143 }
+1
src/lib.rs
··· 1 1 #![no_std] 2 2 3 + pub mod apps; 3 4 pub mod board; 4 5 pub mod drivers; 5 6 pub mod kernel;
+1 -1
src/ui/button.rs
··· 5 5 mono_font::MonoTextStyle, 6 6 pixelcolor::BinaryColor, 7 7 prelude::*, 8 - primitives::{CornerRadii, PrimitiveStyle, Rectangle, RoundedRectangle}, 8 + primitives::{CornerRadii, PrimitiveStyle, RoundedRectangle}, 9 9 text::Text, 10 10 }; 11 11
+3 -36
src/ui/mod.rs
··· 1 1 //! UI primitives for e-paper displays 2 - //! 3 - //! This module provides rotation-aware widgets that handle their own 4 - //! partial refresh regions automatically. 5 - //! 6 - //! # Example 7 - //! 8 - //! ```ignore 9 - //! use pulp_os::ui::{Region, Widget, Label, Button}; 10 - //! use embedded_graphics::mono_font::ascii::FONT_10X20; 11 - //! 12 - //! // Define regions (8-pixel aligned for partial refresh) 13 - //! const TITLE_REGION: Region = Region::new(16, 8, 200, 32); 14 - //! const BTN_REGION: Region = Region::new(16, 48, 96, 40); 15 - //! 16 - //! // Create widgets 17 - //! let title = Label::new(TITLE_REGION, "Hello!", &FONT_10X20); 18 - //! let mut btn = Button::new(BTN_REGION, "Click", &FONT_10X20); 19 - //! 20 - //! // Draw and refresh 21 - //! title.draw(&mut display).unwrap(); 22 - //! btn.draw(&mut display).unwrap(); 23 - //! display.refresh_full(&mut delay); 24 - //! 25 - //! // Later, update just the button 26 - //! btn.set_pressed(true); 27 - //! btn.draw(&mut display).unwrap(); 28 - //! let r = btn.refresh_bounds(); 29 - //! display.refresh_partial(r.x, r.y, r.w, r.h, &mut delay); 30 - //! ``` 31 2 32 3 mod button; 33 4 mod label; 5 + pub mod statusbar; 34 6 mod widget; 35 - // mod progress; 36 7 37 8 pub use button::{Button, ButtonStyle}; 38 9 pub use label::{DynamicLabel, Label}; 10 + pub use statusbar::{BAR_HEIGHT, CONTENT_TOP, StatusBar, SystemStatus, free_stack_bytes}; 39 11 pub use widget::{Alignment, Region, Widget, WidgetState}; 40 - // pub use progress::{ProgressBar, BatteryIndicator, Orientation}; 41 12 42 13 use embedded_graphics::{pixelcolor::BinaryColor, prelude::*}; 43 14 44 - /// Extension trait for drawing and refreshing widgets 45 - /// 46 - /// This trait provides convenience methods for displays that support 47 - /// partial refresh. Import it to use `draw_widget` and `refresh_widget`. 15 + /// Extension trait for drawing widgets 48 16 pub trait WidgetExt<D> 49 17 where 50 18 D: DrawTarget<Color = BinaryColor>, 51 19 { 52 - /// Draw a widget to the display (does not refresh) 53 20 fn draw_widget<W: Widget>(&mut self, widget: &W) -> Result<(), D::Error>; 54 21 } 55 22
+170
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. 5 + 6 + use core::fmt::Write; 7 + 8 + use embedded_graphics::mono_font::MonoTextStyle; 9 + use embedded_graphics::mono_font::ascii::FONT_6X13; 10 + use embedded_graphics::pixelcolor::BinaryColor; 11 + use embedded_graphics::prelude::*; 12 + use embedded_graphics::primitives::PrimitiveStyle; 13 + use embedded_graphics::text::Text; 14 + 15 + use super::widget::Region; 16 + 17 + /// Height of the status bar in pixels. 18 + pub const BAR_HEIGHT: u16 = 18; 19 + 20 + /// Y coordinate where app content should start (below the status bar). 21 + pub const CONTENT_TOP: u16 = BAR_HEIGHT; 22 + 23 + /// Full-width status bar region (top of screen, 480px wide in landscape). 24 + pub const BAR_REGION: Region = Region::new(0, 0, 480, BAR_HEIGHT); 25 + 26 + /// System snapshot passed to the status bar each frame. 27 + pub struct SystemStatus { 28 + /// Uptime in seconds since boot. 29 + pub uptime_secs: u32, 30 + /// Battery voltage in mV (0 = not available). 31 + pub battery_mv: u16, 32 + /// Battery charge percentage (0-100). 33 + pub battery_pct: u8, 34 + /// Heap bytes currently allocated. 35 + pub heap_used: usize, 36 + /// Heap total bytes. 37 + pub heap_total: usize, 38 + /// Approximate free stack in bytes. 39 + pub stack_free: usize, 40 + /// Whether SD card is present. 41 + pub sd_ok: bool, 42 + } 43 + 44 + pub struct StatusBar { 45 + buf: [u8; 80], 46 + len: usize, 47 + } 48 + 49 + impl StatusBar { 50 + pub const fn new() -> Self { 51 + Self { 52 + buf: [0u8; 80], 53 + len: 0, 54 + } 55 + } 56 + 57 + /// Update the status bar text from a system snapshot. 58 + pub fn update(&mut self, s: &SystemStatus) { 59 + self.len = 0; 60 + 61 + let secs = s.uptime_secs % 60; 62 + let mins = (s.uptime_secs / 60) % 60; 63 + let hrs = s.uptime_secs / 3600; 64 + 65 + let mut w = BufWriter { 66 + buf: &mut self.buf, 67 + pos: 0, 68 + }; 69 + 70 + // Battery 71 + if s.battery_mv > 0 { 72 + let _ = write!( 73 + w, 74 + "BAT {}% {}.{}V", 75 + s.battery_pct, 76 + s.battery_mv / 1000, 77 + (s.battery_mv % 1000) / 100 78 + ); 79 + } else { 80 + let _ = write!(w, "BAT --"); 81 + } 82 + 83 + // Uptime 84 + if hrs > 0 { 85 + let _ = write!(w, " {}:{:02}:{:02}", hrs, mins, secs); 86 + } else { 87 + let _ = write!(w, " {:02}:{:02}", mins, secs); 88 + } 89 + 90 + // Heap 91 + if s.heap_total > 0 { 92 + let _ = write!(w, " H:{}/{}K", s.heap_used / 1024, s.heap_total / 1024); 93 + } 94 + 95 + // Stack free 96 + if s.stack_free > 0 { 97 + let _ = write!(w, " S:{}K", s.stack_free / 1024); 98 + } 99 + 100 + // SD 101 + let _ = write!(w, " SD:{}", if s.sd_ok { "OK" } else { "--" }); 102 + 103 + self.len = w.pos; 104 + } 105 + 106 + fn text(&self) -> &str { 107 + core::str::from_utf8(&self.buf[..self.len]).unwrap_or("") 108 + } 109 + 110 + /// Draw the status bar. 111 + pub fn draw<D>(&self, display: &mut D) -> Result<(), D::Error> 112 + where 113 + D: DrawTarget<Color = BinaryColor>, 114 + { 115 + // Dark background 116 + BAR_REGION 117 + .to_rect() 118 + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) 119 + .draw(display)?; 120 + 121 + // White text 122 + let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off); 123 + Text::new(self.text(), Point::new(4, 14), style).draw(display)?; 124 + 125 + Ok(()) 126 + } 127 + 128 + pub fn region(&self) -> Region { 129 + BAR_REGION 130 + } 131 + } 132 + 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 + pub fn free_stack_bytes() -> usize { 139 + let sp: usize; 140 + #[cfg(target_arch = "riscv32")] 141 + unsafe { 142 + core::arch::asm!("mv {}, sp", out(reg) sp); 143 + } 144 + #[cfg(not(target_arch = "riscv32"))] 145 + { 146 + sp = 0; 147 + } 148 + 149 + // ESP32-C3 DRAM: 0x3FC80000..0x3FCE0000 (400KB) 150 + // SP sits near the top; distance to base ≈ free headroom. 151 + const DRAM_BASE: usize = 0x3FC8_0000; 152 + if sp > DRAM_BASE { sp - DRAM_BASE } else { 0 } 153 + } 154 + 155 + /// Tiny no-alloc write helper. 156 + struct BufWriter<'a> { 157 + buf: &'a mut [u8], 158 + pos: usize, 159 + } 160 + 161 + impl<'a> Write for BufWriter<'a> { 162 + fn write_str(&mut self, s: &str) -> core::fmt::Result { 163 + let bytes = s.as_bytes(); 164 + let avail = self.buf.len() - self.pos; 165 + let n = bytes.len().min(avail); 166 + self.buf[self.pos..self.pos + n].copy_from_slice(&bytes[..n]); 167 + self.pos += n; 168 + Ok(()) 169 + } 170 + }
+18 -1
src/ui/widget.rs
··· 7 7 }; 8 8 9 9 /// A rectangular region in logical coordinates. 10 - #[derive(Clone, Copy, Debug, Default)] 10 + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] 11 11 pub struct Region { 12 12 pub x: u16, 13 13 pub y: u16, ··· 56 56 y: self.y, 57 57 w: ((self.w + extra + 7) / 8) * 8, // Round up width to compensate 58 58 h: self.h, 59 + } 60 + } 61 + 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 + pub fn union(self, other: Region) -> Self { 67 + let x1 = self.x.min(other.x); 68 + let y1 = self.y.min(other.y); 69 + let x2 = (self.x + self.w).max(other.x + other.w); 70 + let y2 = (self.y + self.h).max(other.y + other.h); 71 + Self { 72 + x: x1, 73 + y: y1, 74 + w: x2 - x1, 75 + h: y2 - y1, 59 76 } 60 77 } 61 78