···6677[build]
88rustflags = [
99- # Required to obtain backtraces (e.g. when using the "esp-backtrace" crate.)
1010- # NOTE: May negatively impact performance of produced code
119 "-C", "force-frame-pointers",
1210]
1311
+1-3
Cargo.toml
···333334343535[profile.dev]
3636-# Rust debug is too slow.
3737-# For debug builds always builds with some optimization
3836opt-level = "s"
39374038[profile.release]
4141-codegen-units = 1 # LLVM can perform better optimizations using a single thread
3939+codegen-units = 1
4240debug = 2
4341debug-assertions = false
4442incremental = false
-2
build.rs
···11fn main() {
22 linker_be_nice();
33- // make sure linkall.x is the last linker script (otherwise might cause problems with flip-link)
43 println!("cargo:rustc-link-arg=-Tlinkall.x");
54}
65···5453 }
5554 _ => (),
5655 },
5757- // we don't have anything helpful for "missing-lib" yet
5856 _ => {
5957 std::process::exit(1);
6058 }
+13-64
src/apps/files.rs
···11-//! File browser app.
22-//!
33-//! Displays a paginated list of files from the SD card root directory.
44-//! Up/Down to navigate, Confirm to open files, Back to return home.
55-//!
66-//! Storage access pattern: FilesApp doesn't touch hardware directly.
77-//! It sets `needs_load = true`, and main.rs calls `load_page()` with
88-//! data from the storage driver.
99-//!
1010-//! ## Render ownership
1111-//!
1212-//! Two kinds of visual updates:
1313-//!
1414-//! - **Within-page scroll**: selection highlight moves, data unchanged.
1515-//! `move_up`/`move_down` call `ctx.mark_dirty()` on the old and new
1616-//! rows. The framework coalesces them (union of ~100px vs full 364px
1717-//! list — 3.8× less SPI data, less visible flicker).
1818-//!
1919-//! - **Page-boundary scroll**: new data needed from cache.
2020-//! Sets `needs_load = true` only (no mark_dirty). The LoadDirectory
2121-//! job owns the render decision — it fires after data arrives and
2222-//! requests a partial redraw of LIST_REGION.
11+// Paginated file browser for SD card root directory
22+//
33+// Scrolling within a page marks two rows dirty (old + new selection).
44+// Scrolling across a page boundary sets needs_load; the kernel runs
55+// AppWork which reads from DirCache and owns the render decision.
2362424-use embedded_graphics::mono_font::ascii::{FONT_10X20, FONT_6X13};
77+use embedded_graphics::Drawable;
88+use embedded_graphics::mono_font::ascii::{FONT_6X13, FONT_10X20};
259use embedded_graphics::prelude::Primitive;
2626-use embedded_graphics::Drawable;
27102811use crate::apps::{App, AppContext, AppId, Services, Transition};
2912use crate::board::button::Button as HwButton;
···3215use crate::drivers::storage::DirEntry;
3316use crate::ui::{Alignment, Button as UiButton, CONTENT_TOP, DynamicLabel, Label, Region, Widget};
34173535-/// How many entries fit on screen at once.
3618const PAGE_SIZE: usize = 7;
37193838-/// Layout — all Y values relative to CONTENT_TOP.
3920const HEADER_REGION: Region = Region::new(16, CONTENT_TOP + 4, 300, 28);
4021const STATUS_REGION: Region = Region::new(320, CONTENT_TOP + 4, 140, 28);
41224223const LIST_Y: u16 = CONTENT_TOP + 40;
4324const ROW_H: u16 = 52;
44254545-/// The scrollable file list region.
4626const LIST_REGION: Region = Region::new(8, LIST_Y, 464, ROW_H * PAGE_SIZE as u16);
47274828fn row_region(index: usize) -> Region {
···5636 scroll: usize,
5737 selected: usize,
5838 needs_load: bool,
5959- /// Set on fresh entry — tells on_work to invalidate the dir cache
6060- /// before loading, since SD contents may have changed.
6139 stale_cache: bool,
6240 error: Option<&'static str>,
6341}
···1048210583 fn move_up(&mut self, ctx: &mut AppContext) {
10684 if self.selected > 0 {
107107- // Within-page: mark old and new rows dirty.
10885 ctx.mark_dirty(row_region(self.selected));
10986 self.selected -= 1;
11087 ctx.mark_dirty(row_region(self.selected));
11188 } else if self.scroll > 0 {
112112- // Page boundary: need fresh data from cache.
113113- // LoadDirectory owns the render — don't mark dirty here.
11489 self.scroll = self.scroll.saturating_sub(1);
11590 self.needs_load = true;
11691 }
···1189311994 fn move_down(&mut self, ctx: &mut AppContext) {
12095 if self.selected + 1 < self.count {
121121- // Within-page: mark old and new rows dirty.
12296 ctx.mark_dirty(row_region(self.selected));
12397 self.selected += 1;
12498 ctx.mark_dirty(row_region(self.selected));
12599 } else if self.scroll + self.count < self.total {
126126- // Page boundary: need fresh data from cache.
127127- // LoadDirectory owns the render — don't mark dirty here.
128100 self.scroll += 1;
129101 self.needs_load = true;
130102 }
···136108 self.scroll = 0;
137109 self.selected = 0;
138110 self.needs_load = true;
139139- self.stale_cache = true; // SD may have changed since last visit
111111+ self.stale_cache = true;
140112 self.error = None;
141141- // Full redraw for header/chrome. AppWork will populate the
142142- // list and inherit this Full request (partial won't downgrade
143143- // it — see AppContext::request_partial_redraw).
144113 ctx.request_full_redraw();
145114 }
146115···148117 self.count = 0;
149118 }
150119151151- /// Pushed behind a child app (e.g. Reader). Preserve scroll
152152- /// position and cached entries — don't clear count.
153153- fn on_suspend(&mut self) {
154154- // no-op: entries, scroll, selected all stay valid
155155- }
120120+ fn on_suspend(&mut self) {}
156121157157- /// Returning from a child app. Entries are still cached,
158158- /// just repaint. No SD reload needed.
159122 fn on_resume(&mut self, ctx: &mut AppContext) {
160123 ctx.request_full_redraw();
161124 }
···229192 }
230193231194 fn draw(&self, strip: &mut StripBuffer) {
232232- // Header
233195 Label::new(HEADER_REGION, "Files", &FONT_10X20)
234196 .alignment(Alignment::CenterLeft)
235197 .draw(strip)
236198 .unwrap();
237199238238- // Status (page indicator)
239200 if self.total > 0 {
240201 let mut status = DynamicLabel::<20>::new(STATUS_REGION, &FONT_6X13)
241202 .alignment(Alignment::CenterRight);
242203 use core::fmt::Write;
243243- let _ = write!(
244244- status,
245245- "{}/{}",
246246- self.scroll + self.selected + 1,
247247- self.total
248248- );
204204+ let _ = write!(status, "{}/{}", self.scroll + self.selected + 1, self.total);
249205 status.draw(strip).unwrap();
250206 }
251207252252- // Error state
253208 if let Some(msg) = self.error {
254209 Label::new(row_region(0), msg, &FONT_10X20)
255210 .alignment(Alignment::CenterLeft)
···258213 return;
259214 }
260215261261- // Empty state
262216 if self.count == 0 && !self.needs_load {
263217 Label::new(row_region(0), "No files found", &FONT_10X20)
264218 .alignment(Alignment::CenterLeft)
···267221 return;
268222 }
269223270270- // File list
271224 for i in 0..PAGE_SIZE {
272225 let region = row_region(i);
273226···281234 }
282235 btn.draw(strip).unwrap();
283236 } else {
284284- // Clear empty rows
285237 region
286238 .to_rect()
287287- .into_styled(
288288- embedded_graphics::primitives::PrimitiveStyle::with_fill(
289289- embedded_graphics::pixelcolor::BinaryColor::Off,
290290- ),
291291- )
239239+ .into_styled(embedded_graphics::primitives::PrimitiveStyle::with_fill(
240240+ embedded_graphics::pixelcolor::BinaryColor::Off,
241241+ ))
292242 .draw(strip)
293243 .unwrap();
294244 }
295245 }
296246 }
297297-298247}
···11-//! App framework for pulp-os
22-//!
33-//! Apps are self-contained screen-owning modules. The launcher manages
44-//! transitions between them and routes input events.
55-//!
66-//! # Writing an app
77-//!
88-//! ```ignore
99-//! pub struct MyApp { /* state */ }
1010-//!
1111-//! impl App for MyApp {
1212-//! fn on_enter(&mut self, ctx: &mut AppContext) {
1313-//! ctx.request_full_redraw();
1414-//! }
1515-//!
1616-//! fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition {
1717-//! match event {
1818-//! Event::Press(Button::Back) => Transition::Pop,
1919-//! Event::Press(Button::Right) => {
2020-//! self.selected += 1;
2121-//! ctx.mark_dirty(old_region);
2222-//! ctx.mark_dirty(new_region);
2323-//! Transition::None
2424-//! }
2525-//! _ => Transition::None,
2626-//! }
2727-//! }
2828-//!
2929-//! fn draw(&self, strip: &mut StripBuffer) {
3030-//! // Draw widgets — called per-strip during refresh
3131-//! }
3232-//!
3333-//! // For apps with async I/O:
3434-//! fn needs_work(&self) -> bool { self.needs_load }
3535-//!
3636-//! fn on_work<SPI: embedded_hal::spi::SpiDevice>(
3737-//! &mut self, svc: &mut Services<'_, SPI>, ctx: &mut AppContext,
3838-//! ) {
3939-//! // Use svc.dir_page(), svc.read_file_chunk(), etc.
4040-//! // Then ctx.mark_dirty() for changed regions.
4141-//! }
4242-//! }
4343-//! ```
11+// Application framework and launcher
22+//
33+// Apps are stack allocated structs behind the App trait. The Launcher
44+// holds a fixed depth navigation stack (max 4) and an AppContext for
55+// inter-app messaging and redraw requests. No dyn dispatch, no heap.
66+//
77+// Lifecycle: on_enter -> on_event* -> on_suspend/on_exit
88+// on_resume -> on_event* -> on_exit
99+//
1010+// Async I/O: apps that need SD access return needs_work() = true.
1111+// The kernel calls on_work() with a Services handle before rendering.
1212+// This prevents stale renders (the "render ownership invariant"):
1313+// if needs_work() is true, PollInput will not enqueue Render.
1414+//
1515+// Services is the syscall boundary. Apps never touch SPI or caches
1616+// directly. Generic over SPI so board types do not leak in.
44174518pub mod files;
4619pub mod home;
···5326use crate::drivers::storage::{self, DirCache, DirEntry, DirPage};
5427use crate::ui::Region;
55285656-/// Identity of each app in the system.
5729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5830pub enum AppId {
5931 Home,
···6234 Settings,
6335}
64366565-/// What should happen after handling an event.
3737+// Push: new app on top, old app suspended (gets on_resume later)
3838+// Pop: return to parent, current app exits
3939+// Replace: swap in place, no back navigation
4040+// Home: unwind entire stack back to Home
6641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6742pub enum Transition {
6868- /// Stay on current app, no action.
6943 None,
7070- /// Push a new app onto the stack (current stays underneath).
7144 Push(AppId),
7272- /// Pop current app, return to the one below.
7345 Pop,
7474- /// Replace current app entirely (no back navigation).
7546 Replace(AppId),
7676- /// Pop all the way back to Home.
7747 Home,
7848}
79498080-/// Redraw request from an app.
8150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8251pub enum Redraw {
8383- /// Nothing to draw.
8452 None,
8585- /// Partial refresh of a specific region.
8653 Partial(Region),
8787- /// Full screen refresh needed (e.g. on first enter).
8854 Full,
8955}
90569191-/// Small message buffer for passing data between apps.
9292-/// e.g. file browser sets a path, reader reads it on entry.
5757+// 64 bytes is enough for an 8.3 filename or short path
9358const MSG_BUF_SIZE: usize = 64;
94599595-/// Shared context passed to apps. Gives access to cross-app
9696-/// communication without apps needing to know about each other.
9760pub struct AppContext {
9898- /// Message buffer for inter-app data (file path, etc.)
9961 msg_buf: [u8; MSG_BUF_SIZE],
10062 msg_len: usize,
101101- /// Redraw request set by the app
10263 redraw: Redraw,
10364}
10465···11172 }
11273 }
11374114114- /// Set a message for the next app (e.g. a file path to open).
11575 pub fn set_message(&mut self, data: &[u8]) {
11676 let len = data.len().min(MSG_BUF_SIZE);
11777 self.msg_buf[..len].copy_from_slice(&data[..len]);
11878 self.msg_len = len;
11979 }
12080121121- /// Read the message left by the previous app.
12281 pub fn message(&self) -> &[u8] {
12382 &self.msg_buf[..self.msg_len]
12483 }
12584126126- /// Read message as UTF-8 string.
12785 pub fn message_str(&self) -> &str {
12886 core::str::from_utf8(self.message()).unwrap_or("")
12987 }
13088131131- /// Clear the message buffer.
13289 pub fn clear_message(&mut self) {
13390 self.msg_len = 0;
13491 }
13592136136- /// Request a full screen redraw.
13793 pub fn request_full_redraw(&mut self) {
13894 self.redraw = Redraw::Full;
13995 }
14096141141- /// Request a partial redraw of a region.
142142- /// If a partial is already pending, coalesces via bounding box union.
14397 pub fn request_partial_redraw(&mut self, region: Region) {
14498 match self.redraw {
14599 Redraw::Full => {}
···150104 }
151105 }
152106153153- /// Mark a region as needing redraw. Apps call this during `on_event`
154154- /// for each UI element that changed — the framework coalesces
155155- /// multiple calls via bounding-box union automatically.
156156- ///
157157- /// This is the primary way apps request visual updates. Prefer
158158- /// marking individual changed elements over full-screen redraws
159159- /// to minimize e-paper flicker and SPI transfer.
107107+ // primary way apps request visual updates; coalesces via bounding box
160108 #[inline]
161109 pub fn mark_dirty(&mut self, region: Region) {
162110 self.request_partial_redraw(region);
···166114 !matches!(self.redraw, Redraw::None)
167115 }
168116169169- /// Take the pending redraw request (resets to None).
170117 pub fn take_redraw(&mut self) -> Redraw {
171118 let r = self.redraw;
172119 self.redraw = Redraw::None;
···174121 }
175122}
176123177177-// ── Services ───────────────────────────────────────────────────
178178-//
179179-// The app-OS boundary. Apps request hardware operations through
180180-// Services methods — they never touch SPI, SD, or caches directly.
181181-//
182182-// Constructed by the kernel per-job, borrowing the long-lived
183183-// state from main(). Zero-cost: just assembles two references.
184184-//
185185-// Generic over SPI so no concrete board types leak into the app
186186-// framework. The compiler monomorphizes to the real type via
187187-// with_app! dispatch in main.rs.
188188-189189-/// OS services available to apps during `on_work`.
190190-///
191191-/// This is the "syscall interface". Apps get high-level operations
192192-/// (load a directory page, read a file chunk) without knowing about
193193-/// SPI buses, SD protocols, or caching strategies.
194124pub struct Services<'a, SPI: embedded_hal::spi::SpiDevice> {
195125 dir_cache: &'a mut DirCache,
196126 sd: &'a SdStorage<SPI>,
197127}
198128199129impl<'a, SPI: embedded_hal::spi::SpiDevice> Services<'a, SPI> {
200200- /// Construct Services. Called by the kernel at the start of AppWork.
201130 pub fn new(dir_cache: &'a mut DirCache, sd: &'a SdStorage<SPI>) -> Self {
202131 Self { dir_cache, sd }
203132 }
204133205205- /// Load a page of directory entries from the root directory.
206206- ///
207207- /// Uses an internal cache — first call reads from SD (~100ms),
208208- /// subsequent calls are pure memory copies (instant).
209134 pub fn dir_page(
210135 &mut self,
211136 offset: usize,
···215140 Ok(self.dir_cache.page(offset, buf))
216141 }
217142218218- /// Mark the directory cache as stale. Next `dir_page()` will
219219- /// re-read from SD. Call this when the SD card contents may
220220- /// have changed (e.g. fresh app entry).
221143 pub fn invalidate_dir_cache(&mut self) {
222144 self.dir_cache.invalidate();
223145 }
224146225225- /// Read a chunk of a file starting at `offset`.
226226- /// Returns the number of bytes read into `buf`.
227147 pub fn read_file_chunk(
228148 &self,
229149 name: &str,
···233153 storage::read_file_chunk(self.sd, name, offset, buf)
234154 }
235155236236- /// Get the size of a file in the root directory.
237156 pub fn file_size(&self, name: &str) -> Result<u32, &'static str> {
238157 storage::file_size(self.sd, name)
239158 }
240159}
241160242242-/// Core app trait. Each app implements this.
243243-///
244244-/// Apps are statically allocated and own their UI state.
245245-/// They don't hold references to hardware — that's passed
246246-/// in via the draw callback and main loop.
247247-///
248248-/// ## Lifecycle
249249-///
250250-/// - `on_enter` — first activation, or replacing another app.
251251-/// Call `ctx.request_full_redraw()`.
252252-/// - `on_exit` — permanently leaving (Pop, Replace, Home).
253253-/// - `on_suspend` — being pushed behind a child app. Override to
254254-/// preserve state (default: delegates to `on_exit`).
255255-/// - `on_resume` — returning from a child app. Override to skip
256256-/// expensive reinit (default: delegates to `on_enter`).
257257-///
258258-/// ## Async work
259259-///
260260-/// Some apps need deferred I/O (loading files, reading SD).
261261-/// Override `needs_work()` to return true when work is pending,
262262-/// and `on_work()` to perform it using OS `Services`. The kernel
263263-/// schedules this as a generic `AppWork` job — no app-specific
264264-/// job variants or handlers needed.
265161pub trait App {
266266- /// Called when this app becomes the active screen for the first time
267267- /// (or via Replace). Read `ctx.message()` for data from the launcher.
268162 fn on_enter(&mut self, ctx: &mut AppContext);
269269-270270- /// Called when this app is permanently removed from the stack.
271271- fn on_exit(&mut self) {
272272- // Default: no-op
273273- }
274274-275275- /// Called when a child app is pushed on top.
276276- /// The app stays on the stack and will get `on_resume` when the
277277- /// child pops. Override to preserve state; default delegates to `on_exit`.
163163+ fn on_exit(&mut self) {}
278164 fn on_suspend(&mut self) {
279165 self.on_exit();
280166 }
281281-282282- /// Called when returning from a child app (the child popped).
283283- /// Override to skip reloading data that's still valid.
284284- /// Default delegates to `on_enter`.
285167 fn on_resume(&mut self, ctx: &mut AppContext) {
286168 self.on_enter(ctx);
287169 }
288288-289289- /// Handle an input event. Return a transition to navigate.
290290- ///
291291- /// Call `ctx.mark_dirty(region)` for each UI element that changed.
292292- /// The framework coalesces multiple dirty regions automatically.
293170 fn on_event(&mut self, event: Event, ctx: &mut AppContext) -> Transition;
294171295295- /// Draw the app's UI into the strip buffer.
296296- /// Called once per strip during refresh — widgets clip automatically.
172172+ // called once per strip during refresh; widgets clip automatically
297173 fn draw(&self, strip: &mut StripBuffer);
298174299299- /// Does this app have async work pending?
300300- ///
301301- /// Called after every event to decide whether to enqueue `AppWork`.
302302- /// When true, `on_work()` will be called before the next render —
303303- /// this preserves the render ownership invariant (no stale renders).
304175 fn needs_work(&self) -> bool {
305176 false
306177 }
307307-308308- /// Perform async work using OS services.
309309- ///
310310- /// Called by the kernel when the `AppWork` job fires. Use `services`
311311- /// for I/O (directory listing, file reads) and `ctx.mark_dirty()`
312312- /// to request a render of changed regions.
313313- ///
314314- /// The kernel handles render scheduling — just mark what changed.
315178 fn on_work<SPI: embedded_hal::spi::SpiDevice>(
316179 &mut self,
317180 _services: &mut Services<'_, SPI>,
318181 _ctx: &mut AppContext,
319182 ) {
320320- // Default: no-op
321183 }
322184}
323185324324-/// App navigation stack. Fixed-size, no heap.
325186const MAX_STACK_DEPTH: usize = 4;
326187327327-/// Describes which lifecycle methods to call after a navigation.
328188#[derive(Debug, Clone, Copy)]
329189pub struct NavEvent {
330190 pub from: AppId,
331191 pub to: AppId,
332332- /// If true, `from` was suspended (still on stack → call `on_suspend`).
333333- /// If false, `from` was removed (→ call `on_exit`).
334192 pub suspend: bool,
335335- /// If true, `to` was already on the stack (→ call `on_resume`).
336336- /// If false, `to` is freshly entered (→ call `on_enter`).
337193 pub resume: bool,
338194}
339195···347203 pub const fn new() -> Self {
348204 Self {
349205 stack: [AppId::Home; MAX_STACK_DEPTH],
350350- depth: 1, // Start with Home
206206+ depth: 1,
351207 ctx: AppContext::new(),
352208 }
353209 }
354210355355- /// Currently active app.
356211 pub fn active(&self) -> AppId {
357212 self.stack[self.depth - 1]
358213 }
359214360360- /// Apply a transition. Returns a `NavEvent` if an app switch
361361- /// occurred, so the main loop can call the correct lifecycle methods.
362215 pub fn apply(&mut self, transition: Transition) -> Option<NavEvent> {
363216 let old = self.active();
364217···367220368221 Transition::Push(id) => {
369222 if self.depth >= MAX_STACK_DEPTH {
370370- // Stack full — can't preserve the old app, must replace.
371223 self.stack[self.depth - 1] = id;
372372- (false, false) // exit old, enter new
224224+ (false, false)
373225 } else {
374226 self.stack[self.depth] = id;
375227 self.depth += 1;
376376- (true, false) // suspend old, enter new
228228+ (true, false)
377229 }
378230 }
379231380232 Transition::Pop => {
381233 if self.depth > 1 {
382234 self.depth -= 1;
383383- (false, true) // exit old, resume parent
235235+ (false, true)
384236 } else {
385385- return None; // Can't pop below Home
237237+ return None;
386238 }
387239 }
388240389241 Transition::Replace(id) => {
390242 self.stack[self.depth - 1] = id;
391391- (false, false) // exit old, enter new
243243+ (false, false)
392244 }
393245394246 Transition::Home => {
395247 self.depth = 1;
396248 self.stack[0] = AppId::Home;
397397- (false, true) // exit old, resume Home
249249+ (false, true)
398250 }
399251 };
400252···411263 }
412264 }
413265414414- /// Stack depth (for debug).
415266 pub fn depth(&self) -> usize {
416267 self.depth
417268 }
+2-4
src/apps/reader.rs
···11-//! Reader app (stub).
22-//!
33-//! Will eventually render e-book content. For now shows the filename
44-//! passed from the file browser.
11+// File viewer (stub)
22+// Receives filename from FilesApp via ctx.message() on entry.
5364use embedded_graphics::mono_font::ascii::FONT_10X20;
75
···11-//! Raw GPIO output for pins not exposed by esp-hal (e.g. flash pins on ESP32-C3).
22-//!
33-//! The XTEink X4 uses DIO flash mode, freeing GPIO12 (SPIHD) and GPIO13 (SPIWP)
44-//! for general use. esp-hal 1.0 doesn't generate peripheral types for GPIO12-17
55-//! on ESP32-C3, so we drive the pin via direct register writes.
66-//!
77-//! Only implements `embedded_hal::digital::OutputPin` — enough for SPI CS.
11+// Direct register GPIO for pins esp-hal does not expose
22+//
33+// ESP32-C3 in DIO flash mode frees GPIO12 (SPIHD) and GPIO13 (SPIWP).
44+// esp-hal 1.0 has no peripheral types for GPIO12..17 on this chip,
55+// so we bang the registers ourselves. Only OutputPin is implemented.
8699-const GPIO_OUT_W1TS: u32 = 0x6000_4008; // Set output high (write-1-to-set)
1010-const GPIO_OUT_W1TC: u32 = 0x6000_400C; // Set output low (write-1-to-clear)
1111-const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // Enable output (write-1-to-set)
1212-const IO_MUX_BASE: u32 = 0x6000_9000; // IO_MUX register base
1313-const IO_MUX_PIN_STRIDE: u32 = 0x04; // Each pin has a 4-byte register
77+const GPIO_OUT_W1TS: u32 = 0x6000_4008; // write 1 to set output high
88+const GPIO_OUT_W1TC: u32 = 0x6000_400C; // write 1 to set output low
99+const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // write 1 to enable output
1010+const IO_MUX_BASE: u32 = 0x6000_9000;
1111+const IO_MUX_PIN_STRIDE: u32 = 0x04;
14121515-/// Minimal output-only GPIO driver using direct register access.
1616-///
1717-/// Implements `OutputPin` + `ErrorType` from embedded-hal so it can
1818-/// be used as a chip-select pin with `RefCellDevice` / `ExclusiveDevice`.
1913pub struct RawOutputPin {
2020- mask: u32, // Bit mask for this pin (1 << pin_number)
1414+ mask: u32,
2115}
22162317impl RawOutputPin {
2424- /// Configure a GPIO as push-pull output, initially HIGH.
2525- ///
2626- /// # Safety
2727- /// Caller must ensure:
2828- /// - The pin is physically available (not connected to active flash lines)
2929- /// - No other driver is controlling the same pin
1818+ // Pin must not be in active use by flash or another driver.
3019 pub unsafe fn new(pin: u8) -> Self {
3120 let mask = 1u32 << pin;
32213333- // Configure IO_MUX: select GPIO function (function 1), enable output
3422 let mux_reg = (IO_MUX_BASE + pin as u32 * IO_MUX_PIN_STRIDE) as *mut u32;
35233636- // Read-modify-write to preserve reserved bits, set function to GPIO.
3737- // Bits [2:0] = MCU_SEL = 1 (GPIO function)
3824 unsafe {
2525+ // IO_MUX: MCU_SEL[2:0] = 1 selects GPIO function
3926 let val = mux_reg.read_volatile();
4027 let val = (val & !0b111) | 1;
4128 mux_reg.write_volatile(val);
42294343- // Configure GPIO matrix: enable output for this pin
4444- // GPIO_FUNCn_OUT_SEL_CFG register (base 0x60004554, stride 4)
3030+ // GPIO_FUNCn_OUT_SEL_CFG: 0x80 = simple GPIO output
4531 let out_sel = (0x6000_4554 + pin as u32 * 4) as *mut u32;
4646- out_sel.write_volatile(0x80); // SIG_OUT = 128 (simple GPIO output)
3232+ out_sel.write_volatile(0x80);
47334848- // Enable output
4934 (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask);
50355151- // Drive HIGH initially (CS deasserted)
5236 (GPIO_OUT_W1TS as *mut u32).write_volatile(mask);
5337 }
5438
+4-13
src/board/sdcard.rs
···11-//! SD Card support for XTEink X4
22-//!
33-//! The SD card shares the SPI2 bus with the e-paper display.
44-//! Bus arbitration is handled at the board level using RefCellDevice.
11+// SD card over SPI with FAT volume manager
22+// No RTC on board; timestamps are fixed to 2025-01-01.
33+54use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeManager};
65use log::info;
7688-// Dummy time source for FAT timestamps (X4 has no RTC).
97#[derive(Default, Clone, Copy)]
108pub struct DummyTimeSource;
1191210impl TimeSource for DummyTimeSource {
1311 fn get_timestamp(&self) -> Timestamp {
1412 Timestamp {
1515- year_since_1970: 55, // 2025
1313+ year_since_1970: 55,
1614 zero_indexed_month: 0,
1715 zero_indexed_day: 0,
1816 hours: 0,
···2220 }
2321}
24222525-// sd card initialization frequency (Hz).
2623pub const SD_INIT_FREQ_HZ: u32 = 400_000;
27242828-// Normal operating frequency after init
2929-// TODO: Put this somewhere else?
3025pub const SD_NORMAL_FREQ_HZ: u32 = 20_000_000;
31263232-// Wrapper that holds the SdCard + VolumeManager together.
3327pub struct SdStorage<SPI>
3428where
3529 SPI: embedded_hal::spi::SpiDevice,
···4135where
4236 SPI: embedded_hal::spi::SpiDevice,
4337{
4444- // Create SD storage, probing the card during construction.
4538 pub fn new(spi: SPI) -> Self {
4639 let sdcard = SdCard::new(spi, esp_hal::delay::Delay::new());
47404848- // Probe card before handing ownership to VolumeManager.
4949- // This triggers the SD init sequence (CMD0, CMD8, ACMD41, etc).
5041 match sdcard.num_bytes() {
5142 Ok(bytes) => info!("SD card: {} bytes ({} MB)", bytes, bytes / 1024 / 1024),
5243 Err(e) => info!("SD card probe failed: {:?}", e),
+12-47
src/board/strip.rs
···11-// Strip-based rendering buffer
11+// Strip based rendering buffer for e-paper
22//
33-// Instead of holding a full 48KB framebuffer in SRAM, we render
44-// through a small strip buffer (~4KB) and stream each strip to
55-// the display controller via SPI.
66-// The display is divided into horizontal bands of physical rows.
77-// For each band:
88-// 1. Clear the strip buffer to white
99-// 2. Draw all widgets (DrawTarget clips to current band)
1010-// 3. SPI transfer the strip data to display controller RAM
1111-// 4. Reuse the buffer for the next band
33+// Renders through a 4KB strip instead of a full 48KB framebuffer.
44+// The display is split into horizontal bands; each band is cleared,
55+// drawn into by all visible widgets, then sent over SPI. Widgets
66+// always draw to full logical screen coords; clipping happens here.
77+//
88+// Two modes:
99+// begin_strip() -- full width, fixed height (full page refresh)
1010+// begin_window() -- arbitrary rect (partial refresh)
12111312use embedded_graphics_core::{
1413 Pixel,
···19182019use super::display::{HEIGHT, Rotation, WIDTH};
21202222-// Physical rows per strip for full-page rendering.
2323-// 480 / 40 = 12 strips, each 100 bytes/row × 40 rows = 4000 bytes.
2424-pub const STRIP_ROWS: u16 = 40;
2525-pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8; // 100
2121+pub const STRIP_ROWS: u16 = 40; // 800/8 * 40 = 4000B per strip
2222+pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8;
26232724pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000
2825pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12
29263030-// A small rendering buffer that covers a physical rectangle of the display.
3131-//
3232-// Operates in two modes:
3333-// - Full-width strips: For full-page rendering. Covers 800×40 physical
3434-// pixels (4000 bytes). Iterate 12 strips top-to-bottom.
3535-// - Windowed: For partial refresh of small UI regions. Covers an
3636-// arbitrary physical rectangle that fits within STRIP_BUF_SIZE bytes.
3727pub struct StripBuffer {
3828 buf: [u8; STRIP_BUF_SIZE],
3929 rotation: Rotation,
4040- // Physical window this strip covers
4130 win_x: u16,
4231 win_y: u16,
4332 win_w: u16,
4433 win_h: u16,
4545- // Derived from win_w for indexing
4634 row_bytes: u16,
4735}
48364937impl StripBuffer {
5038 pub const fn new() -> Self {
5139 Self {
5252- buf: [0xFF; STRIP_BUF_SIZE], // White
4040+ buf: [0xFF; STRIP_BUF_SIZE],
5341 rotation: Rotation::Deg270,
5442 win_x: 0,
5543 win_y: 0,
···5947 }
6048 }
61496262- // Configure for full-width strip rendering at the given strip index.
6363- // Clears the buffer to white.
6464- //
6565- // strip_idx: 0..STRIP_COUNT (physical row bands top-to-bottom)
6650 pub fn begin_strip(&mut self, rotation: Rotation, strip_idx: u16) {
6751 self.rotation = rotation;
6852 self.win_x = 0;
···7155 self.win_h = STRIP_ROWS;
7256 self.row_bytes = PHYS_BYTES_PER_ROW as u16;
73577474- // Clear to white
7558 self.buf[..STRIP_BUF_SIZE].fill(0xFF);
7659 }
77607878- // Configure for an arbitrary physical window (partial refresh mode).
7979- // Region must be byte-aligned (x and w multiples of 8).
8080- // NOTE: Panics if the window doesn't fit in STRIP_BUF_SIZE.
8161 pub fn begin_window(&mut self, rotation: Rotation, x: u16, y: u16, w: u16, h: u16) {
8262 let rb = (w / 8) as usize;
8363 let total = rb * h as usize;
···10080 self.buf[..total].fill(0xFF);
10181 }
10282103103- // Get the valid data bytes for SPI transfer.
104104- // Only the bytes covering the current window are returned.
10583 pub fn data(&self) -> &[u8] {
10684 let total = self.row_bytes as usize * self.win_h as usize;
10785 &self.buf[..total]
10886 }
10987110110- // Current window's physical origin and size.
11188 pub fn window(&self) -> (u16, u16, u16, u16) {
11289 (self.win_x, self.win_y, self.win_w, self.win_h)
11390 }
···11693 STRIP_COUNT
11794 }
11895119119- // Max rows that fit in the buffer at a given window width.
12096 pub fn max_rows_for_width(width: u16) -> u16 {
12197 let rb = (width / 8) as usize;
12298 if rb == 0 {
···125101 (STRIP_BUF_SIZE / rb) as u16
126102 }
127103128128- // Transform logical coordinates to physical based on rotation.
129104 #[inline]
130105 fn to_physical(&self, lx: u16, ly: u16) -> (u16, u16) {
131106 match self.rotation {
···136111 }
137112 }
138113139139- // set a pixel in the buffer using physical coordinates.
140140- // silently clips if outside current window.
141114 #[inline]
142115 fn set_pixel_physical(&mut self, px: u16, py: u16, black: bool) {
143143- // clip to window
144116 if px < self.win_x || px >= self.win_x + self.win_w {
145117 return;
146118 }
···167139 }
168140}
169141170170-// embedded-graphics integration
171171-172142impl OriginDimensions for StripBuffer {
173173- // Report FULL logical display size.
174174- // Widgets think they're drawing to the entire screen;
175175- // the strip clips at the physical level.
176143 fn size(&self) -> Size {
177144 match self.rotation {
178145 Rotation::Deg0 | Rotation::Deg180 => Size::new(WIDTH as u32, HEIGHT as u32),
···194161 let log_h = size.height as i32;
195162196163 for Pixel(coord, color) in pixels {
197197- // Bounds check against logical dimensions
198164 if coord.x < 0 || coord.x >= log_w || coord.y < 0 || coord.y >= log_h {
199165 continue;
200166 }
201167202202- // Transform logical → physical, then clip to current strip
203168 let (px, py) = self.to_physical(coord.x as u16, coord.y as u16);
204169 self.set_pixel_physical(px, py, color == BinaryColor::On);
205170 }
+5-9
src/drivers/battery.rs
···11-//! Battery monitoring for XTEink X4
22-//!
33-//! GPIO0 reads battery voltage through an on-board voltage divider (1:1, 100K/100K).
44-//! ADC with 11dB attenuation reads 0-2500mV; multiply by 2 for actual battery voltage.
55-//! Li-ion cell: 4200mV = 100%, 3000mV = 0%.
11+// Li-ion battery voltage estimation
22+//
33+// GPIO0 reads through a 100K/100K divider (2:1). ADC with 11dB
44+// attenuation gives 0..2500mV; multiply by 2 for actual cell voltage.
55+// Linear approximation: 4200mV = 100%, 3000mV = 0%.
6677-/// Voltage divider ratio (100K/100K = 2:1)
87const DIVIDER_MULT: u32 = 2;
981010-/// Li-ion voltage bounds in millivolts
119const VBAT_FULL_MV: u32 = 4200;
1210const VBAT_EMPTY_MV: u32 = 3000;
13111414-/// Convert ADC millivolts (post-calibration) to actual battery millivolts.
1512pub fn adc_to_battery_mv(adc_mv: u16) -> u16 {
1613 (adc_mv as u32 * DIVIDER_MULT) as u16
1714}
18151919-/// Battery voltage to charge percentage (0-100), linear approximation.
2016pub fn battery_percentage(battery_mv: u16) -> u8 {
2117 let mv = battery_mv as u32;
2218 if mv >= VBAT_FULL_MV {
+7-27
src/drivers/input.rs
···11-// Input event driver for xteink x4
11+// Debounced input from ADC ladders and power button
22+//
33+// Three sources, one button at a time (hardware limitation of ladders):
44+// Row1 ADC (GPIO1): Right, Left, Confirm, Back
55+// Row2 ADC (GPIO2): VolUp, VolDown
66+// Power (GPIO3): interrupt driven, read via board::power_button_is_low()
27//
33-// The X4 has three physical input sources that all funnel into a
44-// single "one button at a time" deal:
55-// - Row 1 ADC (GPIO1): Right, Left, Confirm, Back via resistance ladder
66-// - Row 2 ADC (GPIO2): Volume Up/Down via resistance ladder
77-// - Power button (GPIO3): Interrupt-driven, read via board::power_button_is_low()
88-// NOTE: Because each resistance ladder can only report one press at a time,
99-// we collapse everything into `Option<Button>` per poll cycle.
88+// 30ms debounce, 1s long press, 150ms repeat.
1091110use esp_hal::time::{Duration, Instant};
1211···2524 Repeat(Button),
2625}
27262828-// Small fixed-size event queue for buffering multiple events per poll.
2927struct EventQueue {
3028 buf: [Option<Event>; 2],
3129 read: u8,
···4644 return;
4745 }
4846 }
4949- // If both slots are full, something is wrong with our logic.
5047 }
51485249 fn pop(&mut self) -> Option<Event> {
···5754 return Some(ev);
5855 }
5956 }
6060- // Reset for next cycle
6157 self.read = 0;
6258 None
6359 }
···6763 }
6864}
69657070-// debounce, long-press, and repeat support.
7166pub struct InputDriver {
7267 hw: InputHw,
7368 stable: Option<Button>,
···9489 }
9590 }
96919797- // poll for the next input event.
9892 pub fn poll(&mut self) -> Option<Event> {
9999- // drain any buffd events first
10093 if !self.queue.is_empty() {
10194 return self.queue.pop();
10295 }
···115108 self.stable
116109 };
117110118118- // normal press
119111 if debounced != self.stable {
120112 if let Some(old) = self.stable {
121113 self.queue.push(Event::Release(old));
···130122 return self.queue.pop();
131123 }
132124133133- // long press and repeat
134125 if let Some(btn) = self.stable {
135126 let held = now - self.press_since;
136127···140131 return Some(Event::LongPress(btn));
141132 }
142133143143- // Fire repeat events at interval
144134 if self.long_press_fired && (now - self.last_repeat) >= Duration::from_millis(REPEAT_MS)
145135 {
146136 self.last_repeat = now;
···151141 None
152142 }
153143154154- /// Read raw button state from hardware (before debouncing).
155144 fn read_raw(&mut self) -> Option<Button> {
156156- // Power button: interrupt-driven, read level from shared static.
157157- // The GPIO interrupt already woke us via signal_button();
158158- // here we just need the current pin state for debounce.
159145 if crate::board::power_button_is_low() {
160146 return Some(Button::Power);
161147 }
162148163163- // read adc channels & decode
164149 let mv1: u16 = nb::block!(self.hw.adc.read_oneshot(&mut self.hw.row1)).unwrap();
165150 let mv2: u16 = nb::block!(self.hw.adc.read_oneshot(&mut self.hw.row2)).unwrap();
166151167152 decode_ladder(mv1, ROW1_THRESHOLDS).or_else(|| decode_ladder(mv2, ROW2_THRESHOLDS))
168153 }
169154170170- /// Read battery voltage in millivolts (ADC-calibrated, before divider correction).
171171- /// The X4 has a voltage divider on GPIO0 — multiply by 2 for actual battery mV.
172155 pub fn read_battery_mv(&mut self) -> u16 {
173156 nb::block!(self.hw.adc.read_oneshot(&mut self.hw.battery)).unwrap()
174157 }
175158176176- /// True when raw button activity was detected but debounce hasn't
177177- /// confirmed it yet. Used by the idle timer to snap back to fast
178178- /// polling so the confirmation arrives in ~10ms, not ~100ms.
179159 pub fn is_debouncing(&self) -> bool {
180160 self.candidate.is_some() && self.candidate != self.stable
181161 }
+2-1
src/drivers/mod.rs
···11+// Hardware abstraction for battery, input, and SD storage
22+13pub mod battery;
24pub mod input;
35pub mod storage;
44-// pub mod display_driver;
+5-49
src/drivers/storage.rs
···11-//! High-level file operations for the SD card.
22-//!
33-//! Uses embedded-sdmmc 0.9's RAII handles (Volume, Directory, File)
44-//! which close automatically on drop.
11+// SD card file operations and directory cache
22+//
33+// FAT directory iteration has no seek; every listing scans from entry 0.
44+// DirCache reads all entries once into RAM and serves pages from there.
55+// 128 entries * 20 bytes = 2.5KB of SRAM.
5667use embedded_sdmmc::{Mode, VolumeIdx};
7889use crate::board::sdcard::SdStorage;
9101010-/// A single directory entry, small enough to keep a page on the stack.
1111#[derive(Clone, Copy)]
1212pub struct DirEntry {
1313 pub name: [u8; 13],
···2929 }
3030}
31313232-/// Result of a paginated directory listing.
3332pub struct DirPage {
3433 pub total: usize,
3534 pub count: usize,
3635}
37363838-// ── Directory cache ────────────────────────────────────────────
3939-//
4040-// FAT directory iteration has no seek — every list_page() must scan
4141-// from the first entry and skip. For scroll position 40, that's
4242-// 40 entries read and discarded. With 100ms+ per SD transaction,
4343-// scrolling feels sluggish.
4444-//
4545-// The cache reads ALL entries once and serves pages from RAM.
4646-// Subsequent scrolls are pure memory copies — instant.
4747-//
4848-// Memory: 128 entries × 20 bytes = 2.5KB (of 400KB SRAM).
4949-5050-/// Maximum directory entries we'll cache.
5137pub const MAX_DIR_ENTRIES: usize = 128;
52385353-/// In-memory cache of a directory's entries.
5454-///
5555-/// Created once in main.rs, lives for the lifetime of the program.
5656-/// `ensure_loaded()` fills it from SD on first access; `page()`
5757-/// serves slices without touching hardware.
5839pub struct DirCache {
5940 entries: [DirEntry; MAX_DIR_ENTRIES],
6041 count: usize,
···7051 }
7152 }
72537373- /// Load all entries from the root directory if not already cached.
7474- /// Returns Ok(()) if cache is warm (already valid), or after a
7575- /// successful SD read. Returns Err only on SD failure.
7654 pub fn ensure_loaded<SPI>(&mut self, sd: &SdStorage<SPI>) -> Result<(), &'static str>
7755 where
7856 SPI: embedded_hal::spi::SpiDevice,
···11189 Ok(())
11290 }
11391114114- /// Copy a page of entries into `buf`, starting at `skip`.
115115- /// Pure memory operation — no SD access.
11692 pub fn page(&self, skip: usize, buf: &mut [DirEntry]) -> DirPage {
11793 let available = self.count.saturating_sub(skip);
11894 let count = available.min(buf.len());
···125101 }
126102 }
127103128128- /// Mark cache as stale. Next `ensure_loaded()` will re-read from SD.
129104 pub fn invalidate(&mut self) {
130105 self.valid = false;
131106 }
132107133133- /// Total cached entries (0 if not loaded).
134108 pub fn total(&self) -> usize {
135109 self.count
136110 }
137111138138- /// Whether the cache has been loaded.
139112 pub fn is_valid(&self) -> bool {
140113 self.valid
141114 }
142115}
143116144144-/// List one page of entries from root directory.
145145-///
146146-/// Skips the first `skip` entries, then fills `buf` with up to `buf.len()` entries.
147147-/// Returns total entry count and how many were written to buf.
148117pub fn list_page<SPI>(
149118 sd: &SdStorage<SPI>,
150119 skip: usize,
···164133 let page_size = buf.len();
165134166135 root.iterate_dir(|entry| {
167167- // Skip dot entries
168136 if entry.name.base_name()[0] == b'.' {
169137 return;
170138 }
···190158 })
191159}
192160193193-/// List files in the root directory, calling `cb` for each entry.
194194-/// Returns the number of entries found.
195161pub fn list_root_dir<SPI>(
196162 sd: &SdStorage<SPI>,
197163 mut cb: impl FnMut(&str, bool, u32),
···219185 Ok(count)
220186}
221187222222-/// Get the size of a file in the root directory.
223188pub fn file_size<SPI>(sd: &SdStorage<SPI>, name: &str) -> Result<u32, &'static str>
224189where
225190 SPI: embedded_hal::spi::SpiDevice,
···236201 Ok(file.length())
237202}
238203239239-/// Read an entire file (or up to buf.len() bytes) into a buffer.
240240-/// Returns the number of bytes read.
241204pub fn read_file<SPI>(
242205 sd: &SdStorage<SPI>,
243206 name: &str,
···267230 Ok(total)
268231}
269232270270-/// Read a chunk of a file starting at `offset`.
271271-/// Returns the number of bytes read.
272233pub fn read_file_chunk<SPI>(
273234 sd: &SdStorage<SPI>,
274235 name: &str,
···301262 Ok(total)
302263}
303264304304-/// Write data to a file (create or truncate).
305265pub fn write_file<SPI>(sd: &SdStorage<SPI>, name: &str, data: &[u8]) -> Result<(), &'static str>
306266where
307267 SPI: embedded_hal::spi::SpiDevice,
···321281 Ok(())
322282}
323283324324-/// Format a ShortFileName (8.3) into a human-readable "NAME.EXT" string.
325325-/// Returns the number of bytes written to `out`.
326284fn format_83_name(sfn: &embedded_sdmmc::ShortFileName, out: &mut [u8; 13]) -> usize {
327285 let base = sfn.base_name();
328286 let ext = sfn.extension();
329287330288 let mut pos = 0;
331289332332- // Copy base name, trimming trailing spaces
333290 for &b in base.iter() {
334291 if b == b' ' {
335292 break;
···338295 pos += 1;
339296 }
340297341341- // Add extension if non-empty
342298 let ext_trimmed: &[u8] = &ext[..ext.iter().position(|&b| b == b' ').unwrap_or(ext.len())];
343299 if !ext_trimmed.is_empty() {
344300 out[pos] = b'.';
+2-1
src/kernel/mod.rs
···11-//! Minimal kernel for pulp-os
11+// Cooperative scheduler and wake/sleep primitives
22+// Single core, no preemption. WFI idles the CPU between events.
2334pub mod scheduler;
45pub mod wake;
+10-37
src/kernel/scheduler.rs
···11-// Priority-based job scheduler for cooperative multitasking.
11+// Priority job scheduler, cooperative
22//
33-// Jobs are signals, not data carriers. State lives in the app/driver
44-// that handles the job. The scheduler only decides execution order.
33+// Jobs are signals, not data carriers. State lives in the subsystem
44+// that handles the job. Three priority tiers, FIFO within each.
55+// Fixed size ring buffers, no allocation.
56//
66-// No dynamic allocation — fixed-size ring buffer queues per priority tier.
77+// High: PollInput, Render
88+// Normal: AppWork, UpdateStatusBar
99+// Low: (reserved)
1010+711use core::fmt;
81299-/// Schedulable units of work.
1010-///
1111-/// Jobs carry no payload. The handler reads state from wherever it
1212-/// lives (app struct, driver, context) when the job executes.
1313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1414pub enum Job {
1515- // ── High priority: interactive, latency-sensitive ──────────
1616- /// Poll the input driver for debounced button events.
1715 PollInput,
1818- /// Flush pending redraw (full or partial) to the display.
1916 Render,
2020-2121- // ── Normal priority: responsive but deferrable ─────────────
2222- /// Run the active app's `on_work()` with OS services.
2323- /// Generic — the kernel doesn't know what the app will do.
2424- /// Replaces per-app job variants (no new jobs when adding apps).
2517 AppWork,
2626- /// Sample battery ADC and refresh the status bar text.
2718 UpdateStatusBar,
2828-2929- // ── Low priority: speculative / background ─────────────────
3030- // (Reserved for future work: prefetch, layout, cache)
3119}
32203321impl fmt::Display for Job {
···4129 }
4230}
43314444-/// Job priority levels (lower numeric value = higher priority).
4532#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
4633pub enum Priority {
4734 High = 0,
···60476148#[derive(Debug, Clone, Copy)]
6249pub enum PushError {
6363- /// Queue for this priority level is full; contains the rejected job.
6450 Full(Job),
6551}
6652···7258 }
7359}
74607575-// ── Ring buffer ────────────────────────────────────────────────
7676-7761struct JobQueue<const N: usize> {
7862 buf: [Option<Job>; N],
7979- head: usize, // next to read
8080- tail: usize, // next to write
6363+ head: usize,
6464+ tail: usize,
8165 len: usize,
8266}
8367···132116 }
133117}
134118135135-// ── Scheduler ──────────────────────────────────────────────────
136136-137137-/// Priority-based job scheduler.
138138-///
139139-/// `pop()` always returns the highest-priority pending job (FIFO
140140-/// within each tier). Jobs can enqueue follow-on work during
141141-/// execution — the drain loop picks it up immediately.
142119pub struct Scheduler {
143120 high: JobQueue<4>,
144121 normal: JobQueue<8>,
···154131 }
155132 }
156133157157- /// Push a job; returns error if the priority queue is full.
158134 pub fn push(&mut self, job: Job) -> Result<(), PushError> {
159135 let result = match job.priority() {
160136 Priority::High => self.high.push(job),
···164140 result.map_err(PushError::Full)
165141 }
166142167167- /// Schedule a job only if it's not already queued (dedup).
168168- /// Primary method for enqueuing — prevents duplicate work.
169143 pub fn push_unique(&mut self, job: Job) -> Result<(), PushError> {
170144 match job.priority() {
171145 Priority::High => {
···189163 }
190164 }
191165192192- /// Pop the highest-priority pending job.
193166 pub fn pop(&mut self) -> Option<Job> {
194167 self.high
195168 .pop()
+13-32
src/kernel/wake.rs
···11+// Wake flag signaling between ISRs and the main loop
22+//
33+// ISRs set atomic flags; the main loop consumes them via try_wake().
44+// Flags are independent so concurrent sources (button + timer + display)
55+// never swallow each other. All cleared atomically inside a critical
66+// section to prevent races on riscv32imc (no hardware atomic RMW).
77+//
88+// Uptime is tracked in 10ms base ticks regardless of actual timer
99+// period. When the timer slows to 100ms during idle, TICK_WEIGHT
1010+// is set to 10 so the counter stays in consistent units.
1111+112use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
21333-// Wake source flags (set by ISR, cleared by main loop)
414static WAKE_BUTTON: AtomicBool = AtomicBool::new(false);
515static WAKE_DISPLAY: AtomicBool = AtomicBool::new(false);
616static WAKE_TIMER: AtomicBool = AtomicBool::new(false);
71788-// How many base ticks (10ms) each timer interrupt represents.
99-// Normally 1. When the timer is slowed to 100ms, set to 10
1010-// so uptime_ticks() stays in consistent 10ms units.
1818+// how many 10ms base ticks each timer interrupt represents (1 or 10)
1119static TICK_WEIGHT: AtomicU32 = AtomicU32::new(1);
12201313-// Uptime in base ticks (10ms each), regardless of actual timer period.
1414-// Protected by critical section because riscv32imc lacks atomic RMW.
2121+// critical section because riscv32imc has no atomic add
1522static UPTIME_TICKS: critical_section::Mutex<core::cell::Cell<u32>> =
1623 critical_section::Mutex::new(core::cell::Cell::new(0));
17241818-/// Which wake sources fired since the last check.
1919-///
2020-/// Each flag is independent — multiple sources can fire between
2121-/// checks and none are lost. The main loop tests each flag it
2222-/// cares about and dispatches accordingly.
2325#[derive(Debug, Clone, Copy)]
2426pub struct WakeFlags {
2527 pub button: bool,
···2830}
29313032impl WakeFlags {
3131- /// True if any input-related source fired (button or timer).
3232- /// Timer wakes always poll input because the ADC-based buttons
3333- /// are sampled on the timer tick.
3433 #[inline]
3534 pub fn has_input(&self) -> bool {
3635 self.button || self.timer
3736 }
3837}
39384040-/// Atomically read and clear all pending wake flags.
4141-/// Returns None if nothing fired.
4239fn take_wake_flags() -> Option<WakeFlags> {
4340 critical_section::with(|_| {
4441 let button = WAKE_BUTTON.load(Ordering::Relaxed);
···4946 return None;
5047 }
51485252- // Clear only the flags we observed
5349 if button {
5450 WAKE_BUTTON.store(false, Ordering::Relaxed);
5551 }
···6864 })
6965}
70667171-// power button was pressed.
7267#[inline]
7368pub fn signal_button() {
7469 WAKE_BUTTON.store(true, Ordering::Release);
7570}
76717777-// signal that the display finished refreshing.
7872#[inline]
7973pub fn signal_display() {
8074 WAKE_DISPLAY.store(true, Ordering::Release);
8175}
82768383-// signal a timer tick.
8477#[inline]
8578pub fn signal_timer() {
8679 WAKE_TIMER.store(true, Ordering::Release);
···9184 });
9285}
93869494-/// Set the tick weight — how many base ticks (10ms) each timer
9595-/// interrupt represents. Called when the timer period changes.
9687pub fn set_tick_weight(weight: u32) {
9788 TICK_WEIGHT.store(weight, Ordering::Release);
9889}
9990100100-/// Uptime in base ticks (10ms each) since boot.
101101-/// Stays consistent regardless of actual timer period.
10291pub fn uptime_ticks() -> u32 {
10392 critical_section::with(|cs| UPTIME_TICKS.borrow(cs).get())
10493}
10594106106-/// Uptime in seconds since boot.
10795pub fn uptime_secs() -> u32 {
10896 uptime_ticks() / 100
10997}
···115103 core::arch::asm!("wfi", options(nomem, nostack));
116104 }
117105118118- // For testing on host
119106 #[cfg(not(target_arch = "riscv32"))]
120107 {
121121- // On host, just yield to simulate
122108 std::thread::yield_now();
123109 }
124110}
125111126126-/// Block until a wake event occurs.
127127-/// Used for deep sleep / idle patterns where the caller
128128-/// wants to hand off control entirely until something happens.
129112pub fn sleep_until_wake() -> WakeFlags {
130113 loop {
131114 if let Some(flags) = take_wake_flags() {
···136119 }
137120}
138121139139-/// Non-blocking wake check. Returns the pending wake flags
140140-/// (consuming them) or None if nothing fired.
141122pub fn try_wake() -> Option<WakeFlags> {
142123 take_wake_flags()
143124}
+2
src/lib.rs
···11+// "operating system" for the XTEink X4 (ESP32-C3, e-paper)
22+13#![no_std]
2435pub mod apps;
+2-16
src/ui/button.rs
···11-//! Button widget for interactive UI elements
11+// Interactive button widget with outline, fill, and rounded styles
22+// Inverts colors when pressed.
2334use embedded_graphics::{
45 mono_font::MonoFont,
···11121213use super::widget::{Alignment, Region, Widget, WidgetState};
13141414-/// Button visual style
1515#[derive(Clone, Copy, Debug, Default, PartialEq)]
1616pub enum ButtonStyle {
1717- /// Simple rectangle outline
1817 #[default]
1918 Outlined,
2020- /// Filled rectangle
2119 Filled,
2222- /// Rounded corners (specify radius)
2320 Rounded(u32),
2421}
25222626-/// A clickable button widget
2723pub struct Button<'a> {
2824 region: Region,
2925 label: &'a str,
···6662 self.state = WidgetState::Dirty;
6763 }
68646969- /// Check if a point is within this button's bounds
7065 pub fn contains(&self, point: Point) -> bool {
7166 self.region.contains(point)
7267 }
···8883 where
8984 D: DrawTarget<Color = BinaryColor>,
9085 {
9191- // When pressed, invert colors
9286 let (bg, fg) = if self.pressed {
9387 (BinaryColor::On, BinaryColor::Off)
9488 } else {
···97919892 let rect = self.region.to_rect();
9993100100- // Draw button background/border based on style
10194 match self.style {
10295 ButtonStyle::Outlined => {
103103- // Clear background
10496 rect.into_styled(PrimitiveStyle::with_fill(bg))
10597 .draw(display)?;
106106- // Draw border
10798 rect.into_styled(PrimitiveStyle::with_stroke(fg, 2))
10899 .draw(display)?;
109100 }
···114105 ButtonStyle::Rounded(radius) => {
115106 let rounded =
116107 RoundedRectangle::new(rect, CornerRadii::new(Size::new(radius, radius)));
117117- // Clear background
118108 rounded
119109 .into_styled(PrimitiveStyle::with_fill(bg))
120110 .draw(display)?;
121121- // Draw border
122111 rounded
123112 .into_styled(PrimitiveStyle::with_stroke(fg, 2))
124113 .draw(display)?;
125114 }
126115 }
127116128128- // Calculate centered text position
129117 let text_size = self.text_size();
130118 let mut pos = Alignment::Center.position(self.region, text_size);
131119 pos.y += self.font.character_size.height as i32;
132120133133- // For filled style, text color is inverted
134121 let text_color = match self.style {
135122 ButtonStyle::Filled => bg,
136123 _ => fg,
137124 };
138125139139- // Draw label
140126 let style = MonoTextStyle::new(self.font, text_color);
141127 Text::new(self.label, pos, style).draw(display)?;
142128
+4-19
src/ui/label.rs
···11+// Static and dynamic text labels for e-paper
22+// Label borrows its text; DynamicLabel<N> owns a fixed buffer
33+// and implements core::fmt::Write for formatted output.
44+15use embedded_graphics::{
26 mono_font::{MonoFont, MonoTextStyle},
37 pixelcolor::BinaryColor,
···812913use super::widget::{Alignment, Region, Widget, WidgetState};
10141111-/// A text label widget
1212-/// Automatically handles background clearing on redraw.
1315pub struct Label<'a> {
1416 region: Region,
1517 text: &'a str,
···5557 }
5658 }
57595858- /// Calculate text size based on font metrics
5960 fn text_size(&self) -> Size {
6061 let char_width = self.font.character_size.width + self.font.character_spacing;
6162 let width = self.text.len() as u32 * char_width;
···7980 (BinaryColor::Off, BinaryColor::On)
8081 };
81828282- // Clear background
8383 self.region
8484 .to_rect()
8585 .into_styled(PrimitiveStyle::with_fill(bg))
8686 .draw(display)?;
87878888- // Calculate text position
8988 let text_size = self.text_size();
9089 let mut pos = self.alignment.position(self.region, text_size);
91909292- // Adjust for text baseline (embedded-graphics draws from baseline)
9391 pos.y += self.font.character_size.height as i32;
94929595- // Draw text
9693 let style = MonoTextStyle::new(self.font, fg);
9794 Text::new(self.text, pos, style).draw(display)?;
9895···112109 }
113110}
114111115115-/// A label that owns its text (for dynamic content)
116112pub struct DynamicLabel<const N: usize> {
117113 region: Region,
118114 buffer: [u8; N],
···154150 self.state = WidgetState::Dirty;
155151 }
156152157157- /// Clear the text buffer
158153 pub fn clear_text(&mut self) {
159154 self.len = 0;
160155 self.state = WidgetState::Dirty;
···194189 (BinaryColor::Off, BinaryColor::On)
195190 };
196191197197- // Clear background
198192 self.region
199193 .to_rect()
200194 .into_styled(PrimitiveStyle::with_fill(bg))
201195 .draw(display)?;
202196203203- // Calculate text position
204197 let text_size = self.text_size();
205198 let mut pos = self.alignment.position(self.region, text_size);
206199 pos.y += self.font.character_size.height as i32;
207200208208- // Draw text
209201 let style = MonoTextStyle::new(self.font, fg);
210202 Text::new(self.text(), pos, style).draw(display)?;
211203···225217 }
226218}
227219228228-/// Write formatted text to a DynamicLabel
229229-///
230230-/// Usage:
231231-/// ```ignore
232232-/// use core::fmt::Write;
233233-/// write!(label, "Count: {}", 42).ok();
234234-/// ```
235220impl<const N: usize> core::fmt::Write for DynamicLabel<N> {
236221 fn write_str(&mut self, s: &str) -> core::fmt::Result {
237222 let bytes = s.as_bytes();
+2-2
src/ui/mod.rs
···11-//! UI primitives for e-paper displays
11+// Widget toolkit for 1-bit e-paper displays
22+// Region based layout, dirty tracking, strip-buffered rendering.
2334mod button;
45mod label;
···12131314use embedded_graphics::{pixelcolor::BinaryColor, prelude::*};
14151515-/// Extension trait for drawing widgets
1616pub trait WidgetExt<D>
1717where
1818 D: DrawTarget<Color = BinaryColor>,
+2-31
src/ui/statusbar.rs
···11-//! Status bar — persistent system info strip.
22-//!
33-//! Drawn by main.rs on every render, outside of app control.
44-//! Shows battery, uptime, heap, stack, and SD status.
11+// Persistent status bar at top of screen
22+// Shows battery, uptime, heap, stack, and SD card state.
5364use core::fmt::Write;
75···14121513use super::widget::Region;
16141717-/// Height of the status bar in pixels.
1815pub const BAR_HEIGHT: u16 = 18;
19162020-/// Y coordinate where app content should start (below the status bar).
2117pub const CONTENT_TOP: u16 = BAR_HEIGHT;
22182323-/// Full-width status bar region (top of screen, 480px wide in landscape).
2419pub const BAR_REGION: Region = Region::new(0, 0, 480, BAR_HEIGHT);
25202626-/// System snapshot passed to the status bar each frame.
2721pub struct SystemStatus {
2828- /// Uptime in seconds since boot.
2922 pub uptime_secs: u32,
3030- /// Battery voltage in mV (0 = not available).
3123 pub battery_mv: u16,
3232- /// Battery charge percentage (0-100).
3324 pub battery_pct: u8,
3434- /// Heap bytes currently allocated.
3525 pub heap_used: usize,
3636- /// Heap total bytes.
3726 pub heap_total: usize,
3838- /// Approximate free stack in bytes.
3927 pub stack_free: usize,
4040- /// Whether SD card is present.
4128 pub sd_ok: bool,
4229}
4330···5441 }
5542 }
56435757- /// Update the status bar text from a system snapshot.
5844 pub fn update(&mut self, s: &SystemStatus) {
5945 self.len = 0;
6046···6753 pos: 0,
6854 };
69557070- // Battery
7156 if s.battery_mv > 0 {
7257 let _ = write!(
7358 w,
···8065 let _ = write!(w, "BAT --");
8166 }
82678383- // Uptime
8468 if hrs > 0 {
8569 let _ = write!(w, " {}:{:02}:{:02}", hrs, mins, secs);
8670 } else {
8771 let _ = write!(w, " {:02}:{:02}", mins, secs);
8872 }
89739090- // Heap
9174 if s.heap_total > 0 {
9275 let _ = write!(w, " H:{}/{}K", s.heap_used / 1024, s.heap_total / 1024);
9376 }
94779595- // Stack free
9678 if s.stack_free > 0 {
9779 let _ = write!(w, " S:{}K", s.stack_free / 1024);
9880 }
9981100100- // SD
10182 let _ = write!(w, " SD:{}", if s.sd_ok { "OK" } else { "--" });
1028310384 self.len = w.pos;
···10788 core::str::from_utf8(&self.buf[..self.len]).unwrap_or("")
10889 }
10990110110- /// Draw the status bar.
11191 pub fn draw<D>(&self, display: &mut D) -> Result<(), D::Error>
11292 where
11393 D: DrawTarget<Color = BinaryColor>,
11494 {
115115- // Dark background
11695 BAR_REGION
11796 .to_rect()
11897 .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
11998 .draw(display)?;
12099121121- // White text
122100 let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::Off);
123101 Text::new(self.text(), Point::new(4, 14), style).draw(display)?;
124102···130108 }
131109}
132110133133-/// Read approximate free stack space.
134134-///
135135-/// ESP32-C3 stack grows downward from top of DRAM.
136136-/// Returns distance from current SP to DRAM base — a rough
137137-/// measure of how much SRAM headroom remains below the stack.
138111pub fn free_stack_bytes() -> usize {
139112 let sp: usize;
140113 #[cfg(target_arch = "riscv32")]
···147120 }
148121149122 // ESP32-C3 DRAM: 0x3FC80000..0x3FCE0000 (400KB)
150150- // SP sits near the top; distance to base ≈ free headroom.
151123 const DRAM_BASE: usize = 0x3FC8_0000;
152124 if sp > DRAM_BASE { sp - DRAM_BASE } else { 0 }
153125}
154126155155-/// Tiny no-alloc write helper.
156127struct BufWriter<'a> {
157128 buf: &'a mut [u8],
158129 pos: usize,
+8-37
src/ui/widget.rs
···11-//! Widgets are self-contained UI elements that know their bounds and can
22-//! draw themselves. They work in logical coordinates (rotation-aware).
11+// Region geometry, alignment, and base widget trait
22+// All coordinates are logical (rotation aware). x/w should be 8 aligned
33+// for partial refresh to avoid byte boundary fixups on the controller.
44+35use embedded_graphics::{
46 pixelcolor::BinaryColor,
57 prelude::*,
68 primitives::{PrimitiveStyle, Rectangle},
79};
81099-/// A rectangular region in logical coordinates.
1011#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1112pub struct Region {
1213 pub x: u16,
···1617}
17181819impl Region {
1919- /// Create a new region. X and W should be 8-pixel aligned for best performance.
2020 pub const fn new(x: u16, y: u16, w: u16, h: u16) -> Self {
2121 Self { x, y, w, h }
2222 }
23232424- /// Create from embedded-graphics Rectangle
2524 pub fn from_rect(rect: Rectangle) -> Self {
2625 Self {
2726 x: rect.top_left.x.max(0) as u16,
···3130 }
3231 }
33323434- /// Convert to embedded-graphics Rectangle
3533 pub fn to_rect(self) -> Rectangle {
3634 Rectangle::new(
3735 Point::new(self.x as i32, self.y as i32),
···4745 Point::new((self.x + self.w / 2) as i32, (self.y + self.h / 2) as i32)
4846 }
49475050- /// Align X to 8-pixel boundary (required for partial refresh)
5148 pub fn align8(self) -> Self {
5249 let aligned_x = (self.x / 8) * 8;
5350 let extra = self.x - aligned_x;
5451 Self {
5552 x: aligned_x,
5653 y: self.y,
5757- w: ((self.w + extra + 7) / 8) * 8, // Round up width to compensate
5454+ w: ((self.w + extra + 7) / 8) * 8,
5855 h: self.h,
5956 }
6057 }
61586262- /// Bounding box union of two regions.
6363- /// The result is the smallest region that contains both inputs.
6464- /// May over-cover the gap between disjoint regions, but never
6565- /// under-covers either one.
6659 pub fn union(self, other: Region) -> Self {
6760 let x1 = self.x.min(other.x);
6861 let y1 = self.y.min(other.y);
···10295 }
10396}
10497105105-/// Text/content alignment within a widget
10698#[derive(Clone, Copy, Debug, Default, PartialEq)]
10799pub enum Alignment {
108100 #[default]
···118110}
119111120112impl Alignment {
121121- /// Calculate position for content of given size within a region
122113 pub fn position(self, region: Region, content_size: Size) -> Point {
123114 let cw = content_size.width as i32;
124115 let ch = content_size.height as i32;
···141132 }
142133}
143134144144-/// Widget state for tracking if redraw is needed
145135#[derive(Clone, Copy, Debug, Default, PartialEq)]
146136pub enum WidgetState {
147147- /// Widget needs to be redrawn
148137 #[default]
149138 Dirty,
150150- /// Widget is up to date
151139 Clean,
152140}
153141154154-/// Core widget trait for UI elements
155155-///
156156-/// Widgets are self-contained UI components that:
157157-/// - Know their bounds (region)
158158-/// - Can draw themselves to any DrawTarget
159159-/// - Track dirty state for efficient updates
160142pub trait Widget {
161161- /// Get the widget's bounding region (in logical coordinates)
162143 fn bounds(&self) -> Region;
163144164164- /// Draw the widget to a display
165145 fn draw<D>(&self, display: &mut D) -> Result<(), D::Error>
166146 where
167147 D: DrawTarget<Color = BinaryColor>;
168148169169- /// Check if widget needs redraw
170149 fn is_dirty(&self) -> bool {
171171- true // Default: always redraw
150150+ true
172151 }
173152174174- /// Mark widget as clean (called after draw)
175175- fn mark_clean(&mut self) {
176176- // Default: no-op
177177- }
153153+ fn mark_clean(&mut self) {}
178154179179- /// Mark widget as needing redraw
180180- fn mark_dirty(&mut self) {
181181- // Default: no-op
182182- }
155155+ fn mark_dirty(&mut self) {}
183156184184- /// Clear the widget's region to background color
185157 fn clear<D>(&self, display: &mut D) -> Result<(), D::Error>
186158 where
187159 D: DrawTarget<Color = BinaryColor>,
···192164 .draw(display)
193165 }
194166195195- /// Get the 8-pixel aligned bounds for partial refresh
196167 fn refresh_bounds(&self) -> Region {
197168 self.bounds().align8()
198169 }