diff --git a/src/app.rs b/src/app.rs index f46e026..cef3d9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,10 @@ pub trait MaraUiApp { /// Drain app-generated MQTT outbound messages fn take_outbound_mqtt_messages(&mut self) -> Vec; + /// Returns `true` once if the render loop should clear the terminal this frame + /// (new session or Debug toggle). Consumes the request. + fn take_redraw_request(&mut self) -> bool; + /// Mirror the MQTT topic prefix into app state (call right after AppConfig::from_env) fn set_mqtt_prefix(&mut self, prefix: &str); @@ -85,10 +89,10 @@ impl MaraUiApp for MaraUi { fn draw(&self, frame: &mut Frame) { let area = frame.area(); - // Show connecting screen until first UART frame arrives. + // Show the waiting screen until the machine is online — both before the first UART + // frame and after telemetry goes stale (machine switched off). // Debug screen is always accessible for diagnostics. - if self.state.machine_state.last_frame.is_none() - && self.state.current_screen != Screen::Debug + if !self.state.machine_online(Instant::now()) && self.state.current_screen != Screen::Debug { Connecting::render(&self.state, area, frame); return; @@ -120,6 +124,10 @@ impl MaraUiApp for MaraUi { self.state.take_outbound_mqtt_messages() } + fn take_redraw_request(&mut self) -> bool { + self.state.take_redraw_request() + } + fn set_mqtt_prefix(&mut self, prefix: &str) { self.state.mqtt_topic_prefix = prefix.to_string(); } @@ -163,8 +171,8 @@ impl MaraUiApp for MaraUi { D: DrawTarget + OriginDimensions, D::Error: core::fmt::Debug, { - // Render rat barista only during connecting/loading phase - let is_connecting = self.state.machine_state.last_frame.is_none() + // Render rat barista whenever the waiting screen is shown (machine offline) + let is_connecting = !self.state.machine_online(Instant::now()) && self.state.current_screen != Screen::Debug; if !is_connecting { diff --git a/src/setup.rs b/src/setup.rs index fdb4621..7313ef1 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,7 +1,6 @@ use crate::app::MaraUiApp; use crate::button::{Button, ButtonState}; use crate::config::AppConfig; -use crate::screens::Screen; use crate::state::global_state::MqttOutboundMessage; use crate::state::{AppEvent, ConnectionStatus, DeviceInfo}; use crate::telemetry::TelemetryFrame; @@ -192,31 +191,18 @@ fn run_app_hardware(mut app: impl MaraUiApp) { let mut last_wifi_check_at: Option = None; let mut wifi_reconnect_at: Option = None; let mut wifi_was_connected = true; - let mut prev_had_telemetry = false; loop { app.tick(); button1_state.update(button1.is_low(), |press_type| { - let was_on_loading = !app.has_telemetry() && app.current_screen() != Screen::Debug; app.handle_press(Button::Button1(press_type)); - if was_on_loading && app.current_screen() == Screen::Debug { - terminal.clear().unwrap(); - } }); while let Ok(telemetry) = rx.try_recv() { app.update_telemetry(telemetry); } - // Clear terminal once when the first UART frame arrives so the loading - // screen (rat image + connecting block) is fully overwritten. - let now_has_telemetry = app.has_telemetry(); - if !prev_had_telemetry && now_has_telemetry { - terminal.clear().unwrap(); - } - prev_had_telemetry = now_has_telemetry; - if let Some(cup_counter_rx) = cup_counter_rx.as_mut() { while let Ok(cups) = cup_counter_rx.try_recv() { app.handle_event(AppEvent::CupCounterUpdated { cups }); @@ -308,6 +294,12 @@ fn run_app_hardware(mut app: impl MaraUiApp) { backlight.set_low().unwrap(); } + // Apply any pending full clear requested by the state machine (new session, Debug + // toggle) so accumulated display artifacts are wiped before the next frame. + if app.take_redraw_request() { + terminal.clear().unwrap(); + } + app.render_image(terminal.backend_mut().display_mut()); terminal diff --git a/src/setup_simulator.rs b/src/setup_simulator.rs index 14620b4..e600852 100644 --- a/src/setup_simulator.rs +++ b/src/setup_simulator.rs @@ -79,11 +79,14 @@ fn run_app_simulator(mut app: impl MaraUiApp) { let boot_time = Instant::now(); let mut last_status_at: Option = None; - let mut prev_had_telemetry = false; - loop { app.tick(); + // Apply any pending full clear requested by the state machine (new session, Debug toggle). + if app.take_redraw_request() { + terminal.clear().unwrap(); + } + app.render_image(terminal.backend_mut().display_mut()); terminal .draw(|f| { @@ -91,13 +94,6 @@ fn run_app_simulator(mut app: impl MaraUiApp) { }) .unwrap(); - // Clear terminal once when the first UART frame arrives - let now_has_telemetry = app.has_telemetry(); - if !prev_had_telemetry && now_has_telemetry { - terminal.clear().unwrap(); - } - prev_had_telemetry = now_has_telemetry; - for event in simulator_window.borrow_mut().events() { match event { SimulatorEvent::Quit => std::process::exit(0), @@ -109,29 +105,16 @@ fn run_app_simulator(mut app: impl MaraUiApp) { } match keycode { Keycode::Right | Keycode::Left => { - terminal.clear().unwrap(); app.handle_press(Button::Button1(ButtonPressType::Short)); } Keycode::Up => { - let had = app.has_telemetry(); app.update_telemetry(TelemetryFrame::debug_pump_on_frame()); - if !had && app.has_telemetry() { - terminal.clear().unwrap(); - } } Keycode::Down => { - let had = app.has_telemetry(); app.update_telemetry(TelemetryFrame::debug_frame()); - if !had && app.has_telemetry() { - terminal.clear().unwrap(); - } } Keycode::Space => { - let had = app.has_telemetry(); app.update_telemetry(TelemetryFrame::debug_no_water_frame()); - if !had && app.has_telemetry() { - terminal.clear().unwrap(); - } } Keycode::M => { app.handle_event(AppEvent::PublishMqttEvent { @@ -140,7 +123,6 @@ fn run_app_simulator(mut app: impl MaraUiApp) { }); } Keycode::D => { - terminal.clear().unwrap(); app.handle_press(Button::Button1(ButtonPressType::Long)); } _ => {} diff --git a/src/state/fsm.rs b/src/state/fsm.rs index 1a7c871..056b246 100644 --- a/src/state/fsm.rs +++ b/src/state/fsm.rs @@ -1,5 +1,6 @@ use log::{error, info}; +use super::global_state::MACHINE_OFFLINE_TIMEOUT; use super::{AppError, AppEvent, ConnectionStatus, DeviceInfo, ExtractionState, GlobalAppState}; use crate::button::{Button, ButtonPressType}; #[cfg(feature = "home-assistant")] @@ -78,6 +79,9 @@ impl AppStateMachine { state.screen_before_debug = Some(state.current_screen); state.current_screen = Screen::Debug; } + // Debug uses a full-screen layout unrelated to the normal screens; clear so + // nothing from the previous layout lingers. + state.request_redraw(); } AppEvent::ErrorOccurred { error } => { @@ -164,7 +168,16 @@ impl AppStateMachine { } // Normal operation after first UART frame arrives. - state.last_activity_at = Some(Instant::now()); + let now = Instant::now(); + let backlight_was_on = state.backlight_should_be_on(now); + state.last_activity_at = Some(now); + + // If the screen was dark (backlight timed out), the first press only wakes the + // backlight and must not also switch screens. + if !backlight_was_on { + return; + } + if let Button::Button1(ButtonPressType::Short) = button && state.current_screen != Screen::Debug { @@ -176,6 +189,18 @@ impl AppStateMachine { pub fn handle_telemetry_frame(state: &mut GlobalAppState, frame: TelemetryFrame, now: Instant) { state.enqueue_mqtt_message("telemetry", telemetry_payload(&frame)); + // A new session starts on the very first frame, or when telemetry resumes after the + // machine has been offline (long UART gap). Reset per-session state and ask the render + // loop to clear the terminal so any accumulated display artifacts are wiped. + let new_session = state + .last_uart_frame_at + .map(|t| now.saturating_duration_since(t) >= MACHINE_OFFLINE_TIMEOUT) + .unwrap_or(true); + if new_session { + state.machine_state.reset_session(); + state.request_redraw(); + } + // frame moves into update_state_with_events; it is stored in state.machine_state.last_frame let (_snapshot, events) = update_state_with_events(&mut state.machine_state, frame, now); @@ -302,6 +327,7 @@ fn telemetry_event_payload(event: &crate::telemetry::AppEvent) -> String { mod tests { use super::*; use crate::screens::Screen; + use crate::state::global_state::BACKLIGHT_TIMEOUT; use std::time::Duration; #[test] @@ -388,6 +414,8 @@ mod tests { let mut state = GlobalAppState::default(); // Navigation is blocked until first telemetry arrives state.machine_state.last_frame = Some(TelemetryFrame::debug_frame()); + // Backlight must be on for a press to switch screens + state.last_activity_at = Some(Instant::now()); let initial_screen = state.current_screen; AppStateMachine::handle_button_press(&mut state, Button::Button1(ButtonPressType::Short)); @@ -395,6 +423,55 @@ mod tests { assert_eq!(state.current_screen, initial_screen.next()); } + #[test] + fn test_press_while_dark_only_wakes_backlight() { + let mut state = GlobalAppState::default(); + state.machine_state.last_frame = Some(TelemetryFrame::debug_frame()); + // Backlight timed out (last activity well in the past) + state.last_activity_at = Some(Instant::now() - BACKLIGHT_TIMEOUT - Duration::from_secs(1)); + let initial_screen = state.current_screen; + + AppStateMachine::handle_button_press(&mut state, Button::Button1(ButtonPressType::Short)); + + // First press only wakes the backlight: screen must not change, activity refreshed + assert_eq!(state.current_screen, initial_screen); + assert!(state.backlight_should_be_on(Instant::now())); + + // Second press (backlight now on) switches screens + AppStateMachine::handle_button_press(&mut state, Button::Button1(ButtonPressType::Short)); + assert_eq!(state.current_screen, initial_screen.next()); + } + + #[test] + fn test_telemetry_resume_starts_new_session() { + let mut state = GlobalAppState::default(); + let t0 = Instant::now(); + + // First frame: a shot runs and is sampled into the graph buffers. + let on = TelemetryFrame::debug_pump_on_frame(); + AppStateMachine::handle_telemetry_frame(&mut state, on, t0); + assert!(state.take_redraw_request(), "first frame starts a session"); + assert!(!state.machine_state.current_boiler_data.is_empty()); + + // Telemetry resumes after the machine was offline: buffers reset, redraw requested, + // and no spurious shot-end event is logged from the stale pump-on frame. + let events_before = state.events_log.len(); + let resume = TelemetryFrame::debug_frame(); + AppStateMachine::handle_telemetry_frame( + &mut state, + resume, + t0 + MACHINE_OFFLINE_TIMEOUT + Duration::from_secs(1), + ); + assert!(state.take_redraw_request(), "resume starts a new session"); + // Exactly one fresh sample after the reset (no stale history) + assert_eq!(state.machine_state.current_boiler_data.len(), 1); + assert_eq!( + state.events_log.len(), + events_before, + "no phantom transition events on resume" + ); + } + #[test] fn test_handle_button_press_blocked_before_telemetry() { let mut state = GlobalAppState::default(); diff --git a/src/state/global_state.rs b/src/state/global_state.rs index 4988d1b..8876d4a 100644 --- a/src/state/global_state.rs +++ b/src/state/global_state.rs @@ -9,6 +9,12 @@ use std::{ // timeout is unnecessary — consider removing BACKLIGHT_TIMEOUT and backlight_should_be_on entirely. pub const BACKLIGHT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long telemetry may be absent before the machine is considered offline. +/// +/// Once this elapses with no UART frame, the UI falls back to the waiting screen and +/// the next frame to arrive starts a fresh session (terminal + graph buffers cleared). +pub const MACHINE_OFFLINE_TIMEOUT: Duration = Duration::from_secs(30); + #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum ConnectionStatus { Disabled, @@ -140,6 +146,9 @@ pub struct GlobalAppState { pub device_info: DeviceInfo, /// Current boot loading stage; `None` before first stage fires, frozen at 100% while waiting for machine pub loading_status: Option<(&'static str, u8)>, + /// Set by the state machine to ask the render loop for a full `terminal.clear()` + /// (new session, Debug toggle). Drained once per frame via `take_redraw_request`. + pub needs_terminal_clear: bool, } impl Default for GlobalAppState { @@ -162,6 +171,7 @@ impl Default for GlobalAppState { mqtt_topic_prefix: "mara".to_string(), device_info: DeviceInfo::default(), loading_status: None, + needs_terminal_clear: false, } } } @@ -235,6 +245,25 @@ impl GlobalAppState { None => false, } } + + /// Returns `true` while telemetry is fresh enough to consider the machine online + /// (a UART frame arrived within the last `MACHINE_OFFLINE_TIMEOUT`). + pub fn machine_online(&self, now: Instant) -> bool { + match self.last_uart_frame_at { + Some(last) => now.saturating_duration_since(last) < MACHINE_OFFLINE_TIMEOUT, + None => false, + } + } + + /// Ask the render loop to perform a full `terminal.clear()` on the next frame. + pub fn request_redraw(&mut self) { + self.needs_terminal_clear = true; + } + + /// Consume a pending redraw request, returning whether the terminal should be cleared. + pub fn take_redraw_request(&mut self) -> bool { + std::mem::take(&mut self.needs_terminal_clear) + } } #[cfg(test)] diff --git a/src/telemetry/telemetry.rs b/src/telemetry/telemetry.rs index aa6ea43..2666333 100644 --- a/src/telemetry/telemetry.rs +++ b/src/telemetry/telemetry.rs @@ -250,6 +250,23 @@ impl Default for MachineState { } impl MachineState { + /// Reset all per-session derived state so a resumed machine starts from a clean slate. + /// + /// Clears the graph buffers (so the chart restarts instead of jumping down from stale + /// data) and drops `last_frame`/`shot_started_at` so the resuming frame is treated as a + /// fresh start and cannot emit spurious mode/shot transition events. + pub fn reset_session(&mut self) { + self.last_frame = None; + self.shot_started_at = None; + self.last_graph_sample_at = None; + self.target_boiler_data.clear(); + self.current_boiler_data.clear(); + self.current_hx_data.clear(); + self.graph_boiler_current.clear(); + self.graph_boiler_target.clear(); + self.graph_hx.clear(); + } + /// Rebuild the cached graph point slices from the rolling VecDeque buffers. /// Uses clear()+extend() to reuse heap allocation after the first call. pub fn rebuild_graph_points(&mut self) {