pages-tui working

This commit is contained in:
Priec
2026-01-18 15:23:04 +01:00
parent 273bf2f946
commit c5fef061f4
8 changed files with 343 additions and 117 deletions

View File

@@ -1073,9 +1073,9 @@ dependencies = [
[[package]]
name = "heapless"
version = "0.9.1"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1edcd5a338e64688fbdcb7531a846cfd3476a54784dcb918a0844682bc7ada5"
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
dependencies = [
"hash32",
"stable_deref_trait",
@@ -1305,6 +1305,13 @@ dependencies = [
"autocfg",
]
[[package]]
name = "pages-tui"
version = "0.1.0"
dependencies = [
"heapless 0.9.2",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -1452,9 +1459,10 @@ dependencies = [
"esp-hal-embassy",
"esp-println",
"esp-wifi",
"heapless 0.9.1",
"heapless 0.9.2",
"log",
"mousefood",
"pages-tui",
"ratatui",
"rust-mqtt",
"smoltcp",

View File

@@ -9,6 +9,7 @@ name = "projekt_final"
path = "./src/bin/main.rs"
[dependencies]
pages-tui = { path = "../../../pages-tui", default-features = false }
esp-bootloader-esp-idf = { version = "0.2.0", features = ["esp32"] }
esp-hal = { version = "=1.0.0-rc.0", features = [
"esp32",

View File

@@ -9,7 +9,7 @@
// TODO WARNING core 1 should be logic, core 0 wifi, its flipped now
use embassy_executor::Spawner;
use embassy_futures::select::{select3, Either3};
use embassy_futures::select::{select, Either, select3, Either3};
use embassy_net::{Runner, StackResources};
use embassy_sync::signal::Signal;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
@@ -20,7 +20,9 @@ use esp_alloc as _;
use esp_backtrace as _;
use esp_hal::{
gpio::InputConfig,
clock::CpuClock,
gpio::{Input, Pull},
i2c::master::{Config as I2cConfig, I2c},
rng::Rng,
system::{CpuControl, Stack},
@@ -31,6 +33,7 @@ use esp_wifi::{
EspWifiController,
};
use pages_tui::input::Key;
use log::info;
use rust_mqtt::packet::v5::publish_packet::QualityOfService;
use static_cell::StaticCell;
@@ -122,6 +125,11 @@ async fn main(spawner: Spawner) -> ! {
NETWORK_READY.wait().await;
info!("Network ready, starting core 0 tasks");
let config = InputConfig::default().with_pull(Pull::Down);
let button_select = Input::new(peripherals.GPIO32, config);
let button_next = Input::new(peripherals.GPIO35, config);
spawner.spawn(button_detection_task(button_select, button_next)).unwrap();
// Core 0: display and MPU tasks
spawner.spawn(display::task::display_task(display_i2c)).expect("spawn display_task");
spawner.spawn(mpu::task::mpu_task(mpu_i2c)).expect("spawn mpu_task");
@@ -248,3 +256,25 @@ async fn connection_task(mut controller: WifiController<'static>) {
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
runner.run().await
}
#[embassy_executor::task]
async fn button_detection_task(mut select_btn: Input<'static>, mut next_btn: Input<'static>) {
use embassy_futures::select::Either;
loop {
match select(
select_btn.wait_for_rising_edge(),
next_btn.wait_for_rising_edge(),
).await {
Either::First(_) => {
info!("Detection: GPIO 32 (Select) triggered!");
display::api::push_key(Key::enter()).await;
}
Either::Second(_) => {
info!("Detection: GPIO 35 (Next) triggered!");
display::api::push_key(Key::tab()).await
}
}
// Debounce: prevent mechanical bouncing from double-triggering
embassy_time::Timer::after(embassy_time::Duration::from_millis(200)).await;
}
}

View File

@@ -5,6 +5,7 @@
//! Features depend on these types, not on each other.
use heapless::String as HString;
use pages_tui::input::Key;
/// IMU sensor reading from MPU6050
#[derive(Clone, Copy, Default, Debug)]
@@ -32,4 +33,6 @@ pub enum DisplayCommand {
SetMqttStatus { connected: bool, msg_count: u32 },
/// Clear the display to default state
Clear,
PushKey(Key),
}

View File

@@ -9,6 +9,7 @@ use embassy_sync::channel::{Channel, Receiver, TrySendError};
use heapless::String;
use crate::contracts::{DisplayCommand, ImuReading};
use pages_tui::input::Key;
/// Queue size for display commands.
/// Moderate size to handle bursts without dropping.
@@ -79,3 +80,7 @@ pub async fn set_mqtt_status(connected: bool, msg_count: u32) {
pub async fn clear() {
send(DisplayCommand::Clear).await;
}
pub async fn push_key(key: Key) {
send(DisplayCommand::PushKey(key)).await;
}

View File

@@ -3,3 +3,4 @@
pub mod api;
pub mod task;
pub mod tui;

View File

@@ -1,84 +1,24 @@
// src/display/task.rs
//! SSD1306 display rendering task optimized for 0.91" 128x32 OLED.
use embassy_time::{Duration, Timer};
use log::{error, info};
use alloc::boxed::Box;
use alloc::format;
use heapless::String;
use mousefood::{EmbeddedBackend, EmbeddedBackendConfig};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Style, Stylize},
widgets::Paragraph,
Terminal,
};
use ssd1306::{
mode::BufferedGraphicsMode, prelude::*, I2CDisplayInterface, Ssd1306,
};
use ratatui::Terminal;
use ssd1306::{mode::BufferedGraphicsMode, prelude::*, I2CDisplayInterface, Ssd1306};
use crate::bus::I2cDevice;
use crate::contracts::{DisplayCommand, ImuReading};
use crate::display::api::receiver;
use crate::display::tui::{render_frame, next_page_id, prev_page_id, DisplayState, Screen, ScreenEvent};
use crate::contracts::DisplayCommand;
use pages_tui::prelude::*;
/// Display refresh interval in milliseconds.
const REFRESH_INTERVAL_MS: u64 = 100;
/// Internal state for what to render.
struct DisplayState {
status: String<32>,
last_imu: Option<ImuReading>,
last_error: Option<String<64>>,
mqtt_connected: bool,
mqtt_msg_count: u32,
}
impl Default for DisplayState {
fn default() -> Self {
Self {
status: String::new(),
last_imu: None,
last_error: None,
mqtt_connected: false,
mqtt_msg_count: 0,
}
}
}
impl DisplayState {
fn apply_command(&mut self, cmd: DisplayCommand) {
match cmd {
DisplayCommand::SetImu(reading) => {
self.last_imu = Some(reading);
self.last_error = None;
}
DisplayCommand::SetStatus(s) => {
self.status = s;
}
DisplayCommand::ShowError(e) => {
self.last_error = Some(e);
}
DisplayCommand::SetMqttStatus { connected, msg_count } => {
self.mqtt_connected = connected;
self.mqtt_msg_count = msg_count;
}
DisplayCommand::Clear => {
self.last_imu = None;
self.last_error = None;
self.status = String::new();
}
}
}
}
/// The display rendering task.
/// Designed for 0.91" 128x32 slim OLED screen.
#[embassy_executor::task]
pub async fn display_task(i2c: I2cDevice) {
info!("Display task starting...");
// Initialize SSD1306 display for 128x32 variant
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306::new(interface, DisplaySize128x32, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
@@ -90,9 +30,6 @@ pub async fn display_task(i2c: I2cDevice) {
}
}
info!("SSD1306 display initialized (128x32)");
// Configure mousefood backend for ratatui
let config = EmbeddedBackendConfig {
flush_callback: Box::new(
|d: &mut Ssd1306<_, _, BufferedGraphicsMode<DisplaySize128x32>>| {
@@ -106,60 +43,68 @@ pub async fn display_task(i2c: I2cDevice) {
let mut terminal = Terminal::new(backend).expect("terminal init failed");
let mut state = DisplayState::default();
let mut orchestrator = Orchestrator::<Screen>::new();
let rx = receiver();
info!("Display task entering render loop");
// Register all pages
orchestrator.register_page("imu".into(), Screen::Imu);
orchestrator.register_page("mqtt".into(), Screen::Mqtt);
orchestrator.register_page("status".into(), Screen::Status);
// Key bindings:
// - Tab (Next button): cycle focus between Prev/Next buttons
// - Enter (Select button): trigger the focused button's action
orchestrator.bind(Key::tab(), ComponentAction::Next);
orchestrator.bind(Key::enter(), ComponentAction::Select);
// Navigate to initial page
let _ = orchestrator.navigate_to("imu".into());
loop {
// Process all pending commands (non-blocking)
while let Ok(cmd) = rx.try_receive() {
state.apply_command(cmd);
match cmd {
DisplayCommand::PushKey(key) => {
if key == Key::tab() {
// Cycle focus between buttons
orchestrator.focus_manager_mut().wrap_next();
info!("Focus changed to: {:?}", orchestrator.focus_manager().current());
} else if key == Key::enter() {
// Process Select - this triggers Component::handle()
let events = orchestrator.process_frame(key);
// Handle navigation events
if let Ok(events) = events {
for event in events {
match event {
ScreenEvent::NavigatePrev => {
if let Some(current) = orchestrator.current_id() {
let prev = prev_page_id(current.as_str());
info!("Navigating to prev: {}", prev);
let _ = orchestrator.navigate_to(prev.into());
}
}
ScreenEvent::NavigateNext => {
if let Some(current) = orchestrator.current_id() {
let next = next_page_id(current.as_str());
info!("Navigating to next: {}", next);
let _ = orchestrator.navigate_to(next.into());
}
}
}
}
}
}
}
_ => state.apply_command(cmd),
}
}
// Render current state
render_frame(&mut terminal, &state);
if let Some(current_screen) = orchestrator.current() {
let focused = orchestrator.focus_manager().current();
render_frame(&mut terminal, current_screen, focused, &state);
}
// Wait before next refresh
Timer::after(Duration::from_millis(REFRESH_INTERVAL_MS)).await;
}
}
/// Render a single frame compactly (for 128x32 OLED)
fn render_frame<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, state: &DisplayState) {
let _ = terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
])
.split(f.area());
// Header: condensed status + MQTT indicator
let mqtt_indicator = if state.mqtt_connected { "M" } else { "m" };
let header_title = format!(
"[{}] {} #{}",
mqtt_indicator,
state.status.as_str(),
state.mqtt_msg_count
);
f.render_widget(Paragraph::new(header_title).style(Style::default().reversed()), chunks[0]);
// Body: minimal content (no borders, short text)
let body_content = if let Some(ref err) = state.last_error {
format!("ERR: {}", err.as_str())
} else if let Some(ref imu) = state.last_imu {
format!(
"A:{:.1} {:.1} {:.1}\nG:{:.0} {:.0} {:.0}\nT:{:.1}C",
imu.accel_g[0], imu.accel_g[1], imu.accel_g[2],
imu.gyro_dps[0], imu.gyro_dps[1], imu.gyro_dps[2],
imu.temp_c
)
} else {
format!("Waiting for data...")
};
f.render_widget(Paragraph::new(body_content), chunks[1]);
});
}

View File

@@ -0,0 +1,233 @@
// src/display/tui.rs
use crate::contracts::{DisplayCommand, ImuReading};
use alloc::format;
use heapless::String as HString;
use pages_tui::prelude::*;
use ratatui::{
layout::{Constraint, Direction, Layout},
style::Stylize,
widgets::Paragraph,
Terminal,
};
use log::info;
/// Focusable buttons available on every page
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PageButton {
Prev,
Next,
}
impl FocusId for PageButton {}
/// All available buttons for iteration
const PAGE_BUTTONS: &[PageButton] = &[PageButton::Prev, PageButton::Next];
/// Display state shared between pages
pub struct DisplayState {
pub(crate) status: HString<32>,
pub(crate) last_imu: Option<ImuReading>,
pub(crate) last_error: Option<HString<64>>,
pub(crate) mqtt_connected: bool,
pub(crate) mqtt_msg_count: u32,
}
impl Default for DisplayState {
fn default() -> Self {
Self {
status: HString::new(),
last_imu: None,
last_error: None,
mqtt_connected: false,
mqtt_msg_count: 0,
}
}
}
impl DisplayState {
pub fn apply_command(&mut self, cmd: DisplayCommand) {
match cmd {
DisplayCommand::SetImu(reading) => {
self.last_imu = Some(reading);
self.last_error = None;
}
DisplayCommand::SetStatus(s) => self.status = s,
DisplayCommand::ShowError(e) => self.last_error = Some(e),
DisplayCommand::SetMqttStatus { connected, msg_count } => {
self.mqtt_connected = connected;
self.mqtt_msg_count = msg_count;
}
DisplayCommand::Clear => {
self.last_imu = None;
self.last_error = None;
self.status = HString::new();
}
DisplayCommand::PushKey(_) => {}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Screen {
Imu,
Mqtt,
Status,
}
/// Events emitted by screens for navigation
#[derive(Clone, Debug)]
pub enum ScreenEvent {
NavigatePrev,
NavigateNext,
}
impl Component for Screen {
type Action = ComponentAction;
type Event = ScreenEvent;
type Focus = PageButton;
fn handle(
&mut self,
focus: &Self::Focus,
action: Self::Action,
) -> Result<Option<Self::Event>, ComponentError> {
match action {
ComponentAction::Select => {
info!("Select on {:?} screen, focused: {:?}", self, focus);
// When Select is pressed, trigger navigation based on focused button
match focus {
PageButton::Prev => return Ok(Some(ScreenEvent::NavigatePrev)),
PageButton::Next => return Ok(Some(ScreenEvent::NavigateNext)),
}
}
_ => {}
}
Ok(None)
}
fn targets(&self) -> &[Self::Focus] {
// Every page has Prev and Next buttons
PAGE_BUTTONS
}
fn on_enter(&mut self) -> Result<(), ComponentError> {
info!("Entered screen: {:?}", self);
Ok(())
}
fn on_exit(&mut self) -> Result<(), ComponentError> {
info!("Exited screen: {:?}", self);
Ok(())
}
}
// ============ PAGE ORDER ============
const PAGE_ORDER: &[&str] = &["imu", "mqtt", "status"];
pub fn next_page_id(current: &str) -> &'static str {
let idx = PAGE_ORDER.iter().position(|&p| p == current).unwrap_or(0);
PAGE_ORDER[(idx + 1) % PAGE_ORDER.len()]
}
pub fn prev_page_id(current: &str) -> &'static str {
let idx = PAGE_ORDER.iter().position(|&p| p == current).unwrap_or(0);
if idx == 0 {
PAGE_ORDER[PAGE_ORDER.len() - 1]
} else {
PAGE_ORDER[idx - 1]
}
}
// ============ RENDERING ============
fn render_imu_content(state: &DisplayState) -> alloc::string::String {
if let Some(ref imu) = state.last_imu {
format!(
"G:{:.0} {:.0} {:.0}\nT:{:.1}C",
imu.gyro_dps[0], imu.gyro_dps[1], imu.gyro_dps[2], imu.temp_c
)
} else {
format!("Waiting IMU...")
}
}
fn render_mqtt_content(state: &DisplayState) -> alloc::string::String {
format!(
"MQTT:{}\nMsg:{}",
if state.mqtt_connected { "ON" } else { "OFF" },
state.mqtt_msg_count
)
}
fn render_status_content(state: &DisplayState) -> alloc::string::String {
format!("Status:\n{}", state.status.as_str())
}
fn render_button(label: &str, focused: bool) -> Paragraph<'static> {
let text = if focused {
format!("[>{}]", label)
} else {
format!(" {} ", label)
};
if focused {
Paragraph::new(text).white().on_blue()
} else {
Paragraph::new(text).gray()
}
}
pub fn render_frame<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
current_screen: &Screen,
focused_button: Option<&PageButton>,
state: &DisplayState,
) {
let _ = terminal.draw(|f| {
// Handle errors with priority
if let Some(ref err) = state.last_error {
let content = format!("ERR: {}", err.as_str());
f.render_widget(Paragraph::new(content).red().bold(), f.area());
return;
}
// Layout: content area + button bar
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // Content
Constraint::Length(1), // Button bar
])
.split(f.area());
// Render page content
let content = match current_screen {
Screen::Imu => render_imu_content(state),
Screen::Mqtt => render_mqtt_content(state),
Screen::Status => render_status_content(state),
};
let style = match current_screen {
Screen::Imu => Paragraph::new(content).cyan(),
Screen::Mqtt => Paragraph::new(content).green(),
Screen::Status => Paragraph::new(content).yellow(),
};
f.render_widget(style, chunks[0]);
// Render button bar
let btn_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Percentage(50),
])
.split(chunks[1]);
let prev_focused = focused_button == Some(&PageButton::Prev);
let next_focused = focused_button == Some(&PageButton::Next);
f.render_widget(render_button("<", prev_focused), btn_chunks[0]);
f.render_widget(render_button(">", next_focused), btn_chunks[1]);
});
}