···11+// physical button positions on the XTEink X4 bezel.
22+// used by button_feedback to render labels at the correct screen edge.
33+44+// center-x of bottom-edge buttons.
55+pub const CX_BACK: u16 = 84;
66+pub const CX_CONFIRM: u16 = 194;
77+pub const CX_LEFT: u16 = 286;
88+pub const CX_RIGHT: u16 = 396;
99+1010+// center-y of right-edge buttons.
1111+pub const CY_VOL_UP: u16 = 364;
1212+pub const CY_VOL_DOWN: u16 = 484;
+231
kernel/src/board/mod.rs
···11+// board support for the XTEink X4 (ESP32-C3, SSD1677 800x480, SD over SPI2)
22+// DMA-backed SPI (GDMA CH0); CriticalSectionDevice arbitrates bus
33+44+pub mod action;
55+pub mod battery;
66+pub mod button;
77+pub mod layout;
88+pub mod raw_gpio;
99+1010+pub use crate::drivers::sdcard::{SdStorage, SyncSdCard};
1111+pub use crate::drivers::ssd1677::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH};
1212+pub use crate::drivers::strip::StripBuffer;
1313+pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder};
1414+1515+pub const SCREEN_W: u16 = HEIGHT; // 480
1616+pub const SCREEN_H: u16 = WIDTH; // 800
1717+1818+use core::cell::RefCell;
1919+2020+use critical_section::Mutex;
2121+use embedded_hal_bus::spi::CriticalSectionDevice;
2222+use esp_hal::{
2323+ Blocking,
2424+ analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation},
2525+ delay::Delay,
2626+ dma::{DmaRxBuf, DmaTxBuf},
2727+ gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull},
2828+ peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals},
2929+ spi,
3030+ time::Rate,
3131+};
3232+use log::info;
3333+use static_cell::StaticCell;
3434+3535+pub type SpiBus = spi::master::SpiDmaBus<'static, Blocking>;
3636+pub type SharedSpiDevice = CriticalSectionDevice<'static, SpiBus, Output<'static>, Delay>;
3737+pub type SdSpiDevice = CriticalSectionDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>;
3838+pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>;
3939+4040+static SPI_BUS: StaticCell<Mutex<RefCell<SpiBus>>> = StaticCell::new();
4141+4242+// cached ref to the SPI bus mutex, set once in Board::init
4343+static SPI_BUS_REF: Mutex<core::cell::Cell<Option<&'static Mutex<RefCell<SpiBus>>>>> =
4444+ Mutex::new(core::cell::Cell::new(None));
4545+4646+static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None));
4747+4848+#[esp_hal::handler]
4949+fn gpio_handler() {
5050+ critical_section::with(|cs| {
5151+ if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut()
5252+ && btn.is_interrupt_set()
5353+ {
5454+ btn.clear_interrupt();
5555+ }
5656+ });
5757+}
5858+5959+pub fn power_button_is_low() -> bool {
6060+ critical_section::with(|cs| {
6161+ POWER_BTN
6262+ .borrow_ref_mut(cs)
6363+ .as_mut()
6464+ .map(|btn| btn.is_low())
6565+ .unwrap_or(false)
6666+ })
6767+}
6868+6969+pub struct InputHw {
7070+ pub adc: Adc<'static, ADC1<'static>, Blocking>,
7171+ pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
7272+ pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
7373+ pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
7474+}
7575+7676+pub struct DisplayHw {
7777+ pub epd: Epd,
7878+}
7979+8080+pub struct StorageHw {
8181+ // sd card, initialised at 400 kHz before EPD touches the bus
8282+ pub sd_card: Option<SyncSdCard>,
8383+}
8484+8585+pub struct Board {
8686+ pub input: InputHw,
8787+ pub display: DisplayHw,
8888+ pub storage: StorageHw,
8989+}
9090+9191+impl Board {
9292+ pub fn init(p: Peripherals) -> Self {
9393+ let input = Self::init_input(&p);
9494+ let (display, storage) = Self::init_spi_peripherals(p);
9595+ Board {
9696+ input,
9797+ display,
9898+ storage,
9999+ }
100100+ }
101101+102102+ // gpio / peripheral ownership:
103103+ //
104104+ // init_input (clone_unchecked) init_spi_peripherals (move/clone)
105105+ // --- ---
106106+ // GPIO0 battery ADC GPIO4 EPD DC
107107+ // GPIO1 button row 1 ADC GPIO5 EPD RST
108108+ // GPIO2 button row 2 ADC GPIO6 EPD BUSY
109109+ // GPIO3 power button GPIO7 SPI MISO
110110+ // ADC1 GPIO8 SPI SCK
111111+ // IO_MUX GPIO10 SPI MOSI
112112+ // GPIO12 SD CS (raw register)
113113+ // GPIO21 EPD CS
114114+ // SPI2, DMA_CH0
115115+116116+ // Safety for all clone_unchecked calls below:
117117+ //
118118+ // init_input borrows Peripherals immutably and clones the pins it
119119+ // needs. init_spi_peripherals later takes ownership of the full
120120+ // Peripherals struct but only touches a disjoint set of GPIOs
121121+ // (GPIO4-8, GPIO10, GPIO21, SPI2, DMA_CH0). See the ownership
122122+ // table above for the complete split. Each peripheral listed here
123123+ // is used exclusively by InputHw and never touched again.
124124+ fn init_input(p: &Peripherals) -> InputHw {
125125+ let mut adc_cfg = AdcConfig::new();
126126+127127+ // Safety: GPIO1 is used only here (button row 1 ADC).
128128+ let row1 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
129129+ unsafe { p.GPIO1.clone_unchecked() },
130130+ Attenuation::_11dB,
131131+ );
132132+133133+ // Safety: GPIO2 is used only here (button row 2 ADC).
134134+ let row2 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
135135+ unsafe { p.GPIO2.clone_unchecked() },
136136+ Attenuation::_11dB,
137137+ );
138138+139139+ // Safety: GPIO0 is used only here (battery voltage ADC).
140140+ let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
141141+ unsafe { p.GPIO0.clone_unchecked() },
142142+ Attenuation::_11dB,
143143+ );
144144+145145+ // Safety: ADC1 is used only here; init_spi_peripherals does not use ADC.
146146+ let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg);
147147+148148+ // Safety: IO_MUX is used only here for the GPIO interrupt handler.
149149+ let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() });
150150+ io.set_interrupt_handler(gpio_handler);
151151+152152+ // Safety: GPIO3 is used only here (power button input with IRQ).
153153+ let mut power = Input::new(
154154+ unsafe { p.GPIO3.clone_unchecked() },
155155+ InputConfig::default().with_pull(Pull::Up),
156156+ );
157157+ power.listen(Event::FallingEdge);
158158+159159+ critical_section::with(|cs| {
160160+ POWER_BTN.borrow_ref_mut(cs).replace(power);
161161+ });
162162+ info!("power button: GPIO3 interrupt armed (FallingEdge)");
163163+164164+ InputHw {
165165+ adc,
166166+ row1,
167167+ row2,
168168+ battery,
169169+ }
170170+ }
171171+172172+ // 400 kHz for SD probe, then 20 MHz; DMA-backed
173173+ fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) {
174174+ let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default());
175175+ let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default());
176176+ let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default());
177177+ let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None));
178178+179179+ // GPIO12 free in DIO mode; no esp-hal type, use raw registers
180180+ let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) };
181181+182182+ let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400));
183183+184184+ let mut spi_raw = spi::master::Spi::new(p.SPI2, slow_cfg)
185185+ .unwrap()
186186+ .with_sck(p.GPIO8)
187187+ .with_mosi(p.GPIO10)
188188+ .with_miso(p.GPIO7);
189189+190190+ // 80 clocks with CS high before DMA conversion (SD spec init)
191191+ let _ = spi_raw.write(&[0xFF; 10]);
192192+193193+ // 4096B each direction: strip max ~4000B, SD sectors 512B
194194+ let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = esp_hal::dma_buffers!(4096);
195195+ let dma_rx_buf = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap();
196196+ let dma_tx_buf = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap();
197197+198198+ let spi_dma_bus = spi_raw
199199+ .with_dma(p.DMA_CH0)
200200+ .with_buffers(dma_rx_buf, dma_tx_buf);
201201+202202+ let spi_ref: &'static Mutex<RefCell<SpiBus>> =
203203+ SPI_BUS.init(Mutex::new(RefCell::new(spi_dma_bus)));
204204+ info!("SPI bus: DMA enabled (CH0, 4096B TX+RX)");
205205+206206+ critical_section::with(|cs| SPI_BUS_REF.borrow(cs).set(Some(spi_ref)));
207207+208208+ let sd_spi = CriticalSectionDevice::new(spi_ref, sd_cs, Delay::new()).unwrap();
209209+210210+ // init SD card now, at 400 kHz on a pristine bus, before EPD
211211+ // traffic -- SD spec requires CMD0 on a clean bus
212212+ let sd_card = SdStorage::init_card(sd_spi);
213213+214214+ let epd_spi = CriticalSectionDevice::new(spi_ref, epd_cs, Delay::new()).unwrap();
215215+ let epd = DisplayDriver::new(epd_spi, dc, rst, busy);
216216+217217+ (DisplayHw { epd }, StorageHw { sd_card })
218218+ }
219219+}
220220+221221+// switch SPI bus from 400 kHz to operational frequency (20 MHz)
222222+// call after Board::init and before first EPD render
223223+pub fn speed_up_spi() {
224224+ let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ));
225225+ critical_section::with(|cs| {
226226+ if let Some(bus) = SPI_BUS_REF.borrow(cs).get() {
227227+ bus.borrow(cs).borrow_mut().apply_config(&fast_cfg).unwrap();
228228+ info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ);
229229+ }
230230+ });
231231+}
+8
kernel/src/drivers/mod.rs
···11+// hardware drivers: chip-level and protocol-level, board-independent
22+33+pub mod battery;
44+pub mod input;
55+pub mod sdcard;
66+pub mod ssd1677;
77+pub mod storage;
88+pub mod strip;
+192
kernel/src/drivers/sdcard.rs
···11+// sd card over SPI: sync SdCard + async volume manager
22+//
33+// sync SdCard handles the SD protocol (CMD0, init, sector I/O)
44+// using embedded_hal SpiDevice + DelayNs traits
55+//
66+// BlockDeviceAdapter bridges sync BlockDevice to AsyncBlockDevice
77+// so AsyncVolumeManager can consume it
88+//
99+// poll_once drives file-I/O futures to completion in a single poll
1010+// (SPI bus is blocking, so every .await resolves immediately)
1111+1212+use core::cell::RefCell;
1313+use core::future::Future;
1414+use core::pin::pin;
1515+use core::task::{Context, Poll, Waker};
1616+use embedded_hal::delay::DelayNs;
1717+1818+use embedded_sdmmc::{
1919+ AsyncBlockDevice, AsyncVolumeManager, Block, BlockCount, BlockDevice, BlockIdx, RawDirectory,
2020+ RawVolume, SdCard, TimeSource, Timestamp, VolumeIdx,
2121+};
2222+use log::info;
2323+2424+use crate::board::SdSpiDevice;
2525+2626+// sync BlockDevice -> AsyncBlockDevice adapter
2727+//
2828+// sync SdCard uses RefCell internally, takes &self for BlockDevice
2929+// methods; we delegate AsyncBlockDevice &mut self to the inner &self
3030+// methods -- all resolve immediately since SPI is DMA-blocking
3131+3232+pub(crate) struct BlockDeviceAdapter<D: BlockDevice>(D);
3333+3434+impl<D: BlockDevice> AsyncBlockDevice for BlockDeviceAdapter<D> {
3535+ type Error = D::Error;
3636+3737+ async fn read(
3838+ &mut self,
3939+ blocks: &mut [Block],
4040+ start_block_idx: BlockIdx,
4141+ ) -> Result<(), Self::Error> {
4242+ self.0.read(blocks, start_block_idx)
4343+ }
4444+4545+ async fn write(
4646+ &mut self,
4747+ blocks: &[Block],
4848+ start_block_idx: BlockIdx,
4949+ ) -> Result<(), Self::Error> {
5050+ self.0.write(blocks, start_block_idx)
5151+ }
5252+5353+ async fn num_blocks(&mut self) -> Result<BlockCount, Self::Error> {
5454+ self.0.num_blocks()
5555+ }
5656+}
5757+5858+// no RTC on this board
5959+6060+pub(crate) struct NullTimeSource;
6161+6262+impl TimeSource for NullTimeSource {
6363+ fn get_timestamp(&self) -> Timestamp {
6464+ Timestamp {
6565+ year_since_1970: 0,
6666+ zero_indexed_month: 0,
6767+ zero_indexed_day: 0,
6868+ hours: 0,
6969+ minutes: 0,
7070+ seconds: 0,
7171+ }
7272+ }
7373+}
7474+7575+// type aliases
7676+7777+pub type SyncSdCard = SdCard<SdSpiDevice, esp_hal::delay::Delay>;
7878+pub(crate) type SdBlockDev = BlockDeviceAdapter<SyncSdCard>;
7979+pub(crate) type VolMgr = AsyncVolumeManager<SdBlockDev, NullTimeSource, 4, 4, 1>;
8080+8181+// persistent volume manager state, held behind RefCell for interior
8282+// mutability (AsyncVolumeManager requires &mut self)
8383+8484+pub(crate) struct SdStorageInner {
8585+ pub(crate) mgr: VolMgr,
8686+ #[allow(dead_code)]
8787+ pub(crate) vol: RawVolume,
8888+ pub(crate) root: RawDirectory,
8989+}
9090+9191+// holds a persistently-mounted AsyncVolumeManager with volume 0 and
9292+// root directory kept open for the device lifetime; RefCell provides
9393+// interior mutability so storage functions can take &SdStorage
9494+9595+pub struct SdStorage {
9696+ inner: Option<RefCell<SdStorageInner>>,
9797+}
9898+9999+impl SdStorage {
100100+ pub fn empty() -> Self {
101101+ Self { inner: None }
102102+ }
103103+104104+ // init SD card at 400 kHz (SD spec init frequency)
105105+ //
106106+ // sync SdCard auto-initialises on first method call; we call
107107+ // num_bytes() to force init and verify the card responds
108108+ //
109109+ // pub so Board::init can run this before other SPI peripherals
110110+ // touch the bus -- SD spec requires a clean 400 kHz bus for CMD0
111111+ pub fn init_card(spi_device: SdSpiDevice) -> Option<SyncSdCard> {
112112+ let sd = SdCard::new(spi_device, esp_hal::delay::Delay::new());
113113+114114+ for attempt in 1..=5 {
115115+ match sd.num_bytes() {
116116+ Ok(size) => {
117117+ info!("SD card: initialised (attempt {})", attempt);
118118+ info!("SD card: {} bytes ({} MB)", size, size / 1024 / 1024);
119119+ return Some(sd);
120120+ }
121121+ Err(e) => {
122122+ info!("SD card: init attempt {} failed: {:?}", attempt, e);
123123+ sd.mark_card_uninit();
124124+ esp_hal::delay::Delay::new().delay_ms(50);
125125+ }
126126+ }
127127+ }
128128+129129+ info!("SD card: all init attempts failed");
130130+ None
131131+ }
132132+133133+ // mount FAT filesystem on an already-initialised SD card
134134+ //
135135+ // opens volume 0 (first MBR partition) and keeps the root
136136+ // directory open for the device lifetime
137137+ pub async fn mount(sd: SyncSdCard) -> Self {
138138+ let adapter = BlockDeviceAdapter(sd);
139139+ let mut mgr = AsyncVolumeManager::new(adapter, NullTimeSource);
140140+141141+ let vol = match mgr.open_raw_volume(VolumeIdx(0)).await {
142142+ Ok(v) => v,
143143+ Err(e) => {
144144+ info!("SD card: open volume failed: {}", e);
145145+ return Self { inner: None };
146146+ }
147147+ };
148148+149149+ let root = match mgr.open_root_dir(vol) {
150150+ Ok(d) => d,
151151+ Err(e) => {
152152+ info!("SD card: open root dir failed: {}", e);
153153+ let _ = mgr.close_volume(vol).await;
154154+ return Self { inner: None };
155155+ }
156156+ };
157157+158158+ info!("SD card: filesystem mounted");
159159+ Self {
160160+ inner: Some(RefCell::new(SdStorageInner { mgr, vol, root })),
161161+ }
162162+ }
163163+164164+ #[inline]
165165+ pub fn probe_ok(&self) -> bool {
166166+ self.inner.is_some()
167167+ }
168168+169169+ #[inline]
170170+ pub(crate) fn borrow_inner(&self) -> Option<core::cell::RefMut<'_, SdStorageInner>> {
171171+ self.inner.as_ref().map(|c| c.borrow_mut())
172172+ }
173173+}
174174+175175+// drive a future to completion in exactly one poll
176176+//
177177+// correct because the SPI bus is blocking and the sync SdCard
178178+// completes every operation before returning -- no inner .await
179179+// ever returns Pending
180180+//
181181+// only use for file-level operations (open, read, write, seek,
182182+// iterate); mount runs inside the real Embassy executor
183183+184184+pub fn poll_once<T>(fut: impl Future<Output = T>) -> T {
185185+ let waker: &Waker = Waker::noop();
186186+ let mut cx = Context::from_waker(waker);
187187+ let mut fut = pin!(fut);
188188+ match fut.as_mut().poll(&mut cx) {
189189+ Poll::Ready(v) => v,
190190+ Poll::Pending => panic!("poll_once: future pended -- SPI must be in Blocking mode"),
191191+ }
192192+}
···11+// pulp-kernel -- hardware drivers, scheduling, and system core
22+//
33+// generic over AppLayer; never imports concrete apps or fonts.
44+// ships a built-in mono font (FONT_6X13) for boot console and
55+// sleep screen. distros bring their own proportional fonts.
66+77+#![no_std]
88+99+extern crate alloc;
1010+1111+pub mod board;
1212+pub mod drivers;
1313+pub mod kernel;
1414+pub mod ui;
+17
kernel/src/ui/mod.rs
···11+// widget primitives for 1-bit e-paper displays
22+//
33+// font-independent: Region, Alignment, stack measurement, StackFmt.
44+// font-dependent widgets (BitmapLabel, QuickMenu, ButtonFeedback)
55+// live in the distro's apps::widgets module.
66+77+pub mod stack_fmt;
88+pub mod statusbar;
99+mod widget;
1010+1111+pub use stack_fmt::{StackFmt, stack_fmt};
1212+pub use statusbar::{
1313+ BAR_HEIGHT, CONTENT_TOP, free_stack_bytes, paint_stack, stack_high_water_mark,
1414+};
1515+pub use widget::{Alignment, Region, wrap_next, wrap_prev};
1616+1717+pub use crate::board::{SCREEN_H, SCREEN_W};
+97
kernel/src/ui/statusbar.rs
···11+// status bar constants and stack-measurement utilities
22+// system stats are emitted via log::info! in the scheduler
33+44+pub const BAR_HEIGHT: u16 = 4;
55+pub const CONTENT_TOP: u16 = BAR_HEIGHT;
66+77+const STACK_PAINT_WORD: u32 = 0xDEAD_BEEF;
88+99+// paint the unused stack with a sentinel word so stack_high_water_mark
1010+// can later measure peak usage; call very early in boot
1111+pub fn paint_stack() {
1212+ #[cfg(target_arch = "riscv32")]
1313+ {
1414+ let sp: usize;
1515+ unsafe {
1616+ core::arch::asm!("mv {}, sp", out(reg) sp);
1717+ }
1818+1919+ unsafe extern "C" {
2020+ static _stack_end_cpu0: u8;
2121+ }
2222+ let bottom = (&raw const _stack_end_cpu0) as usize;
2323+2424+ let guard_skip = 256;
2525+ let paint_bottom = bottom + guard_skip;
2626+2727+ let paint_top = sp.saturating_sub(256);
2828+2929+ if paint_top <= paint_bottom {
3030+ return;
3131+ }
3232+3333+ let start = (paint_bottom + 3) & !3;
3434+3535+ let mut addr = start;
3636+ while addr + 4 <= paint_top {
3737+ unsafe {
3838+ core::ptr::write_volatile(addr as *mut u32, STACK_PAINT_WORD);
3939+ }
4040+ addr += 4;
4141+ }
4242+ }
4343+}
4444+4545+pub fn free_stack_bytes() -> usize {
4646+ #[cfg(target_arch = "riscv32")]
4747+ {
4848+ let sp: usize;
4949+ unsafe {
5050+ core::arch::asm!("mv {}, sp", out(reg) sp);
5151+ }
5252+5353+ unsafe extern "C" {
5454+ static _stack_end_cpu0: u8;
5555+ }
5656+ let stack_bottom = (&raw const _stack_end_cpu0) as usize;
5757+ sp.saturating_sub(stack_bottom)
5858+ }
5959+6060+ #[cfg(not(target_arch = "riscv32"))]
6161+ {
6262+ 0
6363+ }
6464+}
6565+6666+pub fn stack_high_water_mark() -> usize {
6767+ #[cfg(target_arch = "riscv32")]
6868+ {
6969+ unsafe extern "C" {
7070+ static _stack_end_cpu0: u8;
7171+ static _stack_start_cpu0: u8;
7272+ }
7373+ let bottom = (&raw const _stack_end_cpu0) as usize;
7474+ let top = (&raw const _stack_start_cpu0) as usize;
7575+7676+ let guard_skip = 256;
7777+ let scan_bottom = bottom + guard_skip;
7878+7979+ let start = (scan_bottom + 3) & !3;
8080+8181+ let mut addr = start;
8282+ while addr + 4 <= top {
8383+ let val = unsafe { core::ptr::read_volatile(addr as *const u32) };
8484+ if val != STACK_PAINT_WORD {
8585+ break;
8686+ }
8787+ addr += 4;
8888+ }
8989+9090+ top.saturating_sub(addr)
9191+ }
9292+9393+ #[cfg(not(target_arch = "riscv32"))]
9494+ {
9595+ 0
9696+ }
9797+}
···11-// WiFi upload server: GET / serves HTML, POST /upload streams to SD, mDNS as pulp.local.
11+// wifi upload server: HTTP file upload + mDNS (pulp.local)
2233use alloc::string::String;
44use core::fmt::Write as FmtWrite;
···1313use esp_radio::wifi::{ClientConfig, Config, ModeConfig};
1414use log::info;
15151616-use crate::apps::settings::WifiConfig;
1716use crate::board::action::{Action, ActionEvent, ButtonMapper};
1817use crate::board::{Epd, SCREEN_H, SCREEN_W};
1918use crate::drivers::sdcard::SdStorage;
···2120use crate::drivers::strip::StripBuffer;
2221use crate::fonts;
2322use crate::fonts::bitmap::BitmapFont;
2323+use crate::kernel::config::WifiConfig;
2424use crate::kernel::tasks;
2525use crate::ui::{Alignment, BitmapLabel, ButtonFeedback, CONTENT_TOP, Region, stack_fmt};
2626···6767 DeleteFailed,
6868}
69697070-pub async fn run_upload_mode<SPI>(
7070+pub async fn run_upload_mode(
7171 wifi: esp_hal::peripherals::WIFI<'static>,
7272 epd: &mut Epd,
7373 strip: &mut StripBuffer,
7474 delay: &mut Delay,
7575- sd: &SdStorage<SPI>,
7575+ sd: &SdStorage,
7676 ui_font_size_idx: u8,
7777 bumps: &ButtonFeedback,
7878 wifi_cfg: &WifiConfig,
7979-) where
8080- SPI: embedded_hal::spi::SpiDevice,
8181-{
7979+) {
8280 let heading = fonts::heading_font(ui_font_size_idx);
8381 let body = fonts::chrome_font();
8482···326324 info!("upload: exiting, tearing down WiFi");
327325}
328326329329-async fn serve_one_request<SPI>(
327327+async fn serve_one_request(
330328 stack: embassy_net::Stack<'_>,
331329 rx_buf: &mut [u8],
332330 tx_buf: &mut [u8],
333333- sd: &SdStorage<SPI>,
331331+ sd: &SdStorage,
334332) -> ServerEvent
335333where
336336- SPI: embedded_hal::spi::SpiDevice,
337334{
338335 let mut socket = TcpSocket::new(stack, rx_buf, tx_buf);
339336 socket.set_timeout(Some(Duration::from_secs(30)));
···546543 ServerEvent::Nothing
547544}
548545549549-async fn handle_upload<SPI>(
546546+async fn handle_upload(
550547 socket: &mut TcpSocket<'_>,
551551- sd: &SdStorage<SPI>,
548548+ sd: &SdStorage,
552549 boundary: &[u8],
553550 initial_body: &[u8],
554551) -> Result<([u8; 13], u8), &'static str>
555552where
556556- SPI: embedded_hal::spi::SpiDevice,
557553{
558554 if boundary.len() > MAX_BOUNDARY_LEN {
559555 return Err("boundary too long");
···581577 let (name_buf, name_len) = sanitize_83(raw_name);
582578 if name_len == 0 {
583579 return Err("invalid filename");
580580+ }
581581+582582+ // warn if sanitisation changed the name, two different
583583+ // original names can map to the same 8.3 name, causing
584584+ // the second upload to silently overwrite the first.
585585+ if raw_name != &name_buf[..name_len as usize] {
586586+ log::warn!(
587587+ "upload: sanitised '{}' -> '{}' (may overwrite existing file)",
588588+ core::str::from_utf8(raw_name).unwrap_or("?"),
589589+ core::str::from_utf8(&name_buf[..name_len as usize]).unwrap_or("?"),
590590+ );
584591 }
585592586593 let file_start = pos + 4;
···851858 return;
852859 }
853860854854- info!("upload: mDNS query for pulp.local — responding");
861861+ info!("upload: mDNS query for pulp.local -- responding");
855862856863 let mut resp = [0u8; MDNS_RESPONSE_LEN];
857864 let len = build_mdns_response(&mut resp, ip_octets);
+13
src/apps/widgets/mod.rs
···11+// font-dependent UI widgets (app-side)
22+//
33+// these widgets depend on BitmapFont from the fontdue pipeline and
44+// live in the apps layer, not the kernel. the kernel's ui/ module
55+// holds only font-independent primitives (Region, Alignment, etc.).
66+77+pub mod bitmap_label;
88+pub mod button_feedback;
99+pub mod quick_menu;
1010+1111+pub use bitmap_label::{BitmapDynLabel, BitmapLabel};
1212+pub use button_feedback::{BUTTON_BAR_H, ButtonFeedback};
1313+pub use quick_menu::QuickMenu;
+83-641
src/bin/main.rs
···11-// pulp-os: Embassy event loop, app dispatch, rendering.
11+// hardware init, construct Kernel + AppManager, boot, run
2233#![no_std]
44#![no_main]
···66use esp_backtrace as _;
77use esp_hal::clock::CpuClock;
88use esp_hal::delay::Delay;
99-use esp_hal::gpio::RtcPinWithResistors;
109use esp_hal::interrupt::software::SoftwareInterruptControl;
1111-use esp_hal::rtc_cntl::Rtc;
1212-use esp_hal::rtc_cntl::sleep::{RtcioWakeupSource, WakeupLevel};
1010+use esp_hal::ram;
1311use esp_hal::timer::timg::TimerGroup;
1412use log::info;
15131616-use embassy_futures::select::{Either, select};
1717-use embassy_time::{Duration, Ticker};
1818-1414+use pulp_os::apps::Launcher;
1915use pulp_os::apps::files::FilesApp;
2016use pulp_os::apps::home::HomeApp;
2121-use pulp_os::apps::reader::{self, ReaderApp};
1717+use pulp_os::apps::manager::AppManager;
1818+use pulp_os::apps::reader::ReaderApp;
2219use pulp_os::apps::settings::SettingsApp;
2323-use pulp_os::apps::{
2424- App, AppContext, AppId, BookmarkCache, Launcher, Redraw, Services, Transition,
2525-};
2626-use pulp_os::board::Board;
2727-use pulp_os::board::action::{Action, ActionEvent, ButtonMapper};
2020+use pulp_os::apps::widgets::{ButtonFeedback, QuickMenu};
2121+use pulp_os::board::action::ButtonMapper;
2222+use pulp_os::board::{Board, speed_up_spi};
2823use pulp_os::drivers::battery;
2924use pulp_os::drivers::input::InputDriver;
3030-use pulp_os::drivers::storage::{self, DirCache};
2525+use pulp_os::drivers::sdcard::SdStorage;
2626+use pulp_os::drivers::storage;
3127use pulp_os::drivers::strip::StripBuffer;
3232-use pulp_os::fonts;
2828+use pulp_os::kernel::BookmarkCache;
2929+use pulp_os::kernel::BootConsole;
3030+use pulp_os::kernel::Kernel;
3131+use pulp_os::kernel::dir_cache::DirCache;
3332use pulp_os::kernel::tasks;
3434-use pulp_os::kernel::uptime_secs;
3535-use pulp_os::ui::quick_menu::{MAX_APP_ACTIONS, QuickMenuResult};
3636-use pulp_os::ui::{
3737- BAR_HEIGHT, ButtonFeedback, QuickMenu, StatusBar, SystemStatus, free_stack_bytes, paint_stack,
3838- stack_high_water_mark,
3939-};
3333+use pulp_os::kernel::work_queue;
3434+use pulp_os::ui::paint_stack;
4035use static_cell::{ConstStaticCell, StaticCell};
41364237esp_bootloader_esp_idf::esp_app_desc!();
43384444-const TICK_MS: u64 = 10;
4545-4646-const DEFAULT_GHOST_CLEAR_EVERY: u32 = 10;
4747-4848-struct Apps {
4949- home: &'static mut HomeApp,
5050- files: &'static mut FilesApp,
5151- reader: &'static mut ReaderApp,
5252- settings: &'static mut SettingsApp,
5353-}
5454-5555-impl Apps {
5656- fn propagate_fonts(&mut self, quick_menu: &mut QuickMenu, bumps: &mut ButtonFeedback) {
5757- let ui_idx = self.settings.system_settings().ui_font_size_idx;
5858- let book_idx = self.settings.system_settings().book_font_size_idx;
5959- self.home.set_ui_font_size(ui_idx);
6060- self.files.set_ui_font_size(ui_idx);
6161- self.settings.set_ui_font_size(ui_idx);
6262- self.reader.set_book_font_size(book_idx);
6363- let chrome = fonts::chrome_font();
6464- self.reader.set_chrome_font(chrome);
6565- quick_menu.set_chrome_font(chrome);
6666- bumps.set_chrome_font(chrome);
6767- }
6868-}
6969-7070-macro_rules! with_app {
7171- ($id:expr, $apps:expr, |$app:ident| $body:expr) => {
7272- match $id {
7373- AppId::Home => {
7474- let $app = &mut *$apps.home;
7575- $body
7676- }
7777- AppId::Files => {
7878- let $app = &mut *$apps.files;
7979- $body
8080- }
8181- AppId::Reader => {
8282- let $app = &mut *$apps.reader;
8383- $body
8484- }
8585- AppId::Settings => {
8686- let $app = &mut *$apps.settings;
8787- $body
8888- }
8989- AppId::Upload => {
9090- unreachable!("Upload mode is handled outside the app dispatch loop");
9191- }
9292- }
9393- };
9494-}
9595-9696-macro_rules! apply_transition {
9797- ($nav:expr, $launcher:expr, $apps:expr, $bm_cache:expr,
9898- $quick_menu:expr, $bumps:expr) => {{
9999- let nav = $nav;
100100- info!("app: {:?} -> {:?}", nav.from, nav.to);
101101-102102- if nav.from == AppId::Reader {
103103- $apps.reader.save_position($bm_cache);
104104- }
105105-106106- if nav.from != AppId::Upload {
107107- if nav.suspend {
108108- with_app!(nav.from, $apps, |app| {
109109- app.on_suspend();
110110- });
111111- } else {
112112- with_app!(nav.from, $apps, |app| {
113113- app.on_exit();
114114- });
115115- }
116116- }
117117-118118- $apps.propagate_fonts($quick_menu, $bumps);
119119-120120- if nav.to != AppId::Upload {
121121- if nav.resume {
122122- with_app!(nav.to, $apps, |app| {
123123- app.on_resume(&mut $launcher.ctx);
124124- });
125125- } else {
126126- with_app!(nav.to, $apps, |app| {
127127- app.on_enter(&mut $launcher.ctx);
128128- });
129129- }
130130- }
131131- }};
132132-}
133133-134134-// busy-wait with input: selects on BUSY pin, input channel, work ticker.
135135-// macro because it .awaits inside the main async fn.
136136-macro_rules! busy_wait_with_input {
137137- ($epd:expr, $mapper:expr,
138138- $quick_menu:expr, $launcher:expr, $apps:expr,
139139- $dir_cache:expr, $bm_cache:expr, $sd:expr) => {{
140140- let mut _deferred: Option<Transition> = None;
141141- let mut _work_ticker = Ticker::every(Duration::from_millis(TICK_MS));
142142- loop {
143143- if !$epd.is_busy() {
144144- break;
145145- }
146146-147147- match select(
148148- $epd.busy_pin().wait_for_low(),
149149- select(tasks::INPUT_EVENTS.receive(), _work_ticker.next()),
150150- )
151151- .await
152152- {
153153- Either::First(_) => break,
154154-155155- Either::Second(Either::First(hw_event)) => {
156156- let event = $mapper.map_event(hw_event);
157157-158158- if $quick_menu.open {
159159- continue;
160160- }
161161-162162- let active = $launcher.active();
163163- let t = with_app!(active, $apps, |app| app.on_event(event, &mut $launcher.ctx));
164164- if !matches!(t, Transition::None) && _deferred.is_none() {
165165- _deferred = Some(t);
166166- }
167167- }
168168-169169- Either::Second(Either::Second(_)) => {}
170170- }
171171-172172- let active = $launcher.active();
173173- let needs = with_app!(active, $apps, |app| app.needs_work());
174174- if needs {
175175- let mut svc = Services::new($dir_cache, $bm_cache, &$sd);
176176- with_app!(active, $apps, |app| app
177177- .on_work(&mut svc, &mut $launcher.ctx));
178178- }
179179- }
180180- _deferred
181181- }};
182182-}
183183-184184-macro_rules! enter_sleep {
185185- ($reason:expr, $bm_cache:expr, $board:expr, $strip:expr, $delay:expr) => {{
186186- info!("{}: entering sleep...", $reason);
187187-188188- if $bm_cache.is_dirty() {
189189- $bm_cache.flush(&$board.storage.sd);
190190- }
191191-192192- $board
193193- .display
194194- .epd
195195- .full_refresh_async($strip, &mut $delay, &|s: &mut StripBuffer| {
196196- use embedded_graphics::mono_font::MonoTextStyle;
197197- use embedded_graphics::mono_font::ascii::FONT_6X13;
198198- use embedded_graphics::pixelcolor::BinaryColor;
199199- use embedded_graphics::prelude::*;
200200- use embedded_graphics::text::Text;
201201-202202- let style = MonoTextStyle::new(&FONT_6X13, BinaryColor::On);
203203- let _ = Text::new("(sleep)", Point::new(210, 400), style).draw(s);
204204- })
205205- .await;
206206- info!("display: sleep screen rendered");
207207-208208- $board.display.epd.enter_deep_sleep();
209209- info!("display: deep sleep mode 1");
210210-211211- let mut rtc = Rtc::new(unsafe { esp_hal::peripherals::LPWR::steal() });
212212- let mut gpio3 = unsafe { esp_hal::peripherals::GPIO3::steal() };
213213- let wakeup_pins: &mut [(&mut dyn RtcPinWithResistors, WakeupLevel)] =
214214- &mut [(&mut gpio3, WakeupLevel::Low)];
215215- let rtcio = RtcioWakeupSource::new(wakeup_pins);
216216-217217- info!("mcu: entering deep sleep (power button to wake)");
218218- rtc.sleep_deep(&[&rtcio]);
219219- }};
220220-}
221221-222222-// Build the per-strip draw closure: statusbar, active app, quick-menu overlay, button labels.
223223-// Macro because it captures borrows of different concrete app types (via with_app!).
224224-macro_rules! draw_scene {
225225- ($app:expr, $statusbar:expr, $quick_menu:expr, $bumps:expr, $draw_bar:expr) => {
226226- |s: &mut StripBuffer| {
227227- if $draw_bar {
228228- $statusbar.draw(s).unwrap();
229229- }
230230- $app.draw(s);
231231- if $quick_menu.open {
232232- $quick_menu.draw(s);
233233- }
234234- $bumps.draw(s);
235235- }
236236- };
237237-}
238238-239239-// heavy statics out of the async future; ConstStaticCell for const-fn types, StaticCell otherwise
3939+// heavy statics: kept out of the async future to keep it ~200 B
2404024141static STRIP: ConstStaticCell<StripBuffer> = ConstStaticCell::new(StripBuffer::new());
242242-static STATUSBAR: ConstStaticCell<StatusBar> = ConstStaticCell::new(StatusBar::new());
24342static READER: ConstStaticCell<ReaderApp> = ConstStaticCell::new(ReaderApp::new());
24443static LAUNCHER: ConstStaticCell<Launcher> = ConstStaticCell::new(Launcher::new());
24544static QUICK_MENU: ConstStaticCell<QuickMenu> = ConstStaticCell::new(QuickMenu::new());
24645static BUMPS: ConstStaticCell<ButtonFeedback> = ConstStaticCell::new(ButtonFeedback::new());
24746static DIR_CACHE: ConstStaticCell<DirCache> = ConstStaticCell::new(DirCache::new());
24847static BM_CACHE: ConstStaticCell<BookmarkCache> = ConstStaticCell::new(BookmarkCache::new());
4848+static CONSOLE: ConstStaticCell<BootConsole> = ConstStaticCell::new(BootConsole::new());
2494925050static HOME: StaticCell<HomeApp> = StaticCell::new();
25151static FILES: StaticCell<FilesApp> = StaticCell::new();
···25656 esp_println::logger::init_logger_from_env();
25757 let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
25858 let peripherals = esp_hal::init(config);
259259-26059 paint_stack();
6060+ // 108 KB main DRAM heap; leaves ~56 KB for stack
6161+ esp_alloc::heap_allocator!(size: 110_592);
6262+ // reclaim ~64 KB from 2nd-stage bootloader; net heap ~172 KB
6363+ esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 64_000);
26164262262- // 140KB: WiFi radio ~65KB static + stack + esp-radio leaves no room for more
263263- esp_alloc::heap_allocator!(size: 143360);
6565+ let console = CONSOLE.take();
6666+ console.push("pulp-os 0.1.0");
6767+ console.push("esp32c3 rv32imc 160mhz");
6868+ console.push("heap: 172K (108K + 64K reclaimed)");
2646926570 info!("booting...");
266717272+ // Safety: TIMG0 and SW_INTERRUPT are cloned here and consumed by
7373+ // esp_rtos::start. They are never used again after this point.
7474+ // Board::init (which takes ownership of `peripherals`) does not
7575+ // touch TIMG0 or SW_INTERRUPT, see the pin ownership table in
7676+ // board/mod.rs for the full split.
26777 let timg0 = TimerGroup::new(unsafe { peripherals.TIMG0.clone_unchecked() });
26878 let sw_ints =
26979 SoftwareInterruptControl::new(unsafe { peripherals.SW_INTERRUPT.clone_unchecked() });
27080 esp_rtos::start(timg0.timer0, sw_ints.software_interrupt0);
271271- info!("esp-rtos scheduler started (TIMG0 + SW_INT0).");
8181+8282+ // Peripherals move into Board::init, which splits them across
8383+ // init_input (ADC pins, GPIO3, IO_MUX) and init_spi_peripherals
8484+ // (SPI2, DMA, display + SD GPIOs). each peripheral is used in
8585+ // exactly one place, see the ownership table in board/mod.rs.
8686+ let board = Board::init(peripherals);
8787+ console.push("spi: dma ch0, 4096B tx+rx");
27288273273- let mut board = Board::init(peripherals);
8989+ let mut epd = board.display.epd;
27490 let mut delay = Delay::new();
275275- board.display.epd.init(&mut delay);
276276- info!("hardware initialized.");
9191+ epd.init(&mut delay);
9292+ console.push("epd: ssd1677 800x480 init");
27793278278- let strip = STRIP.take();
9494+ speed_up_spi();
9595+ console.push("spi: 400kHz -> 20MHz");
27996280280- let statusbar = STATUSBAR.take();
281281- let mut sd_ok = board
282282- .storage
283283- .sd
284284- .volume_mgr
285285- .open_volume(embedded_sdmmc::VolumeIdx(0))
286286- .is_ok();
287287-288288- if sd_ok && let Err(e) = storage::ensure_pulp_dir(&board.storage.sd) {
289289- info!("warning: failed to create _PULP dir: {}", e);
290290- }
291291-292292- let mut input = InputDriver::new(board.input);
293293- let mapper = ButtonMapper::new();
294294-295295- let mut apps = Apps {
296296- home: HOME.init(HomeApp::new()),
297297- files: FILES.init(FilesApp::new()),
298298- reader: READER.take(),
299299- settings: SETTINGS.init(SettingsApp::new()),
9797+ let sd = match board.storage.sd_card {
9898+ Some(card) => {
9999+ console.push("sd: card detected");
100100+ SdStorage::mount(card).await
101101+ }
102102+ None => {
103103+ console.push("sd: not found");
104104+ SdStorage::empty()
105105+ }
300106 };
301107302302- let launcher = LAUNCHER.take();
303303- let quick_menu = QUICK_MENU.take();
304304- let bumps = BUMPS.take();
305305-306306- let dir_cache = DIR_CACHE.take();
307307- let bm_cache = BM_CACHE.take();
308308-309309- bm_cache.ensure_loaded(&board.storage.sd);
310310-311311- {
312312- let mut svc = Services::new(dir_cache, bm_cache, &board.storage.sd);
313313- apps.settings.load_eager(&mut svc);
314314- apps.propagate_fonts(quick_menu, bumps);
315315- apps.home.load_recent(&mut svc);
108108+ let sd_ok = sd.probe_ok();
109109+ if sd_ok {
110110+ console.push("sd: fat32 mounted");
111111+ let _ = storage::ensure_pulp_dir_async(&sd).await;
316112 }
317113318318- tasks::set_idle_timeout(apps.settings.system_settings().sleep_timeout);
114114+ let mut input = InputDriver::new(board.input);
115115+ let battery_mv = battery::adc_to_battery_mv(input.read_battery_mv());
319116320320- let cached_battery_mv_init = battery::adc_to_battery_mv(input.read_battery_mv());
321321- update_statusbar(statusbar, cached_battery_mv_init, sd_ok);
117117+ let mut kernel = Kernel::new(
118118+ sd,
119119+ epd,
120120+ STRIP.take(),
121121+ DIR_CACHE.take(),
122122+ BM_CACHE.take(),
123123+ delay,
124124+ sd_ok,
125125+ battery_mv,
126126+ );
322127323323- apps.home.on_enter(&mut launcher.ctx);
128128+ let mut app_mgr = AppManager::new(
129129+ LAUNCHER.take(),
130130+ HOME.init(HomeApp::new()),
131131+ FILES.init(FilesApp::new()),
132132+ READER.take(),
133133+ SETTINGS.init(SettingsApp::new()),
134134+ QUICK_MENU.take(),
135135+ BUMPS.take(),
136136+ ButtonMapper::new(),
137137+ );
324138325325- board
326326- .display
327327- .epd
328328- .full_refresh_async(strip, &mut delay, &|s: &mut StripBuffer| {
329329- statusbar.draw(s).unwrap();
330330- apps.home.draw(s);
331331- })
332332- .await;
139139+ console.push("kernel: constructed");
140140+ kernel.show_boot_console(console).await;
333141334334- let _ = launcher.ctx.take_redraw();
335335- info!("ui ready.");
142142+ kernel.boot(&mut app_mgr).await;
336143337144 spawner.spawn(tasks::input_task(input)).unwrap();
338145 spawner.spawn(tasks::housekeeping_task()).unwrap();
339146 spawner.spawn(tasks::idle_timeout_task()).unwrap();
340340- info!("tasks spawned (input_task, housekeeping_task, idle_timeout_task).");
147147+ spawner.spawn(work_queue::worker_task()).unwrap();
341148 info!("kernel ready.");
342149343343- let mut work_ticker = Ticker::every(Duration::from_millis(TICK_MS));
344344-345345- let mut partial_refreshes: u32 = 0;
346346- let mut cached_battery_mv: u16 = cached_battery_mv_init;
347347- let mut red_stale: bool = false;
348348-349349- loop {
350350- // 0. upload mode intercept: bypasses App trait, runs own async loop
351351- if launcher.active() == AppId::Upload {
352352- let wifi = unsafe { esp_hal::peripherals::WIFI::steal() };
353353- pulp_os::apps::upload::run_upload_mode(
354354- wifi,
355355- &mut board.display.epd,
356356- strip,
357357- &mut delay,
358358- &board.storage.sd,
359359- apps.settings.system_settings().ui_font_size_idx,
360360- bumps,
361361- apps.settings.wifi_config(),
362362- )
363363- .await;
364364-365365- // pop back and re-render
366366- if let Some(nav) = launcher.apply(Transition::Pop) {
367367- apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps);
368368- }
369369- launcher.ctx.request_full_redraw();
370370- continue;
371371- }
372372-373373- // 1. wait for input or work tick
374374- let hw_event = match select(tasks::INPUT_EVENTS.receive(), work_ticker.next()).await {
375375- Either::First(ev) => Some(ev),
376376- Either::Second(_) => None,
377377- };
378378-379379- // 2. input event
380380- if let Some(hw_event) = hw_event {
381381- // power long-press: intercept before mapping so no app sees it
382382- if hw_event
383383- == pulp_os::drivers::input::Event::LongPress(pulp_os::board::button::Button::Power)
384384- {
385385- enter_sleep!("power held", bm_cache, board, strip, delay);
386386- }
387387-388388- let event = mapper.map_event(hw_event);
389389-390390- // quick-menu
391391- if quick_menu.open {
392392- if let ActionEvent::Press(action) | ActionEvent::Repeat(action) = event {
393393- let result = quick_menu.on_action(action);
394394- match result {
395395- QuickMenuResult::Consumed => {
396396- if quick_menu.dirty {
397397- launcher.ctx.mark_dirty(quick_menu.region());
398398- quick_menu.dirty = false;
399399- }
400400- }
401401- QuickMenuResult::Close => {
402402- let region = quick_menu.region();
403403- sync_quick_menu(
404404- quick_menu,
405405- launcher.active(),
406406- &mut apps,
407407- &mut launcher.ctx,
408408- );
409409- launcher.ctx.mark_dirty(region);
410410- }
411411- QuickMenuResult::RefreshScreen => {
412412- sync_quick_menu(
413413- quick_menu,
414414- launcher.active(),
415415- &mut apps,
416416- &mut launcher.ctx,
417417- );
418418- launcher.ctx.request_full_redraw();
419419- }
420420- QuickMenuResult::GoHome => {
421421- sync_quick_menu(
422422- quick_menu,
423423- launcher.active(),
424424- &mut apps,
425425- &mut launcher.ctx,
426426- );
427427- let transition = Transition::Home;
428428- if let Some(nav) = launcher.apply(transition) {
429429- apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps);
430430- }
431431- }
432432- QuickMenuResult::AppTrigger(id) => {
433433- let active = launcher.active();
434434- let region = quick_menu.region();
435435- sync_quick_menu(quick_menu, active, &mut apps, &mut launcher.ctx);
436436- with_app!(active, apps, |app| {
437437- app.on_quick_trigger(id, &mut launcher.ctx);
438438- });
439439- if active == AppId::Reader {
440440- apps.reader.save_position(bm_cache);
441441- }
442442- launcher.ctx.mark_dirty(region);
443443- }
444444- }
445445- }
446446- }
447447- // menu toggle
448448- else if matches!(event, ActionEvent::Press(Action::Menu)) {
449449- let active = launcher.active();
450450- let actions: &[_] = with_app!(active, apps, |app| app.quick_actions());
451451- quick_menu.show(actions);
452452- launcher.ctx.mark_dirty(quick_menu.region());
453453- }
454454- // app dispatch
455455- else {
456456- let active = launcher.active();
457457- let transition = with_app!(active, apps, |app| {
458458- app.on_event(event, &mut launcher.ctx)
459459- });
460460-461461- if let Some(nav) = launcher.apply(transition) {
462462- apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps);
463463- }
464464- }
465465- }
466466-467467- // if we just landed on Upload, skip to top where intercept lives
468468- if launcher.active() == AppId::Upload {
469469- continue;
470470- }
471471-472472- // 3. app work: one step per iteration; multi-step ops yield between SD reads
473473- {
474474- let active = launcher.active();
475475- let needs = with_app!(active, apps, |app| app.needs_work());
476476- if needs {
477477- let mut svc = Services::new(dir_cache, bm_cache, &board.storage.sd);
478478- with_app!(active, apps, |app| {
479479- app.on_work(&mut svc, &mut launcher.ctx);
480480- });
481481- }
482482- }
483483-484484- // 4. housekeeping
485485-486486- // battery mv (~30s, from input_task)
487487- if let Some(mv) = tasks::BATTERY_MV.try_take() {
488488- cached_battery_mv = mv;
489489- }
490490-491491- // SD presence check (~30s)
492492- if tasks::SD_CHECK_DUE.try_take().is_some() {
493493- sd_ok = board
494494- .storage
495495- .sd
496496- .volume_mgr
497497- .open_volume(embedded_sdmmc::VolumeIdx(0))
498498- .is_ok();
499499- }
500500-501501- // bookmark flush (~30s)
502502- if tasks::BOOKMARK_FLUSH_DUE.try_take().is_some() && bm_cache.is_dirty() {
503503- bm_cache.flush(&board.storage.sd);
504504- }
505505-506506- // status bar refresh (~5s)
507507- if tasks::STATUS_DUE.try_take().is_some() {
508508- update_statusbar(statusbar, cached_battery_mv, sd_ok);
509509-510510- // re-sync idle timeout in case settings changed
511511- if apps.settings.is_loaded() {
512512- tasks::set_idle_timeout(apps.settings.system_settings().sleep_timeout);
513513- }
514514- }
515515-516516- // idle sleep: flush, sleep screen, deep sleep; wake = full reset
517517- if tasks::IDLE_SLEEP_DUE.try_take().is_some() {
518518- enter_sleep!("idle timeout", bm_cache, board, strip, delay);
519519- }
520520-521521- // 5. render
522522- if !launcher.ctx.has_redraw() {
523523- continue;
524524- }
525525-526526- let redraw = launcher.ctx.take_redraw();
527527-528528- // try partial; fall through to full on ghost-clear, initial refresh, or explicit Full
529529- 'render: {
530530- if let Redraw::Partial(r) = redraw {
531531- let ghost_clear_every = if apps.settings.is_loaded() {
532532- apps.settings.system_settings().ghost_clear_every as u32
533533- } else {
534534- DEFAULT_GHOST_CLEAR_EVERY
535535- };
536536-537537- if partial_refreshes < ghost_clear_every {
538538- let r = r.align8();
539539- let render_bar_overlaps = r.y < BAR_HEIGHT;
540540-541541- // phase 1: write BW; if red_stale also write RED=!BW so DU drives all pixels
542542- let active = launcher.active();
543543- let rs = with_app!(active, apps, |app| {
544544- let draw =
545545- draw_scene!(app, statusbar, quick_menu, bumps, render_bar_overlaps);
546546- if red_stale {
547547- board.display.epd.partial_phase1_bw_inv_red(
548548- strip, r.x, r.y, r.w, r.h, &mut delay, &draw,
549549- )
550550- } else {
551551- board
552552- .display
553553- .epd
554554- .partial_phase1_bw(strip, r.x, r.y, r.w, r.h, &mut delay, &draw)
555555- }
556556- });
557557-558558- if let Some(rs) = rs {
559559- // phase 2: kick DU waveform (~400-600ms)
560560- board.display.epd.partial_start_du(&rs);
561561-562562- // process input + work while DU runs
563563- let deferred = busy_wait_with_input!(
564564- board.display.epd,
565565- mapper,
566566- quick_menu,
567567- launcher,
568568- apps,
569569- dir_cache,
570570- bm_cache,
571571- board.storage.sd
572572- );
573573-574574- // phase 3: sync RED+BW; skip if content changed during DU (rapid nav).
575575- // draw() now produces the next page; writing it to both planes ghosts.
576576- // leave RED stale; next render uses inv-red to correct it.
577577- // merge region so the inv-red pass covers pixels this DU changed.
578578- if launcher.ctx.has_redraw() {
579579- // content changed; skip sync, mark region for inv-red pass
580580- launcher.ctx.mark_dirty(r);
581581- red_stale = true;
582582- partial_refreshes += 1;
583583- } else {
584584- // stable; sync planes, power off
585585- red_stale = false;
586586- let active = launcher.active();
587587- with_app!(active, apps, |app| {
588588- let draw = draw_scene!(
589589- app,
590590- statusbar,
591591- quick_menu,
592592- bumps,
593593- render_bar_overlaps
594594- );
595595- board.display.epd.partial_phase3_sync(strip, &rs, &draw);
596596- });
597597- partial_refreshes += 1;
598598- board.display.epd.power_off_async().await;
599599- }
600600-601601- // apply deferred transition from busy wait
602602- if let Some(transition) = deferred
603603- && let Some(nav) = launcher.apply(transition)
604604- {
605605- apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps);
606606- }
607607-608608- break 'render;
609609- }
610610-611611- if !board.display.epd.needs_initial_refresh() {
612612- break 'render; // degenerate zero-size region
613613- }
614614- // fall through to full GC
615615- info!("display: partial failed (initial refresh), promoting to full");
616616- } else {
617617- info!("display: promoted partial to full (ghosting clear)");
618618- }
619619- // fall through to full GC
620620- }
621621-622622- // full GC refresh: explicit Full, ghost-clear, or initial-refresh fallback
623623- if matches!(redraw, Redraw::Full | Redraw::Partial(_)) {
624624- // ensure analog off; no-op normally, required after skipped power-off
625625- board.display.epd.power_off_async().await;
626626-627627- update_statusbar(statusbar, cached_battery_mv, sd_ok);
628628-629629- let active = launcher.active();
630630- with_app!(active, apps, |app| {
631631- let draw = draw_scene!(app, statusbar, quick_menu, bumps, true);
632632- board.display.epd.write_full_frame(strip, &mut delay, &draw);
633633- });
634634-635635- board.display.epd.start_full_update();
636636-637637- // process input during ~1.6s GC waveform
638638- let deferred = busy_wait_with_input!(
639639- board.display.epd,
640640- mapper,
641641- quick_menu,
642642- launcher,
643643- apps,
644644- dir_cache,
645645- bm_cache,
646646- board.storage.sd
647647- );
648648-649649- board.display.epd.finish_full_update();
650650- partial_refreshes = 0;
651651- red_stale = false;
652652-653653- if let Some(transition) = deferred
654654- && let Some(nav) = launcher.apply(transition)
655655- {
656656- apply_transition!(nav, launcher, apps, bm_cache, quick_menu, bumps);
657657- }
658658- }
659659- } // 'render
660660- }
661661-}
662662-663663-// helpers
664664-665665-fn update_statusbar(bar: &mut StatusBar, battery_mv: u16, sd_ok: bool) {
666666- const HEAP_TOTAL: usize = 143360; // matches heap_allocator!(size: ...) above
667667- let stats = esp_alloc::HEAP.stats();
668668-669669- let bat_pct = battery::battery_percentage(battery_mv);
670670-671671- bar.update(&SystemStatus {
672672- uptime_secs: uptime_secs(),
673673- battery_mv,
674674- battery_pct: bat_pct,
675675- heap_used: stats.current_usage,
676676- heap_peak: stats.max_usage,
677677- heap_total: HEAP_TOTAL,
678678- stack_free: free_stack_bytes(),
679679- stack_hwm: stack_high_water_mark(),
680680- sd_ok,
681681- });
682682-}
683683-684684-// push quick-menu cycle changes into active app; persist settings-owned values.
685685-// hand-written match (not with_app!) because we also borrow apps.settings below.
686686-fn sync_quick_menu(qm: &QuickMenu, active: AppId, apps: &mut Apps, ctx: &mut AppContext) {
687687- for id in 0..MAX_APP_ACTIONS as u8 {
688688- if let Some(value) = qm.app_cycle_value(id) {
689689- match active {
690690- AppId::Home => apps.home.on_quick_cycle_update(id, value, ctx),
691691- AppId::Files => apps.files.on_quick_cycle_update(id, value, ctx),
692692- AppId::Reader => apps.reader.on_quick_cycle_update(id, value, ctx),
693693- AppId::Settings => apps.settings.on_quick_cycle_update(id, value, ctx),
694694- AppId::Upload => {}
695695- }
696696- }
697697- }
698698-699699- // persist reader font-size change into settings
700700- if active == AppId::Reader
701701- && let Some(font_idx) = qm.app_cycle_value(reader::QA_FONT_SIZE)
702702- {
703703- let ss = apps.settings.system_settings_mut();
704704- if ss.book_font_size_idx != font_idx {
705705- ss.book_font_size_idx = font_idx;
706706- apps.settings.mark_save_needed();
707707- }
708708- }
150150+ kernel.run(&mut app_mgr).await
709151}
+3-3
src/board/action.rs
kernel/src/board/action.rs
···11-// Semantic actions decoupled from physical buttons.
22-// Apps match on Action, never on HwButton. ButtonMapper translates
33-// physical events using the fixed portrait one-handed layout.
11+// semantic actions decoupled from physical buttons
22+// apps match on Action, never on HwButton
4354use crate::board::button::Button;
65use crate::drivers::input::Event;
···4443 }
4544}
46454646+// fixed portrait one-handed layout
4747#[derive(Default)]
4848pub struct ButtonMapper;
4949
+3-3
src/board/button.rs
kernel/src/board/button.rs
···11-// Button definitions and ADC resistance ladder decoding.
22-// Two ADC ladders (Row1 GPIO1, Row2 GPIO2) plus discrete power button on GPIO3.
33-// Each ladder encodes buttons as voltage levels.
11+// button definitions and ADC resistance ladder decoding
22+// two ADC ladders (Row1 GPIO1, Row2 GPIO2) plus power button (GPIO3)
4354#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Button {
···35343635pub const DEFAULT_TOLERANCE: u16 = 150;
37363737+// (center_mv, tolerance_mv, button)
3838pub const ROW1_THRESHOLDS: &[(u16, u16, Button)] = &[
3939 (3, 50, Button::Right),
4040 (1113, DEFAULT_TOLERANCE, Button::Left),
-189
src/board/mod.rs
···11-// XTEink X4 board support (ESP32-C3, SSD1677 800x480, SD over SPI2).
22-// DMA-backed SPI (GDMA CH0); RefCellDevice arbitrates bus.
33-44-pub mod action;
55-pub mod button;
66-pub mod raw_gpio;
77-88-pub use crate::drivers::sdcard::SdStorage;
99-pub use crate::drivers::ssd1677::{DisplayDriver, HEIGHT, SPI_FREQ_MHZ, WIDTH};
1010-pub use crate::drivers::strip::StripBuffer;
1111-pub use button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder};
1212-1313-pub const SCREEN_W: u16 = HEIGHT; // 480
1414-pub const SCREEN_H: u16 = WIDTH; // 800
1515-1616-use core::cell::RefCell;
1717-1818-use critical_section::Mutex;
1919-use embedded_hal_bus::spi::RefCellDevice;
2020-use esp_hal::{
2121- Blocking,
2222- analog::adc::{Adc, AdcCalCurve, AdcConfig, AdcPin, Attenuation},
2323- delay::Delay,
2424- dma::{DmaRxBuf, DmaTxBuf},
2525- gpio::{Event, Input, InputConfig, Io, Level, Output, OutputConfig, Pull},
2626- peripherals::{ADC1, GPIO0, GPIO1, GPIO2, Peripherals},
2727- spi,
2828- time::Rate,
2929-};
3030-use log::info;
3131-use static_cell::StaticCell;
3232-3333-pub type SpiBus = spi::master::SpiDmaBus<'static, Blocking>;
3434-pub type SharedSpiDevice = RefCellDevice<'static, SpiBus, Output<'static>, Delay>;
3535-pub type SdSpiDevice = RefCellDevice<'static, SpiBus, raw_gpio::RawOutputPin, Delay>;
3636-pub type Epd = DisplayDriver<SharedSpiDevice, Output<'static>, Output<'static>, Input<'static>>;
3737-3838-static SPI_BUS: StaticCell<RefCell<SpiBus>> = StaticCell::new();
3939-4040-// ISR clears interrupt flag; any interrupt wakes the Embassy executor
4141-static POWER_BTN: Mutex<RefCell<Option<Input<'static>>>> = Mutex::new(RefCell::new(None));
4242-4343-#[esp_hal::handler]
4444-fn gpio_handler() {
4545- critical_section::with(|cs| {
4646- if let Some(btn) = POWER_BTN.borrow_ref_mut(cs).as_mut()
4747- && btn.is_interrupt_set()
4848- {
4949- btn.clear_interrupt();
5050- // any interrupt wakes the Embassy executor; no explicit signal needed
5151- }
5252- });
5353-}
5454-5555-pub fn power_button_is_low() -> bool {
5656- critical_section::with(|cs| {
5757- POWER_BTN
5858- .borrow_ref_mut(cs)
5959- .as_mut()
6060- .map(|btn| btn.is_low())
6161- .unwrap_or(false)
6262- })
6363-}
6464-6565-pub struct InputHw {
6666- pub adc: Adc<'static, ADC1<'static>, Blocking>,
6767- pub row1: AdcPin<GPIO1<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
6868- pub row2: AdcPin<GPIO2<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
6969- pub battery: AdcPin<GPIO0<'static>, ADC1<'static>, AdcCalCurve<ADC1<'static>>>,
7070-}
7171-7272-pub struct DisplayHw {
7373- pub epd: Epd,
7474-}
7575-7676-pub struct StorageHw {
7777- pub sd: SdStorage<SdSpiDevice>,
7878-}
7979-8080-pub struct Board {
8181- pub input: InputHw,
8282- pub display: DisplayHw,
8383- pub storage: StorageHw,
8484-}
8585-8686-impl Board {
8787- pub fn init(p: Peripherals) -> Self {
8888- let input = Self::init_input(&p);
8989- let (display, storage) = Self::init_spi_peripherals(p);
9090- Board {
9191- input,
9292- display,
9393- storage,
9494- }
9595- }
9696-9797- // Takes &Peripherals so init_spi_peripherals can consume them by value.
9898- // Safety: each clone_unchecked targets a distinct GPIO/ADC peripheral;
9999- // no pin is used by both init_input and init_spi_peripherals.
100100- fn init_input(p: &Peripherals) -> InputHw {
101101- let mut adc_cfg = AdcConfig::new();
102102-103103- let row1 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
104104- unsafe { p.GPIO1.clone_unchecked() },
105105- Attenuation::_11dB,
106106- );
107107-108108- let row2 = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
109109- unsafe { p.GPIO2.clone_unchecked() },
110110- Attenuation::_11dB,
111111- );
112112-113113- let battery = adc_cfg.enable_pin_with_cal::<_, AdcCalCurve<ADC1>>(
114114- unsafe { p.GPIO0.clone_unchecked() },
115115- Attenuation::_11dB,
116116- );
117117-118118- let adc = Adc::new(unsafe { p.ADC1.clone_unchecked() }, adc_cfg);
119119-120120- let mut io = Io::new(unsafe { p.IO_MUX.clone_unchecked() });
121121- io.set_interrupt_handler(gpio_handler);
122122-123123- let mut power = Input::new(
124124- unsafe { p.GPIO3.clone_unchecked() },
125125- InputConfig::default().with_pull(Pull::Up),
126126- );
127127- power.listen(Event::FallingEdge);
128128-129129- critical_section::with(|cs| {
130130- POWER_BTN.borrow_ref_mut(cs).replace(power);
131131- });
132132- info!("power button: GPIO3 interrupt armed (FallingEdge)");
133133-134134- InputHw {
135135- adc,
136136- row1,
137137- row2,
138138- battery,
139139- }
140140- }
141141-142142- // 400kHz for SD probe, then 20MHz; DMA-backed
143143- fn init_spi_peripherals(p: Peripherals) -> (DisplayHw, StorageHw) {
144144- let epd_cs = Output::new(p.GPIO21, Level::High, OutputConfig::default());
145145- let dc = Output::new(p.GPIO4, Level::High, OutputConfig::default());
146146- let rst = Output::new(p.GPIO5, Level::High, OutputConfig::default());
147147-148148- // no pre-armed interrupt; esp-hal async Wait manages GPIO6
149149- let busy = Input::new(p.GPIO6, InputConfig::default().with_pull(Pull::None));
150150-151151- // GPIO12 free in DIO mode; no esp-hal type, use raw registers
152152- let sd_cs = unsafe { raw_gpio::RawOutputPin::new(12) };
153153-154154- let slow_cfg = spi::master::Config::default().with_frequency(Rate::from_khz(400));
155155-156156- let mut spi_raw = spi::master::Spi::new(p.SPI2, slow_cfg)
157157- .unwrap()
158158- .with_sck(p.GPIO8)
159159- .with_mosi(p.GPIO10)
160160- .with_miso(p.GPIO7);
161161-162162- // 80 clocks with CS high before DMA conversion (SD spec init)
163163- let _ = spi_raw.write(&[0xFF; 10]);
164164-165165- // 4096B each direction: strip max ~4000B, SD sectors 512B
166166- let (rx_buffer, rx_descriptors, tx_buffer, tx_descriptors) = esp_hal::dma_buffers!(4096);
167167- let dma_rx_buf = DmaRxBuf::new(rx_descriptors, rx_buffer).unwrap();
168168- let dma_tx_buf = DmaTxBuf::new(tx_descriptors, tx_buffer).unwrap();
169169-170170- let spi_dma_bus = spi_raw
171171- .with_dma(p.DMA_CH0)
172172- .with_buffers(dma_rx_buf, dma_tx_buf);
173173-174174- let spi_ref: &'static RefCell<SpiBus> = SPI_BUS.init(RefCell::new(spi_dma_bus));
175175- info!("SPI bus: DMA enabled (CH0, 4096B TX+RX)");
176176-177177- let sd_spi = RefCellDevice::new(spi_ref, sd_cs, Delay::new()).unwrap();
178178- let sd = SdStorage::new(sd_spi);
179179-180180- let fast_cfg = spi::master::Config::default().with_frequency(Rate::from_mhz(SPI_FREQ_MHZ));
181181- spi_ref.borrow_mut().apply_config(&fast_cfg).unwrap();
182182- info!("SPI bus: 400kHz -> {}MHz", SPI_FREQ_MHZ);
183183-184184- let epd_spi = RefCellDevice::new(spi_ref, epd_cs, Delay::new()).unwrap();
185185- let epd = DisplayDriver::new(epd_spi, dc, rst, busy);
186186-187187- (DisplayHw { epd }, StorageHw { sd })
188188- }
189189-}
···11-// Direct register GPIO for pins esp-hal does not expose.
22-// DIO flash mode frees GPIO12/13; esp-hal 1.0 has no peripheral types
33-// for GPIO12..17 on ESP32-C3. Only OutputPin is implemented.
11+// direct register GPIO for pins esp-hal does not expose
22+// DIO flash mode frees GPIO12/13; esp-hal 1.0 has no peripheral
33+// types for GPIO12..17 on ESP32-C3
4455-const GPIO_OUT_W1TS: u32 = 0x6000_4008; // set output high
66-const GPIO_OUT_W1TC: u32 = 0x6000_400C; // set output low
77-const GPIO_ENABLE_W1TS: u32 = 0x6000_4024; // enable output
55+const GPIO_OUT_W1TS: u32 = 0x6000_4008;
66+const GPIO_OUT_W1TC: u32 = 0x6000_400C;
77+const GPIO_ENABLE_W1TS: u32 = 0x6000_4024;
88const IO_MUX_BASE: u32 = 0x6000_9000;
99const IO_MUX_PIN_STRIDE: u32 = 0x04;
1010···3030 out_sel.write_volatile(0x80);
31313232 (GPIO_ENABLE_W1TS as *mut u32).write_volatile(mask);
3333-3433 (GPIO_OUT_W1TS as *mut u32).write_volatile(mask);
3534 }
3635
···11-// Li-ion battery voltage estimation.
22-// GPIO0 reads through 100K/100K divider (2:1); ADC 11dB attenuation gives
33-// 0..2500mV; multiply by 2 for actual cell voltage.
44-// Piecewise-linear LUT models the discharge curve.
11+// battery voltage estimation --- generic over board calibration.
22+// board-specific divider ratio and discharge curve live in board::battery.
5366-const DIVIDER_MULT: u32 = 2;
77-88-// (millivolts, percentage); must be sorted descending by mV
99-const DISCHARGE_CURVE: &[(u32, u8)] = &[
1010- (4200, 100),
1111- (4060, 90),
1212- (3980, 80),
1313- (3920, 70),
1414- (3870, 60),
1515- (3830, 50),
1616- (3790, 40),
1717- (3750, 30),
1818- (3700, 20),
1919- (3600, 10),
2020- (3400, 5),
2121- (3000, 0),
2222-];
44+use crate::board::battery::{DISCHARGE_CURVE, DIVIDER_MULT};
235246pub fn adc_to_battery_mv(adc_mv: u16) -> u16 {
257 (adc_mv as u32 * DIVIDER_MULT) as u16
···3719 return DISCHARGE_CURVE[last].1;
3820 }
39214040- // interpolate between bracketing points
4122 let mut i = 0;
4223 while i + 1 < DISCHARGE_CURVE.len() {
4324 let (mv_hi, pct_hi) = DISCHARGE_CURVE[i];
+6-8
src/drivers/input.rs
kernel/src/drivers/input.rs
···11-// Debounced input from ADC ladders and power button.
22-// One button at a time (ladder hw limitation). Sources: Row1 (GPIO1),
33-// Row2 (GPIO2), Power (GPIO3 interrupt). 15ms debounce, 1s long press, 150ms repeat.
44-// ADC reads oversampled (4 samples averaged) to reject noise from ESP32-C3
55-// non-linearity and battery sag during SPI traffic; ~40us per channel.
11+// debounced input from ADC ladders and power button
22+// one button at a time (ladder hw limitation)
33+// 15 ms debounce, 1 s long press, 150 ms repeat
44+// ADC reads oversampled 4x to reject noise (~40 us per channel)
6576use esp_hal::time::{Duration, Instant};
8798use crate::board::InputHw;
109use crate::board::button::{Button, ROW1_THRESHOLDS, ROW2_THRESHOLDS, decode_ladder};
11101212-// 4-sample average; ~40us per channel, rejects ADC noise from SPI/battery sag
1311macro_rules! read_averaged {
1412 ($adc:expr, $pin:expr) => {{
1513 let mut sum: u32 = 0;
···3432}
35333634struct EventQueue {
3737- buf: [Option<Event>; 2],
3535+ buf: [Option<Event>; 4],
3836}
39374038impl EventQueue {
4139 const fn new() -> Self {
4242- Self { buf: [None; 2] }
4040+ Self { buf: [None; 4] }
4341 }
44424543 fn push(&mut self, ev: Event) {
-9
src/drivers/mod.rs
···11-// Hardware drivers: chip-level and protocol-level, board-independent.
22-// Each module is reusable across boards; pin assignments and bus wiring in board/.
33-44-pub mod battery;
55-pub mod input;
66-pub mod sdcard;
77-pub mod ssd1677;
88-pub mod storage;
99-pub mod strip;
···11-// Strip-based rendering buffer for e-paper.
22-// 4KB strip instead of 48KB framebuffer; display split into horizontal bands.
33-// Widgets draw to logical coords, clipped here.
44-// begin_strip() for full refresh, begin_window() for partial.
11+// strip-based rendering buffer for e-paper
22+// 4 KB strip instead of 48 KB framebuffer; display split into horizontal bands
33+// widgets draw to logical coords, clipped here
5465use embedded_graphics_core::{
76 Pixel,
···1413use super::ssd1677::{HEIGHT, Rotation, WIDTH};
1514use crate::ui::Region;
16151717-pub const STRIP_ROWS: u16 = 40; // 4000B per strip (800/8 * 40)
1616+pub const STRIP_ROWS: u16 = 40;
1817pub const PHYS_BYTES_PER_ROW: usize = (WIDTH as usize) / 8;
19182020-pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize; // 4000B
2121-pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS; // 12 strips
1919+pub const STRIP_BUF_SIZE: usize = PHYS_BYTES_PER_ROW * STRIP_ROWS as usize;
2020+pub const STRIP_COUNT: u16 = HEIGHT / STRIP_ROWS;
22212322pub struct StripBuffer {
2423 buf: [u8; STRIP_BUF_SIZE],
···9897 (self.win_x, self.win_y, self.win_w, self.win_h)
9998 }
10099101101- // physical window -> logical coords for widget culling
102100 pub fn logical_window(&self) -> Region {
103101 match self.rotation {
104102 Rotation::Deg0 => Region::new(self.win_x, self.win_y, self.win_w, self.win_h),
···135133 (STRIP_BUF_SIZE / rb) as u16
136134 }
137135138138- #[inline]
139136 fn to_physical(&self, lx: u16, ly: u16) -> (u16, u16) {
140137 match self.rotation {
141138 Rotation::Deg0 => (lx, ly),
···145142 }
146143 }
147144148148- #[inline]
149145 fn set_pixel_physical(&mut self, px: u16, py: u16, black: bool) {
150146 if px < self.win_x || px >= self.win_x + self.win_w {
151147 return;
···166162 }
167163 }
168164169169- // direct 1-bit glyph blit; bypasses DrawTarget overhead
170165 #[allow(clippy::too_many_arguments)]
171166 pub fn blit_1bpp(
172167 &mut self,
···188183 }
189184 }
190185191191- // Deg270: (lx,ly) -> (ly, HEIGHT-1-lx); inner loop is row-contiguous
192186 #[inline(never)]
193187 #[allow(clippy::too_many_arguments)]
194188 fn blit_1bpp_270(
···248242 }
249243 }
250244251251- // generic fallback (Deg0/Deg90/Deg180)
252245 #[allow(clippy::too_many_arguments)]
253246 fn blit_1bpp_generic(
254247 &mut self,
···285278 }
286279 }
287280288288- // byte-aligned rect fill in physical coords, clipped to window
289281 fn fill_physical_rect(&mut self, px0: u16, py0: u16, px1: u16, py1: u16, black: bool) {
290282 let cx0 = px0.max(self.win_x);
291283 let cx1 = px1.min(self.win_x + self.win_w);
···404396 where
405397 I: IntoIterator<Item = Self::Color>,
406398 {
407407- // contiguous fills rare on 1-bit; fall back to per-pixel
408399 let w = area.size.width as i32;
409400 if w == 0 {
410401 return Ok(());
+176-33
src/fonts/bitmap.rs
···11-// Pre-rasterised 1-bit bitmap font types.
22-// Data in flash via &'static refs from build.rs. Packed MSB-first, row-major.
11+// pre-rasterised 1-bit bitmap font types
22+// data in flash via &'static refs from build.rs, packed MSB-first, row-major
33+//
44+// two glyph tables per font:
55+// ascii 0x20-0x7E: contiguous direct-indexed (fast, zero-search)
66+// extended unicode: sorted codepoint array, binary-searched at runtime
77+//
88+// characters not found in either table render as '?' (ascii fallback)
39410use embedded_graphics_core::geometry::Size;
511use embedded_graphics_core::pixelcolor::BinaryColor;
···9151016pub const FIRST_CHAR: u8 = 0x20;
1117pub const LAST_CHAR: u8 = 0x7E;
1212-pub const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize; // 95
1818+pub const GLYPH_COUNT: usize = (LAST_CHAR - FIRST_CHAR + 1) as usize;
13191414-// map arbitrary byte to printable char; out-of-range becomes '?'
2020+// map a raw byte to a printable ascii char, or '?' if out of range
1521#[inline]
1622pub fn byte_to_char(b: u8) -> char {
1723 if (FIRST_CHAR..=LAST_CHAR).contains(&b) {
···2127 }
2228}
23293030+// metrics and bitmap location for a single rasterised glyph
2431#[derive(Clone, Copy)]
2532#[repr(C)]
2633pub struct BitmapGlyph {
2727- pub advance: u8,
2828- pub offset_x: i8,
2929- pub offset_y: i8,
3030- pub width: u8,
3131- pub height: u8,
3232- pub bitmap_offset: u16,
3434+ pub advance: u8, // horizontal advance width in pixels
3535+ pub offset_x: i8, // cursor to glyph left edge
3636+ pub offset_y: i8, // baseline to glyph top (negative = above)
3737+ pub width: u8, // bitmap width in pixels
3838+ pub height: u8, // bitmap height in pixels
3939+ pub bitmap_offset: u16, // byte offset into bitmap array
3340}
34414242+// pre-rasterised 1-bit bitmap font stored in flash
4343+//
4444+// ascii glyphs are direct-indexed for 0x20-0x7E
4545+// extended unicode glyphs are sorted by codepoint, binary-searched
4646+// generated at build time by build.rs; zero heap, zero parsing
3547pub struct BitmapFont {
3636- pub glyphs: &'static [BitmapGlyph; GLYPH_COUNT],
3737- pub bitmaps: &'static [u8],
3838- pub line_height: u16,
3939- pub ascent: u16,
4848+ pub glyphs: &'static [BitmapGlyph; GLYPH_COUNT], // ascii, indexed by (ch - FIRST_CHAR)
4949+ pub bitmaps: &'static [u8], // packed 1-bit data for ascii
5050+5151+ pub ext_codepoints: &'static [u32], // sorted extended unicode codepoints
5252+ pub ext_glyphs: &'static [BitmapGlyph], // parallel to ext_codepoints
5353+ pub ext_bitmaps: &'static [u8], // packed 1-bit data for extended
5454+5555+ pub line_height: u16, // ascent + descent + leading
5656+ pub ascent: u16, // baseline to top of tallest glyph
5757+}
5858+5959+// result of a glyph lookup: metrics and which bitmap table to use
6060+#[derive(Clone, Copy)]
6161+pub struct ResolvedGlyph<'a> {
6262+ pub glyph: &'a BitmapGlyph,
6363+ pub bitmaps: &'a [u8],
4064}
41654266impl BitmapFont {
6767+ // look up a character, return glyph metrics
6868+ // ascii: direct array index; extended: binary search
6969+ // unknown chars fall back to space glyph (index 0)
4370 #[inline]
4471 pub fn glyph(&self, ch: char) -> &BitmapGlyph {
7272+ self.resolve(ch).glyph
7373+ }
7474+7575+ // look up a character, return glyph + correct bitmap slice
7676+ pub fn resolve(&self, ch: char) -> ResolvedGlyph<'_> {
4577 let code = ch as u32;
4646- if (FIRST_CHAR as u32..=LAST_CHAR as u32).contains(&code) {
4747- &self.glyphs[(code - FIRST_CHAR as u32) as usize]
4848- } else {
4949- &self.glyphs[0] // space
7878+7979+ // fast path: ascii
8080+ if code >= FIRST_CHAR as u32 && code <= LAST_CHAR as u32 {
8181+ return ResolvedGlyph {
8282+ glyph: &self.glyphs[(code - FIRST_CHAR as u32) as usize],
8383+ bitmaps: self.bitmaps,
8484+ };
8585+ }
8686+8787+ // extended unicode: binary search
8888+ if let Ok(idx) = self.ext_codepoints.binary_search(&code) {
8989+ return ResolvedGlyph {
9090+ glyph: &self.ext_glyphs[idx],
9191+ bitmaps: self.ext_bitmaps,
9292+ };
9393+ }
9494+9595+ // fallback: '?' from ascii table
9696+ let q_idx = (b'?' - FIRST_CHAR) as usize;
9797+ ResolvedGlyph {
9898+ glyph: &self.glyphs[q_idx],
9999+ bitmaps: self.bitmaps,
50100 }
51101 }
52102103103+ // true if this font has a real glyph for ch (not just '?' fallback)
104104+ #[inline]
105105+ pub fn has_glyph(&self, ch: char) -> bool {
106106+ let code = ch as u32;
107107+ if code >= FIRST_CHAR as u32 && code <= LAST_CHAR as u32 {
108108+ return true;
109109+ }
110110+ self.ext_codepoints.binary_search(&code).is_ok()
111111+ }
112112+113113+ // horizontal advance for a single character
53114 #[inline]
54115 pub fn advance(&self, ch: char) -> u8 {
55116 self.glyph(ch).advance
56117 }
571185858- // sum of advance widths for every character in text
119119+ // total width in pixels of a &str
59120 #[inline]
60121 pub fn measure_str(&self, text: &str) -> u16 {
61122 text.chars().map(|c| self.advance(c) as u16).sum()
62123 }
631246464- // sum of advance widths for every byte; out-of-range bytes count as '?'
6565- #[inline]
125125+ // total width in pixels of a &[u8] slice (decodes utf-8)
66126 pub fn measure_bytes(&self, text: &[u8]) -> u16 {
6767- text.iter()
6868- .map(|&b| self.advance(byte_to_char(b)) as u16)
6969- .sum()
127127+ Utf8Iter::new(text).map(|c| self.advance(c) as u16).sum()
70128 }
711297272- // draw a single glyph in BinaryColor::On (black); return advance width
130130+ // draw a character at (cx, baseline) in black, return advance
73131 #[inline]
74132 pub fn draw_char(&self, strip: &mut StripBuffer, ch: char, cx: i32, baseline: i32) -> u8 {
75133 self.draw_char_fg(strip, ch, BinaryColor::On, cx, baseline)
76134 }
771357878- // draw a single glyph in the given foreground colour; return advance width
136136+ // draw a character with given foreground colour, return advance
79137 #[inline]
80138 pub fn draw_char_fg(
81139 &self,
···85143 cx: i32,
86144 baseline: i32,
87145 ) -> u8 {
8888- let g = self.glyph(ch);
146146+ let resolved = self.resolve(ch);
147147+ let g = resolved.glyph;
89148 if g.width > 0 && g.height > 0 {
9090- blit_glyph(strip, self.bitmaps, g, fg, cx, baseline);
149149+ blit_glyph(strip, resolved.bitmaps, g, fg, cx, baseline);
91150 }
92151 g.advance
93152 }
941539595- // draw text in BinaryColor::On; return x after the last glyph
154154+ // draw a &str at (cx, baseline) in black, return final x
96155 pub fn draw_str(&self, strip: &mut StripBuffer, text: &str, cx: i32, baseline: i32) -> i32 {
97156 self.draw_str_fg(strip, text, BinaryColor::On, cx, baseline)
98157 }
99158100100- // draw text in the given foreground colour; return x after the last glyph
159159+ // draw a &str with given foreground, return final x
101160 pub fn draw_str_fg(
102161 &self,
103162 strip: &mut StripBuffer,
···113172 x
114173 }
115174175175+ // draw a &[u8] (decoded as utf-8) at (cx, baseline) in black, return final x
116176 pub fn draw_bytes(&self, strip: &mut StripBuffer, text: &[u8], cx: i32, baseline: i32) -> i32 {
117177 let mut x = cx;
118118- for &b in text {
119119- x += self.draw_char(strip, byte_to_char(b), x, baseline) as i32;
178178+ for ch in Utf8Iter::new(text) {
179179+ x += self.draw_char(strip, ch, x, baseline) as i32;
120180 }
121181 x
122182 }
123183124124- // measure, align, and draw text; does not clear background
184184+ // draw a &[u8] with given foreground, return final x
185185+ pub fn draw_bytes_fg(
186186+ &self,
187187+ strip: &mut StripBuffer,
188188+ text: &[u8],
189189+ fg: BinaryColor,
190190+ cx: i32,
191191+ baseline: i32,
192192+ ) -> i32 {
193193+ let mut x = cx;
194194+ for ch in Utf8Iter::new(text) {
195195+ x += self.draw_char_fg(strip, ch, fg, x, baseline) as i32;
196196+ }
197197+ x
198198+ }
199199+200200+ // draw a &str aligned within a region
125201 pub fn draw_aligned(
126202 &self,
127203 strip: &mut StripBuffer,
···166242 fg == BinaryColor::On,
167243 );
168244}
245245+246246+// minimal utf-8 byte-slice iterator
247247+// decodes &[u8] one char at a time; invalid sequences replaced with U+FFFD
248248+// (which renders as '?' via the font fallback)
249249+pub struct Utf8Iter<'a> {
250250+ data: &'a [u8],
251251+ pos: usize,
252252+}
253253+254254+impl<'a> Utf8Iter<'a> {
255255+ #[inline]
256256+ pub fn new(data: &'a [u8]) -> Self {
257257+ Self { data, pos: 0 }
258258+ }
259259+}
260260+261261+impl Iterator for Utf8Iter<'_> {
262262+ type Item = char;
263263+264264+ fn next(&mut self) -> Option<char> {
265265+ if self.pos >= self.data.len() {
266266+ return None;
267267+ }
268268+269269+ let b0 = self.data[self.pos];
270270+271271+ // single-byte ascii
272272+ if b0 < 0x80 {
273273+ self.pos += 1;
274274+ return Some(b0 as char);
275275+ }
276276+277277+ // determine expected sequence length from lead byte
278278+ let (mut cp, expected) = if b0 < 0xC0 {
279279+ // stray continuation byte
280280+ self.pos += 1;
281281+ return Some('\u{FFFD}');
282282+ } else if b0 < 0xE0 {
283283+ ((b0 as u32) & 0x1F, 2)
284284+ } else if b0 < 0xF0 {
285285+ ((b0 as u32) & 0x0F, 3)
286286+ } else if b0 < 0xF8 {
287287+ ((b0 as u32) & 0x07, 4)
288288+ } else {
289289+ self.pos += 1;
290290+ return Some('\u{FFFD}');
291291+ };
292292+293293+ if self.pos + expected > self.data.len() {
294294+ self.pos = self.data.len();
295295+ return Some('\u{FFFD}');
296296+ }
297297+298298+ // decode continuation bytes
299299+ for i in 1..expected {
300300+ let cont = self.data[self.pos + i];
301301+ if cont & 0xC0 != 0x80 {
302302+ self.pos += i;
303303+ return Some('\u{FFFD}');
304304+ }
305305+ cp = (cp << 6) | (cont as u32 & 0x3F);
306306+ }
307307+308308+ self.pos += expected;
309309+ Some(char::from_u32(cp).unwrap_or('\u{FFFD}'))
310310+ }
311311+}
+65-14
src/fonts/mod.rs
···11-// Build-time rasterised bitmap fonts for e-ink rendering.
22-// TTFs rasterised by build.rs via fontdue into 1-bit tables in flash.
33-// Zero heap, zero parsing at runtime. Three sizes: 0=Small, 1=Medium, 2=Large.
11+// build-time rasterised bitmap fonts for e-ink rendering
22+// TTFs rasterised by build.rs via fontdue into 1-bit tables in flash
33+// zero heap, zero parsing at runtime
44+//
55+// five size tiers: 0=XSmall 1=Small 2=Medium 3=Large 4=XLarge
4657pub mod bitmap;
68···1214use crate::drivers::strip::StripBuffer;
1315use bitmap::BitmapFont;
14161515-// 0 = Small, 1 = Medium, 2 = Large
1616-pub const FONT_SIZE_NAMES: &[&str] = &["Small", "Medium", "Large"];
1717+pub const FONT_SIZE_COUNT: usize = 5;
1818+1919+pub const FONT_SIZE_NAMES: &[&str] = &["XSmall", "Small", "Medium", "Large", "XLarge"];
2020+2121+// pre-resolved body + heading font pair for a given size index
2222+#[derive(Clone, Copy)]
2323+pub struct UiFonts {
2424+ pub body: &'static BitmapFont,
2525+ pub heading: &'static BitmapFont,
2626+}
17272828+impl UiFonts {
2929+ pub fn for_size(idx: u8) -> Self {
3030+ Self {
3131+ body: body_font(idx),
3232+ heading: heading_font(idx),
3333+ }
3434+ }
3535+}
3636+3737+// human-readable name for size index (clamped to valid range)
3838+#[inline]
1839pub fn font_size_name(idx: u8) -> &'static str {
1940 FONT_SIZE_NAMES
2041 .get(idx as usize)
···2243 .unwrap_or("Small")
2344}
24452525-// body font by index: 0 = Small, 1 = Medium, 2 = Large
4646+#[inline]
4747+pub const fn max_size_idx() -> u8 {
4848+ (FONT_SIZE_COUNT - 1) as u8
4949+}
5050+2651pub fn body_font(idx: u8) -> &'static BitmapFont {
2752 match idx {
2828- 1 => &font_data::REGULAR_BODY_MEDIUM,
2929- 2 => &font_data::REGULAR_BODY_LARGE,
5353+ 0 => &font_data::REGULAR_BODY_XSMALL,
5454+ 1 => &font_data::REGULAR_BODY_SMALL,
5555+ 2 => &font_data::REGULAR_BODY_MEDIUM,
5656+ 3 => &font_data::REGULAR_BODY_LARGE,
5757+ 4 => &font_data::REGULAR_BODY_XLARGE,
3058 _ => &font_data::REGULAR_BODY_SMALL,
3159 }
3260}
33613434-// chrome font (button labels, quick-menu items, loading text, etc.);
3535-// always returns the small body font regardless of the size setting
6262+// chrome font (button labels, quick-menu items, loading text)
6363+// always the XSmall body font, compact for UI chrome
3664pub fn chrome_font() -> &'static BitmapFont {
3765 body_font(0)
3866}
39674068pub fn heading_font(idx: u8) -> &'static BitmapFont {
4169 match idx {
4242- 1 => &font_data::REGULAR_HEADING_MEDIUM,
4343- 2 => &font_data::REGULAR_HEADING_LARGE,
7070+ 0 => &font_data::REGULAR_HEADING_XSMALL,
7171+ 1 => &font_data::REGULAR_HEADING_SMALL,
7272+ 2 => &font_data::REGULAR_HEADING_MEDIUM,
7373+ 3 => &font_data::REGULAR_HEADING_LARGE,
7474+ 4 => &font_data::REGULAR_HEADING_XLARGE,
4475 _ => &font_data::REGULAR_HEADING_SMALL,
4576 }
4677}
···5384 Heading,
5485}
55868787+// complete set of four style variants at a single size tier
8888+// missing weights fall back to regular automatically
5689#[derive(Clone, Copy)]
5790pub struct FontSet {
5891 regular: &'static BitmapFont,
···9412795128 pub fn for_size(idx: u8) -> Self {
96129 match idx {
130130+ 0 => Self::from_fonts(
131131+ &font_data::REGULAR_BODY_XSMALL,
132132+ &font_data::BOLD_BODY_XSMALL,
133133+ &font_data::ITALIC_BODY_XSMALL,
134134+ &font_data::REGULAR_HEADING_XSMALL,
135135+ ),
97136 1 => Self::from_fonts(
137137+ &font_data::REGULAR_BODY_SMALL,
138138+ &font_data::BOLD_BODY_SMALL,
139139+ &font_data::ITALIC_BODY_SMALL,
140140+ &font_data::REGULAR_HEADING_SMALL,
141141+ ),
142142+ 2 => Self::from_fonts(
98143 &font_data::REGULAR_BODY_MEDIUM,
99144 &font_data::BOLD_BODY_MEDIUM,
100145 &font_data::ITALIC_BODY_MEDIUM,
101146 &font_data::REGULAR_HEADING_MEDIUM,
102147 ),
103103- 2 => Self::from_fonts(
148148+ 3 => Self::from_fonts(
104149 &font_data::REGULAR_BODY_LARGE,
105150 &font_data::BOLD_BODY_LARGE,
106151 &font_data::ITALIC_BODY_LARGE,
107152 &font_data::REGULAR_HEADING_LARGE,
153153+ ),
154154+ 4 => Self::from_fonts(
155155+ &font_data::REGULAR_BODY_XLARGE,
156156+ &font_data::BOLD_BODY_XLARGE,
157157+ &font_data::ITALIC_BODY_XLARGE,
158158+ &font_data::REGULAR_HEADING_XLARGE,
108159 ),
109160 _ => Self::from_fonts(
110161 &font_data::REGULAR_BODY_SMALL,
···116167 }
117168118169 pub fn new() -> Self {
119119- Self::for_size(0)
170170+ Self::for_size(1)
120171 }
121172122173 #[inline]
-8
src/kernel/mod.rs
···11-// Kernel: Embassy async runtime wrapper.
22-// wake: uptime helper (embassy_time)
33-// tasks: spawned tasks (input polling, housekeeping, idle sleep)
44-55-pub mod tasks;
66-pub mod wake;
77-88-pub use wake::uptime_secs;
+4-22
src/kernel/tasks.rs
kernel/src/kernel/tasks.rs
···11-// Embassy spawned tasks: input polling, housekeeping, idle sleep.
22-// input_task: ADC ladder + power button debounce, 10ms poll.
33-// housekeeping_task: periodic signals (status bar, SD check, bookmark flush).
44-// idle_timeout_task: fires IDLE_SLEEP_DUE after configured idle minutes.
11+// embassy spawned tasks: input polling, housekeeping, idle sleep
5263use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
74use embassy_sync::channel::Channel;
···118use crate::drivers::battery;
129use crate::drivers::input::{Event, InputDriver};
13101414-// debounced events from input_task to main loop
1511pub const INPUT_CHANNEL_CAP: usize = 8;
1612pub static INPUT_EVENTS: Channel<CriticalSectionRawMutex, Event, INPUT_CHANNEL_CAP> =
1713 Channel::new();
18141919-// latest battery mv; Signal overwrites stale values
2015pub static BATTERY_MV: Signal<CriticalSectionRawMutex, u16> = Signal::new();
21162222-// 3000 x 10ms = 30s between battery reads
2323-const BATTERY_INTERVAL_TICKS: u32 = 3000;
1717+const BATTERY_INTERVAL_TICKS: u32 = 3000; // 3000 x 10 ms = 30 s
24182519#[embassy_executor::task]
2620pub async fn input_task(mut input: InputDriver) -> ! {
2721 let mut ticker = Ticker::every(Duration::from_millis(10));
2822 let mut battery_counter: u32 = 0;
29233030- // initial reading so the status bar has a value before the first 30s
3124 let raw = input.read_battery_mv();
3225 BATTERY_MV.signal(battery::adc_to_battery_mv(raw));
3326···3528 ticker.next().await;
36293730 if let Some(ev) = input.poll() {
3838- let _ = INPUT_EVENTS.try_send(ev); // drop on full; main drains faster than events arrive
3131+ let _ = INPUT_EVENTS.try_send(ev);
3932 IDLE_RESET.signal(());
4033 }
4134···54475548#[embassy_executor::task]
5649pub async fn housekeeping_task() -> ! {
5757- // let boot rendering finish before first housekeeping cycle
5850 Timer::after(Duration::from_secs(5)).await;
59516052 let mut status_ticker = Ticker::every(Duration::from_secs(5));
6153 let mut sd_ticker = Ticker::every(Duration::from_secs(30));
62546363- // stagger bookmark ticker 2s behind SD so they don't hit the card together
6464- Timer::after(Duration::from_secs(2)).await;
5555+ Timer::after(Duration::from_secs(2)).await; // stagger behind SD
6556 let mut bm_ticker = Ticker::every(Duration::from_secs(30));
66576758 loop {
···7566 }
7667}
77687878-// set by main after loading settings; re-signal on change; 0 = never
7969pub static IDLE_TIMEOUT_MINS: Signal<CriticalSectionRawMutex, u16> = Signal::new();
8080-8181-// any button activity; Signal collapses rapid presses to one
8270pub static IDLE_RESET: Signal<CriticalSectionRawMutex, ()> = Signal::new();
8383-8484-// fired when idle timer expires; main loop puts display + MCU to sleep
8571pub static IDLE_SLEEP_DUE: Signal<CriticalSectionRawMutex, ()> = Signal::new();
86728773#[inline]
···9480 let mut timeout_mins = IDLE_TIMEOUT_MINS.wait().await;
95819682 loop {
9797- // park until a non-zero timeout is configured
9883 if timeout_mins == 0 {
9984 timeout_mins = IDLE_TIMEOUT_MINS.wait().await;
10085 continue;
···1028710388 let duration = Duration::from_secs(timeout_mins as u64 * 60);
10489105105- // drain stale signals before starting countdown
10690 let _ = IDLE_RESET.try_take();
10791 if let Some(new) = IDLE_TIMEOUT_MINS.try_take() {
10892 timeout_mins = new;
···120104 .await
121105 {
122106 Either3::First(()) => {
123123- // activity; restart countdown
124107 continue;
125108 }
126109 Either3::Second(new_mins) => {
···130113 Either3::Third(()) => {
131114 IDLE_SLEEP_DUE.signal(());
132115133133- // park until main acts; deep sleep is -> ! so this rarely returns
134116 use embassy_futures::select::{Either, select};
135117 match select(IDLE_RESET.wait(), IDLE_TIMEOUT_MINS.wait()).await {
136118 Either::First(()) => {}
-5
src/kernel/wake.rs
···11-// Uptime helper backed by Embassy's monotonic clock.
22-pub fn uptime_secs() -> u32 {
33- let ticks = embassy_time::Instant::now().as_ticks();
44- (ticks / embassy_time::TICK_HZ) as u32 // TICK_HZ = 1_000_000 on ESP32-C3
55-}
+7-4
src/lib.rs
···11-// pulp-os: operating system for the XTEink X4 (ESP32-C3, e-paper)
11+// pulp-os -- e-reader firmware for the XTEink X4
2233#![no_std]
4455extern crate alloc;
6677+// kernel crate re-exports -- keeps crate::board, crate::drivers,
88+// crate::kernel paths working in app code without import changes
99+pub use pulp_kernel::board;
1010+pub use pulp_kernel::drivers;
1111+pub use pulp_kernel::kernel;
1212+713pub mod apps;
88-pub mod board;
99-pub mod drivers;
1014pub mod fonts;
1111-pub mod kernel;
1215pub mod ui;
···11-// Region geometry and alignment helpers; x/w should be 8-aligned for partial refresh.
11+// region geometry and alignment helpers
2233use embedded_graphics::{prelude::*, primitives::Rectangle};
44