From c5fef061f4c1884ba920ee204e36788560b96d4d Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 18 Jan 2026 15:23:04 +0100 Subject: [PATCH] pages-tui working --- mqtt_display/Cargo.lock | 14 +- mqtt_display/Cargo.toml | 1 + mqtt_display/src/bin/main.rs | 32 ++++- mqtt_display/src/contracts.rs | 3 + mqtt_display/src/display/api.rs | 5 + mqtt_display/src/display/mod.rs | 1 + mqtt_display/src/display/task.rs | 171 ++++++++--------------- mqtt_display/src/display/tui.rs | 233 +++++++++++++++++++++++++++++++ 8 files changed, 343 insertions(+), 117 deletions(-) create mode 100644 mqtt_display/src/display/tui.rs diff --git a/mqtt_display/Cargo.lock b/mqtt_display/Cargo.lock index 1d189a0..5c38fed 100644 --- a/mqtt_display/Cargo.lock +++ b/mqtt_display/Cargo.lock @@ -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", diff --git a/mqtt_display/Cargo.toml b/mqtt_display/Cargo.toml index 982c0ce..2c7365c 100644 --- a/mqtt_display/Cargo.toml +++ b/mqtt_display/Cargo.toml @@ -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", diff --git a/mqtt_display/src/bin/main.rs b/mqtt_display/src/bin/main.rs index 73a8c72..671639c 100644 --- a/mqtt_display/src/bin/main.rs +++ b/mqtt_display/src/bin/main.rs @@ -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; + } +} diff --git a/mqtt_display/src/contracts.rs b/mqtt_display/src/contracts.rs index 0733785..d676b51 100644 --- a/mqtt_display/src/contracts.rs +++ b/mqtt_display/src/contracts.rs @@ -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), } diff --git a/mqtt_display/src/display/api.rs b/mqtt_display/src/display/api.rs index 2191402..fb9128c 100644 --- a/mqtt_display/src/display/api.rs +++ b/mqtt_display/src/display/api.rs @@ -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; +} diff --git a/mqtt_display/src/display/mod.rs b/mqtt_display/src/display/mod.rs index fb82585..b4bc7c1 100644 --- a/mqtt_display/src/display/mod.rs +++ b/mqtt_display/src/display/mod.rs @@ -3,3 +3,4 @@ pub mod api; pub mod task; +pub mod tui; diff --git a/mqtt_display/src/display/task.rs b/mqtt_display/src/display/task.rs index d647ff2..a6d1f67 100644 --- a/mqtt_display/src/display/task.rs +++ b/mqtt_display/src/display/task.rs @@ -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, - last_error: Option>, - 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>| { @@ -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::::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(terminal: &mut Terminal, 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]); - }); -} diff --git a/mqtt_display/src/display/tui.rs b/mqtt_display/src/display/tui.rs new file mode 100644 index 0000000..ff8f041 --- /dev/null +++ b/mqtt_display/src/display/tui.rs @@ -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, + pub(crate) last_error: Option>, + 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, 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( + terminal: &mut Terminal, + 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]); + }); +}