Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ pub trait MaraUiApp {
/// Drain app-generated MQTT outbound messages
fn take_outbound_mqtt_messages(&mut self) -> Vec<MqttOutboundMessage>;

/// 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);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -163,8 +171,8 @@ impl MaraUiApp for MaraUi {
D: DrawTarget<Color = Rgb565> + 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 {
Expand Down
20 changes: 6 additions & 14 deletions src/setup.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -192,31 +191,18 @@ fn run_app_hardware(mut app: impl MaraUiApp) {
let mut last_wifi_check_at: Option<Instant> = None;
let mut wifi_reconnect_at: Option<Instant> = 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 });
Expand Down Expand Up @@ -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
Expand Down
28 changes: 5 additions & 23 deletions src/setup_simulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,21 @@ fn run_app_simulator(mut app: impl MaraUiApp) {
let boot_time = Instant::now();
let mut last_status_at: Option<Instant> = 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| {
app.draw(f);
})
.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),
Expand All @@ -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 {
Expand All @@ -140,7 +123,6 @@ fn run_app_simulator(mut app: impl MaraUiApp) {
});
}
Keycode::D => {
terminal.clear().unwrap();
app.handle_press(Button::Button1(ButtonPressType::Long));
}
_ => {}
Expand Down
79 changes: 78 additions & 1 deletion src/state/fsm.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -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 } => {
Expand Down Expand Up @@ -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
{
Expand All @@ -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);

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -388,13 +414,64 @@ 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));

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();
Expand Down
29 changes: 29 additions & 0 deletions src/state/global_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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)]
Expand Down
17 changes: 17 additions & 0 deletions src/telemetry/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading