Krolyxon
Commit fa8c856

Branches

Tags

Refs →

orion-rffa8c856

wip: Phase 1 scaffold — board, drivers, UI, app modules

Add shared-I2C deps (esp-idf-hal, embedded-hal-bus) and flesh out the Rust
rewrite skeleton: board/board.rs, display/buttons drivers, ui menu + widgets,
and the apps module.
Author
krolyxon
Date
2026-10-21 16:15 +0530
Parent
dd33723
Commit
fa8c85601fe7e2245a884df206374da42aef11af

+607 −15

11 files changed

  1. M Cargo.toml +5 −0
  2. A src/apps/mod.rs +49 −0
  3. A src/board/board.rs +55 −0
  4. M src/board/mod.rs +1 −0
  5. M src/drivers/buttons.rs +75 −0
  6. M src/drivers/display.rs +83 −7
  7. M src/drivers/mod.rs +1 −0
  8. M src/main.rs +71 −8
  9. A src/ui/menu.rs +242 −0
  10. A src/ui/mod.rs +2 −0
  11. A src/ui/widgets.rs +23 −0
M Cargo.toml View
@@ -26,5 +26,10 @@ embassy-time = { version = "0.5", features = ["generic-queue-8"] }
2626 embedded-graphics = "0.8.2"
2727 ssd1306 = "0.10.0"
2828
29+# Explicit HAL (re-exported by esp-idf-svc, but used directly across the rewrite)
30+esp-idf-hal = "0.46"
31+# Shared I2C bus: one I2cDriver behind a RefCell, handed to OLED + (later) PN532
32+embedded-hal-bus = "0.3"
33+
2934 [build-dependencies]
3035 embuild = "0.33"
A src/apps/mod.rs View
@@ -0,0 +1,49 @@
1+//! The `App` abstraction: each menu feature owns its own render/poll loop and
2+//! returns when the user presses BACK.
3+//!
4+//! This replaces the Arduino per-feature blocking `while(1)` loops. An app
5+//! borrows the shared resources through [`AppCtx`] for the duration of `run`
6+//! and releases them on return, so the menu regains control afterwards.
7+
8+use esp_idf_hal::delay::FreeRtos;
9+
10+use crate::board::board::Board;
11+use crate::drivers::buttons::{debounce, Buttons};
12+use crate::drivers::display::Display;
13+use crate::ui::widgets;
14+
15+/// How an app finished. Only BACK exists for now; future apps may add variants
16+/// (e.g. requesting a restart).
17+pub enum AppResult {
18+ Back,
19+}
20+
21+/// Everything an app may touch, borrowed for the length of `run`.
22+pub struct AppCtx<'a> {
23+ pub display: &'a mut Display,
24+ pub buttons: &'a mut Buttons,
25+ pub board: &'a mut Board,
26+}
27+
28+pub trait App {
29+ fn run(&mut self, ctx: &mut AppCtx) -> AppResult;
30+}
31+
32+/// Placeholder app for features awaiting their phase. Shows a name screen and
33+/// waits for BACK.
34+pub struct StubApp {
35+ pub name: &'static str,
36+}
37+
38+impl App for StubApp {
39+ fn run(&mut self, ctx: &mut AppCtx) -> AppResult {
40+ widgets::stub(ctx.display, self.name);
41+ loop {
42+ if ctx.buttons.back() {
43+ debounce();
44+ return AppResult::Back;
45+ }
46+ FreeRtos::delay_ms(20);
47+ }
48+ }
49+}
A src/board/board.rs View
@@ -0,0 +1,55 @@
1+//! The one owned `Board`, built in `main` from `Peripherals::take()`.
2+//!
3+//! It owns the shared buses and the peripheral/pin handles that later phases
4+//! consume (radios, CC1101, WiFi/BLE modem). Display and Buttons are built as
5+//! siblings in `main` and reach apps via `AppCtx`, not through `Board`.
6+//!
7+//! The shared I2C lives behind a `'static` `RefCell` so SSD1306 (always-on) and
8+//! PN532 can both hold `RefCellDevice`s over it without re-initialising the bus.
9+
10+use core::cell::RefCell;
11+
12+use esp_idf_hal::gpio::AnyIOPin;
13+use esp_idf_hal::i2c::I2cDriver;
14+use esp_idf_hal::modem::Modem;
15+use esp_idf_hal::spi::{SPI2, SPI3};
16+
17+/// Shared handle to the I2C0 bus (OLED + PN532). Leaked to `'static` in `main`
18+/// because the display is always on for the lifetime of the program.
19+pub type SharedI2c = &'static RefCell<I2cDriver<'static>>;
20+
21+/// NRF24 bus + control pins (SPI2). Consumed in Phase 4.
22+pub struct NrfPins {
23+ pub sck: AnyIOPin<'static>,
24+ pub miso: AnyIOPin<'static>,
25+ pub mosi: AnyIOPin<'static>,
26+ pub ce1: AnyIOPin<'static>,
27+ pub csn1: AnyIOPin<'static>,
28+ pub ce2: AnyIOPin<'static>,
29+ pub csn2: AnyIOPin<'static>,
30+}
31+
32+/// CC1101 bus + control pins (SPI3). Consumed in Phase 4.
33+pub struct Cc1101Pins {
34+ pub sck: AnyIOPin<'static>,
35+ pub miso: AnyIOPin<'static>,
36+ pub mosi: AnyIOPin<'static>,
37+ pub cs: AnyIOPin<'static>,
38+ pub gdo0: AnyIOPin<'static>,
39+ pub gdo2: AnyIOPin<'static>,
40+}
41+
42+/// Owns the peripherals and bus handles not already consumed by the display and
43+/// buttons. Passed `&mut` into every app via `AppCtx`.
44+pub struct Board {
45+ /// Shared I2C0 bus (OLED is live; PN532 joins in Phase 4).
46+ pub i2c: SharedI2c,
47+ /// WiFi/BLE radio — acquired/released by one app at a time (Phase 2/3).
48+ pub modem: Modem<'static>,
49+ /// SPI2 host for the two NRF24 radios (Phase 4).
50+ pub spi2: SPI2<'static>,
51+ /// SPI3 host for the CC1101 (Phase 4).
52+ pub spi3: SPI3<'static>,
53+ pub nrf: NrfPins,
54+ pub cc1101: Cc1101Pins,
55+}
M src/board/mod.rs View
@@ -1 +1,2 @@
1+pub mod board;
12 pub mod pins;
M src/drivers/buttons.rs View
@@ -0,0 +1,75 @@
1+//! Six momentary buttons, active-low with internal pull-ups.
2+//!
3+//! Parity reference: `src-arduino/utils/buttons.cpp`. Arduino uses
4+//! `pinMode(INPUT_PULLUP)` and reports pressed as `!digitalRead(pin)`, i.e. a
5+//! pressed button reads LOW. We mirror that: `get_level() == Level::Low`.
6+//!
7+//! Debounce (the Arduino `delay(150)` after each handled press) lives at the
8+//! call site via [`debounce`], matching the original per-action delays.
9+
10+use esp_idf_hal::delay::FreeRtos;
11+use esp_idf_hal::gpio::{AnyIOPin, Input, Level, PinDriver, Pull};
12+
13+/// The ~150ms debounce the Arduino firmware applies after each handled press.
14+pub const DEBOUNCE_MS: u32 = 150;
15+
16+/// Block for the standard debounce interval after acting on a press.
17+pub fn debounce() {
18+ FreeRtos::delay_ms(DEBOUNCE_MS);
19+}
20+
21+type Btn = PinDriver<'static, Input>;
22+
23+pub struct Buttons {
24+ up: Btn,
25+ down: Btn,
26+ select: Btn,
27+ back: Btn,
28+ right: Btn,
29+ left: Btn,
30+}
31+
32+impl Buttons {
33+ /// Configure the six pins as pulled-up inputs. Order matches the physical
34+ /// labels, not GPIO numbers (see `board::pins`).
35+ pub fn new(
36+ up: AnyIOPin<'static>,
37+ down: AnyIOPin<'static>,
38+ select: AnyIOPin<'static>,
39+ back: AnyIOPin<'static>,
40+ right: AnyIOPin<'static>,
41+ left: AnyIOPin<'static>,
42+ ) -> Self {
43+ Self {
44+ up: Self::input(up),
45+ down: Self::input(down),
46+ select: Self::input(select),
47+ back: Self::input(back),
48+ right: Self::input(right),
49+ left: Self::input(left),
50+ }
51+ }
52+
53+ fn input(pin: AnyIOPin<'static>) -> Btn {
54+ PinDriver::input(pin, Pull::Up).expect("button input")
55+ }
56+
57+ pub fn up(&self) -> bool {
58+ self.up.get_level() == Level::Low
59+ }
60+ pub fn down(&self) -> bool {
61+ self.down.get_level() == Level::Low
62+ }
63+ pub fn select(&self) -> bool {
64+ self.select.get_level() == Level::Low
65+ }
66+ pub fn back(&self) -> bool {
67+ self.back.get_level() == Level::Low
68+ }
69+ pub fn right(&self) -> bool {
70+ self.right.get_level() == Level::Low
71+ }
72+ pub fn left(&self) -> bool {
73+ self.left.get_level() == Level::Low
74+ }
75+}
M src/drivers/display.rs View
@@ -1,18 +1,94 @@
1−use crate::board::pins::*;
1+//! SSD1306 128x64 OLED over the shared I2C0 bus.
2+//!
3+//! Parity reference: `src-arduino/ui/display.cpp` (u8g2 `..._F_HW_I2C`, font
4+//! `u8g2_font_6x12_tr`). We approximate u8g2 with `embedded-graphics`
5+//! `MonoTextStyle` + `FONT_6X10`, keeping the Arduino draw coordinates so menu
6+//! layout matches 1:1. Drawing uses u8g2-style baseline coordinates
7+//! (`Baseline::Alphabetic`): the given `y` is the text baseline, as in
8+//! `u8g2.drawStr(x, y, ...)`.
9+
10+use embedded_graphics::{
11+ mono_font::{
12+ ascii::{FONT_6X10, FONT_9X18_BOLD},
13+ MonoFont, MonoTextStyle,
14+ },
15+ pixelcolor::BinaryColor,
16+ prelude::*,
17+ primitives::{PrimitiveStyle, Rectangle},
18+ text::{Baseline, Text},
19+};
20+use embedded_hal_bus::i2c::RefCellDevice;
21+use esp_idf_hal::i2c::I2cDriver;
22+use ssd1306::{
23+ mode::{BufferedGraphicsMode, DisplayConfig},
24+ prelude::*,
25+ I2CDisplayInterface, Ssd1306,
26+};
27+
28+use crate::board::board::SharedI2c;
29+
30+/// The I2C device the OLED talks over: a `RefCellDevice` sharing the `'static`
31+/// bus so PN532 can later take a second device on the same `RefCell`.
32+type OledI2c = RefCellDevice<'static, I2cDriver<'static>>;
33+
34+type Oled =
35+ Ssd1306<I2CInterface<OledI2c>, DisplaySize128x64, BufferedGraphicsMode<DisplaySize128x64>>;
236
337 pub struct Display {
38+ raw: Oled,
439 }
540
641 impl Display {
7− pub fn new() -> Self {
8− Self {}
42+ /// Build + initialise the SSD1306 over the shared bus and clear the screen.
43+ pub fn new(bus: SharedI2c) -> Self {
44+ let dev = RefCellDevice::new(bus);
45+ let iface = I2CDisplayInterface::new(dev);
46+ let mut raw = Ssd1306::new(iface, DisplaySize128x64, DisplayRotation::Rotate0)
47+ .into_buffered_graphics_mode();
48+ raw.init().expect("ssd1306 init");
49+
50+ let mut display = Self { raw };
51+ display.clear();
52+ display.flush();
53+ display
54+ }
55+
56+ /// Clear the off-screen buffer (does not touch the panel until `flush`).
57+ pub fn clear(&mut self) {
58+ self.raw.clear_buffer();
59+ }
60+
61+ /// Push the off-screen buffer to the panel (u8g2 `sendBuffer`).
62+ pub fn flush(&mut self) {
63+ let _ = self.raw.flush();
64+ }
65+
66+ /// Normal text: foreground pixels on, baseline at `y`.
67+ pub fn text(&mut self, x: i32, y: i32, s: &str) {
68+ self.draw_text(x, y, s, &FONT_6X10, BinaryColor::On);
69+ }
70+
71+ /// Reversed text (off pixels) for drawing over a filled box
72+ /// (u8g2 `setDrawColor(0)`).
73+ pub fn text_rev(&mut self, x: i32, y: i32, s: &str) {
74+ self.draw_text(x, y, s, &FONT_6X10, BinaryColor::Off);
75+ }
76+
77+ /// Large title text (approximates u8g2 `logisoso20`) for the splash screen.
78+ pub fn title(&mut self, x: i32, y: i32, s: &str) {
79+ self.draw_text(x, y, s, &FONT_9X18_BOLD, BinaryColor::On);
980 }
1081
11− pub fn init(&mut self) {
12− log::info!("display Initialized!!");
82+ /// Filled rectangle (u8g2 `drawBox`).
83+ pub fn fill_rect(&mut self, x: i32, y: i32, w: u32, h: u32) {
84+ let _ = Rectangle::new(Point::new(x, y), Size::new(w, h))
85+ .into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
86+ .draw(&mut self.raw);
1387 }
1488
15− pub fn draw_text(&mut self, text: &str) {
16− log::info!("{}", text);
89+ fn draw_text(&mut self, x: i32, y: i32, s: &str, font: &MonoFont<'static>, color: BinaryColor) {
90+ let style = MonoTextStyle::new(font, color);
91+ let _ = Text::with_baseline(s, Point::new(x, y), style, Baseline::Alphabetic)
92+ .draw(&mut self.raw);
1793 }
1894 }
M src/drivers/mod.rs View
@@ -1 +1,2 @@
1+pub mod buttons;
12 pub mod display;
M src/main.rs View
@@ -1,21 +1,84 @@
1+// Scaffold phase of the Arduino -> Rust rewrite: later phases consume the
2+// reserved Board peripherals/pins, so allow the not-yet-read fields for now.
3+#![allow(dead_code)]
14
5+mod apps;
26 mod board;
37 mod drivers;
8+mod ui;
49
10+use core::cell::RefCell;
11+
12+use esp_idf_hal::i2c::{config::Config as I2cConfig, I2cDriver};
13+use esp_idf_hal::peripherals::Peripherals;
14+use esp_idf_hal::units::FromValueType;
15+
16+use board::board::{Board, Cc1101Pins, NrfPins, SharedI2c};
17+use drivers::buttons::Buttons;
518 use drivers::display::Display;
619
720 fn main() {
8− // It is necessary to call this function once. Otherwise, some patches to the runtime
9− // implemented by esp-idf-sys might not link properly. See https://github.com/esp-rs/esp-idf-template/issues/71
21+ // Required once so esp-idf-sys runtime patches link. See
22+ // https://github.com/esp-rs/esp-idf-template/issues/71
1023 esp_idf_svc::sys::link_patches();
11−
12− // Bind the log crate to the ESP Logging facilities
1324 esp_idf_svc::log::EspLogger::initialize_default();
1425
15− log::info!("ORION-RF Booted!!");
16− let mut display = Display::new();
26+ log::info!("ORION-RF booting");
27+
28+ let peripherals = Peripherals::take().expect("take peripherals");
29+ let pins = peripherals.pins;
30+
31+ // Shared I2C0 bus (OLED + PN532). Leaked to 'static: the display is on for
32+ // the whole program, so this bus never needs to be dropped or re-created.
33+ let i2c = I2cDriver::new(
34+ peripherals.i2c0,
35+ pins.gpio8, // SDA
36+ pins.gpio9, // SCL
37+ &I2cConfig::new().baudrate(400u32.kHz().into()),
38+ )
39+ .expect("i2c0 init");
40+ let i2c_bus: SharedI2c = Box::leak(Box::new(RefCell::new(i2c)));
41+
42+ let mut display = Display::new(i2c_bus);
43+
44+ // Buttons by physical label (active-low, internal pull-ups).
45+ let mut buttons = Buttons::new(
46+ pins.gpio4.degrade_input_output(), // UP
47+ pins.gpio5.degrade_input_output(), // DOWN
48+ pins.gpio6.degrade_input_output(), // SELECT
49+ pins.gpio7.degrade_input_output(), // BACK
50+ pins.gpio1.degrade_input_output(), // RIGHT
51+ pins.gpio2.degrade_input_output(), // LEFT
52+ );
53+
54+ // Everything the radio/NFC phases will consume, owned up front.
55+ let mut board = Board {
56+ i2c: i2c_bus,
57+ modem: peripherals.modem,
58+ spi2: peripherals.spi2,
59+ spi3: peripherals.spi3,
60+ nrf: NrfPins {
61+ sck: pins.gpio18.degrade_input_output(),
62+ miso: pins.gpio16.degrade_input_output(),
63+ mosi: pins.gpio17.degrade_input_output(),
64+ ce1: pins.gpio10.degrade_input_output(),
65+ csn1: pins.gpio11.degrade_input_output(),
66+ ce2: pins.gpio12.degrade_input_output(),
67+ csn2: pins.gpio13.degrade_input_output(),
68+ },
69+ cc1101: Cc1101Pins {
70+ sck: pins.gpio15.degrade_input_output(),
71+ miso: pins.gpio41.degrade_input_output(),
72+ mosi: pins.gpio35.degrade_input_output(),
73+ cs: pins.gpio40.degrade_input_output(),
74+ gdo0: pins.gpio39.degrade_input_output(),
75+ gdo2: pins.gpio42.degrade_input_output(),
76+ },
77+ };
1778
18− display.init();
19− display.draw_text("ORION-RF");
79+ ui::widgets::splash(&mut display);
80+ esp_idf_hal::delay::FreeRtos::delay_ms(1500);
2081
82+ log::info!("ORION-RF ready");
83+ ui::menu::run(&mut display, &mut buttons, &mut board);
2184 }
A src/ui/menu.rs View
@@ -0,0 +1,242 @@
1+//! Scrolling menu + submenu navigation and feature dispatch.
2+//!
3+//! Parity reference: `src-arduino/ui/menu.cpp`. Four visible rows, a `>`
4+//! cursor, `^`/`v` scroll indicators, wrap-around up/down, and the same item
5+//! order. Selecting BadUSB or NRF Tools enters a submenu; BACK from a submenu
6+//! returns to the main menu. Restart shows a YES/NO confirm dialog.
7+//!
8+//! Phase 1 dispatches every feature except the submenu entries and Restart to
9+//! [`StubApp`]; later phases swap in the real apps.
10+
11+use esp_idf_hal::delay::FreeRtos;
12+
13+use crate::apps::{App, AppCtx, StubApp};
14+use crate::board::board::Board;
15+use crate::drivers::buttons::{debounce, Buttons};
16+use crate::drivers::display::Display;
17+
18+const VISIBLE_ROWS: usize = 4;
19+
20+const MAIN: &[&str] = &[
21+ "BadUSB",
22+ "RF Capture",
23+ "NRF Tools",
24+ "BLE Scan",
25+ "Wifi Scan",
26+ "Wifi Analyzer",
27+ "System Info",
28+ "Device Check",
29+ "Restart",
30+ "Ble Mouse",
31+ "NFC Tools",
32+];
33+
34+const NRF_TOOLS: &[&str] = &[
35+ "BLE Jammer",
36+ "Bluetooth Jammer",
37+ "Wifi Jammer",
38+ "USB Wireless",
39+ "Video TX",
40+ "Zigbee",
41+ "RC",
42+];
43+
44+const BADUSB: &[&str] = &[
45+ "ORION Demo",
46+ "RickRoll",
47+ "Matrix Rain",
48+ "Fake Terminal",
49+ "Wifi Password Extractor",
50+ "Fake Update",
51+ "Fake BSOD",
52+ "Glitch Screen",
53+ "ASCII Spam",
54+ "Hacker Typer",
55+ "PWN Reverse shell",
56+ "Phishing attack",
57+ "Desktop Ghost",
58+ "System Stresser",
59+];
60+
61+#[derive(Clone, Copy, PartialEq)]
62+enum Screen {
63+ Main,
64+ Nrf,
65+ Badusb,
66+}
67+
68+struct Nav {
69+ screen: Screen,
70+ index: usize,
71+ offset: usize,
72+}
73+
74+impl Nav {
75+ fn items(&self) -> &'static [&'static str] {
76+ match self.screen {
77+ Screen::Main => MAIN,
78+ Screen::Nrf => NRF_TOOLS,
79+ Screen::Badusb => BADUSB,
80+ }
81+ }
82+
83+ fn enter(&mut self, screen: Screen) {
84+ self.screen = screen;
85+ self.index = 0;
86+ self.offset = 0;
87+ }
88+}
89+
90+/// Run the menu forever. This is the main loop of the device.
91+pub fn run(display: &mut Display, buttons: &mut Buttons, board: &mut Board) -> ! {
92+ let mut nav = Nav {
93+ screen: Screen::Main,
94+ index: 0,
95+ offset: 0,
96+ };
97+ draw(display, &mut nav);
98+
99+ loop {
100+ if buttons.up() {
101+ let len = nav.items().len();
102+ nav.index = if nav.index == 0 { len - 1 } else { nav.index - 1 };
103+ draw(display, &mut nav);
104+ debounce();
105+ } else if buttons.down() {
106+ nav.index += 1;
107+ if nav.index >= nav.items().len() {
108+ nav.index = 0;
109+ }
110+ draw(display, &mut nav);
111+ debounce();
112+ } else if buttons.select() {
113+ debounce();
114+ launch(display, buttons, board, &mut nav);
115+ draw(display, &mut nav);
116+ } else if buttons.back() {
117+ if nav.screen != Screen::Main {
118+ nav.enter(Screen::Main);
119+ draw(display, &mut nav);
120+ }
121+ debounce();
122+ } else {
123+ FreeRtos::delay_ms(20);
124+ }
125+ }
126+}
127+
128+/// Draw the current menu with scroll handling (parity: `drawMenu`).
129+fn draw(display: &mut Display, nav: &mut Nav) {
130+ let items = nav.items();
131+
132+ if nav.index < nav.offset {
133+ nav.offset = nav.index;
134+ }
135+ if nav.index >= nav.offset + VISIBLE_ROWS {
136+ nav.offset = nav.index - VISIBLE_ROWS + 1;
137+ }
138+
139+ display.clear();
140+
141+ for i in 0..VISIBLE_ROWS {
142+ let item = nav.offset + i;
143+ if item >= items.len() {
144+ break;
145+ }
146+ let y = 14 + i as i32 * 14;
147+ if item == nav.index {
148+ display.text(0, y, ">");
149+ }
150+ display.text(10, y, items[item]);
151+ }
152+
153+ if nav.offset > 0 {
154+ display.text(118, 10, "^");
155+ }
156+ if nav.offset + VISIBLE_ROWS < items.len() {
157+ display.text(118, 62, "v");
158+ }
159+
160+ display.flush();
161+}
162+
163+/// Act on SELECT in the current screen (parity: `launchFeature`).
164+fn launch(display: &mut Display, buttons: &mut Buttons, board: &mut Board, nav: &mut Nav) {
165+ match nav.screen {
166+ Screen::Main => match nav.index {
167+ 0 => nav.enter(Screen::Badusb),
168+ 2 => nav.enter(Screen::Nrf),
169+ 8 => confirm_restart(display, buttons),
170+ other => run_stub(display, buttons, board, MAIN[other]),
171+ },
172+ Screen::Nrf => run_stub(display, buttons, board, NRF_TOOLS[nav.index]),
173+ Screen::Badusb => run_stub(display, buttons, board, BADUSB[nav.index]),
174+ }
175+}
176+
177+fn run_stub(display: &mut Display, buttons: &mut Buttons, board: &mut Board, name: &'static str) {
178+ let mut app = StubApp { name };
179+ let mut ctx = AppCtx {
180+ display,
181+ buttons,
182+ board,
183+ };
184+ let _ = app.run(&mut ctx);
185+}
186+
187+/// YES/NO restart confirmation (parity: `menu.cpp` case 8).
188+fn confirm_restart(d: &mut Display, b: &mut Buttons) {
189+ // Wait for the SELECT that opened this dialog to be released.
190+ FreeRtos::delay_ms(200);
191+ while b.select() {
192+ FreeRtos::delay_ms(10);
193+ }
194+
195+ let mut confirm = false;
196+
197+ loop {
198+ d.clear();
199+ d.text(18, 18, "Restart Device?");
200+
201+ if confirm {
202+ d.fill_rect(10, 35, 45, 15);
203+ d.text_rev(20, 47, "YES");
204+ d.text(75, 47, "NO");
205+ } else {
206+ d.text(20, 47, "YES");
207+ d.fill_rect(65, 35, 45, 15);
208+ d.text_rev(78, 47, "NO");
209+ }
210+
211+ d.flush();
212+
213+ if b.left() || b.up() {
214+ confirm = true;
215+ debounce();
216+ }
217+ if b.right() || b.down() {
218+ confirm = false;
219+ debounce();
220+ }
221+
222+ if b.select() {
223+ debounce();
224+ if confirm {
225+ d.clear();
226+ d.text(28, 30, "Restarting...");
227+ d.flush();
228+ FreeRtos::delay_ms(1000);
229+ esp_idf_hal::reset::restart();
230+ } else {
231+ break;
232+ }
233+ }
234+
235+ if b.back() {
236+ debounce();
237+ break;
238+ }
239+
240+ FreeRtos::delay_ms(10);
241+ }
242+}
A src/ui/mod.rs View
@@ -0,0 +1,2 @@
1+pub mod menu;
2+pub mod widgets;
A src/ui/widgets.rs View
@@ -0,0 +1,23 @@
1+//! Reusable screens drawn with the [`Display`] primitives.
2+//!
3+//! Coordinates mirror the Arduino reference so the ported UI looks the same.
4+
5+use crate::drivers::display::Display;
6+
7+/// Boot splash (parity: `src-arduino/main.cpp` `showSplash`).
8+pub fn splash(d: &mut Display) {
9+ d.clear();
10+ d.title(10, 40, "Orion-RF");
11+ d.text(25, 58, "Initializing...");
12+ d.flush();
13+}
14+
15+/// Placeholder screen for features not yet ported. Replaced by the real app in
16+/// its phase; until then every unimplemented menu entry lands here.
17+pub fn stub(d: &mut Display, name: &str) {
18+ d.clear();
19+ d.text(0, 14, name);
20+ d.text(0, 34, "Not implemented");
21+ d.text(0, 54, "BACK to return");
22+ d.flush();
23+}