From f0c9ed4e7f1c6963fbad645b3eb9df55d27ccfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kai=20B=C3=B6rnert?= Date: Mon, 27 Apr 2026 15:04:05 +0200 Subject: [PATCH] Add live log buffering support and endpoint; enhance log display functionality. --- Software/MainBoard/rust/.idea/vcs.xml | 1 - Software/MainBoard/rust/src/hal/water.rs | 20 +++- .../MainBoard/rust/src/log/interceptor.rs | 98 +++++++++++----- .../MainBoard/rust/src/webserver/get_log.rs | 29 +++++ Software/MainBoard/rust/src/webserver/mod.rs | 22 ++-- .../MainBoard/rust/src_webpack/src/api.ts | 11 ++ .../MainBoard/rust/src_webpack/src/log.html | 49 +++++++- .../MainBoard/rust/src_webpack/src/log.ts | 107 +++++++++++++++--- .../MainBoard/rust/src_webpack/src/main.ts | 3 +- 9 files changed, 274 insertions(+), 66 deletions(-) diff --git a/Software/MainBoard/rust/.idea/vcs.xml b/Software/MainBoard/rust/.idea/vcs.xml index 298f634..c2365ab 100644 --- a/Software/MainBoard/rust/.idea/vcs.xml +++ b/Software/MainBoard/rust/.idea/vcs.xml @@ -2,6 +2,5 @@ - \ No newline at end of file diff --git a/Software/MainBoard/rust/src/hal/water.rs b/Software/MainBoard/rust/src/hal/water.rs index 44036c7..5f3b457 100644 --- a/Software/MainBoard/rust/src/hal/water.rs +++ b/Software/MainBoard/rust/src/hal/water.rs @@ -88,15 +88,33 @@ impl<'a> TankSensor<'a> { //multisample should be moved to water_temperature_c let mut attempt = 1; let mut delay = Delay::new(); - self.one_wire_bus.reset(&mut delay)?; + + let presence = self.one_wire_bus.reset(&mut delay)?; + println!("OneWire: reset presence pulse = {}", presence); + if !presence { + println!("OneWire: no device responded to reset — check pull-up resistor and wiring"); + } + let mut search = DeviceSearch::new(); let mut water_temp_sensor: Option = None; + let mut devices_found = 0u8; while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? { + devices_found += 1; + println!( + "OneWire: found device #{} family=0x{:02X} addr={:02X?}", + devices_found, device.address[0], device.address + ); if device.address[0] == ds18b20::FAMILY_CODE { water_temp_sensor = Some(device); break; + } else { + println!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE); } } + if devices_found == 0 { + println!("OneWire: search found zero devices on the bus"); + } + match water_temp_sensor { Some(device) => { println!("Found one wire device: {:?}", device); diff --git a/Software/MainBoard/rust/src/log/interceptor.rs b/Software/MainBoard/rust/src/log/interceptor.rs index 62611df..adeb034 100644 --- a/Software/MainBoard/rust/src/log/interceptor.rs +++ b/Software/MainBoard/rust/src/log/interceptor.rs @@ -2,42 +2,84 @@ use alloc::string::String; use alloc::vec::Vec; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::blocking_mutex::Mutex as BlockingMutex; -use embassy_sync::mutex::Mutex; -use log::{error, LevelFilter, Log, Metadata, Record}; +use log::{LevelFilter, Log, Metadata, Record}; + +const MAX_LIVE_LOG_ENTRIES: usize = 64; + +struct LiveLogBuffer { + entries: Vec<(u64, String)>, + next_seq: u64, +} + +impl LiveLogBuffer { + const fn new() -> Self { + Self { + entries: Vec::new(), + next_seq: 0, + } + } + + fn push(&mut self, text: String) { + if self.entries.len() >= MAX_LIVE_LOG_ENTRIES { + self.entries.remove(0); + } + self.entries.push((self.next_seq, text)); + self.next_seq += 1; + } + + fn get_after(&self, after: Option) -> (Vec<(u64, String)>, bool, u64) { + let next_seq = self.next_seq; + match after { + None => (self.entries.clone(), false, next_seq), + Some(after_seq) => { + let result: Vec<_> = self.entries + .iter() + .filter(|(seq, _)| *seq > after_seq) + .cloned() + .collect(); + + // Dropped if there are entries that should exist (seq > after_seq) but + // the oldest retained entry has a higher seq than after_seq + 1. + let dropped = if next_seq > after_seq.saturating_add(1) { + if let Some((oldest_seq, _)) = self.entries.first() { + *oldest_seq > after_seq.saturating_add(1) + } else { + // Buffer empty but entries were written — all dropped + true + } + } else { + false + }; + + (result, dropped, next_seq) + } + } + } +} pub struct InterceptorLogger { - // Async mutex for start/stop capture from async context - async_capture: Mutex, - // Blocking mutex for the actual data to be used in sync log() - sync_capture: BlockingMutex>>>, + live_log: BlockingMutex>, } impl InterceptorLogger { pub const fn new() -> Self { Self { - async_capture: Mutex::new(()), - sync_capture: BlockingMutex::new(core::cell::RefCell::new(None)), + live_log: BlockingMutex::new(core::cell::RefCell::new(LiveLogBuffer::new())), } } - pub async fn start_capture(&self) { - let _guard = self.async_capture.lock().await; - self.sync_capture.lock(|capture| { - *capture.borrow_mut() = Some(Vec::new()); - }); - } - - pub async fn stop_capture(&self) -> Option> { - let _guard = self.async_capture.lock().await; - self.sync_capture - .lock(|capture| capture.borrow_mut().take()) + /// Returns (entries_after, dropped, next_seq). + /// Pass `after = None` to retrieve the entire current buffer. + /// Pass `after = Some(seq)` to retrieve only entries with seq > that value. + pub fn get_live_logs(&self, after: Option) -> (Vec<(u64, String)>, bool, u64) { + self.live_log.lock(|buf| buf.borrow().get_after(after)) } pub fn init(&'static self) { match log::set_logger(self).map(|()| log::set_max_level(LevelFilter::Info)) { Ok(()) => {} - Err(e) => { - error!("Logger already set: {}", e); + Err(_e) => { + esp_println::println!("ERROR: Logger already set"); } } } @@ -50,16 +92,14 @@ impl Log for InterceptorLogger { fn log(&self, record: &Record) { if self.enabled(record.metadata()) { - let message = alloc::format!("{}", record.args()); + let message = alloc::format!("{}: {}", record.level(), record.args()); - // Print to serial using esp_println - esp_println::println!("{}: {}", record.level(), message); + // Print to serial + esp_println::println!("{}", message); - // Capture if active - self.sync_capture.lock(|capture| { - if let Some(ref mut buffer) = *capture.borrow_mut() { - buffer.push(alloc::format!("{}: {}", record.level(), message)); - } + // Store in live log ring buffer + self.live_log.lock(|buf| { + buf.borrow_mut().push(message); }); } } diff --git a/Software/MainBoard/rust/src/webserver/get_log.rs b/Software/MainBoard/rust/src/webserver/get_log.rs index 0f403a0..1a4e504 100644 --- a/Software/MainBoard/rust/src/webserver/get_log.rs +++ b/Software/MainBoard/rust/src/webserver/get_log.rs @@ -1,7 +1,10 @@ use crate::fat_error::FatResult; use crate::log::LOG_ACCESS; +use alloc::string::String; +use alloc::vec::Vec; use edge_http::io::server::Connection; use edge_nal::io::{Read, Write}; +use serde::Serialize; pub(crate) async fn get_log( conn: &mut Connection<'_, T, N>, @@ -34,3 +37,29 @@ where conn.write_all("]".as_bytes()).await?; Ok(Some(200)) } + +#[derive(Serialize)] +struct LiveLogEntry { + seq: u64, + text: String, +} + +#[derive(Serialize)] +struct LiveLogResponse { + entries: Vec, + dropped: bool, + next_seq: u64, +} + +pub(crate) async fn get_live_log(after: Option) -> FatResult> { + let (raw_entries, dropped, next_seq) = crate::log::INTERCEPTOR.get_live_logs(after); + let response = LiveLogResponse { + entries: raw_entries + .into_iter() + .map(|(seq, text)| LiveLogEntry { seq, text }) + .collect(), + dropped, + next_seq, + }; + Ok(Some(serde_json::to_string(&response)?)) +} diff --git a/Software/MainBoard/rust/src/webserver/mod.rs b/Software/MainBoard/rust/src/webserver/mod.rs index ea82a3c..5bb31e2 100644 --- a/Software/MainBoard/rust/src/webserver/mod.rs +++ b/Software/MainBoard/rust/src/webserver/mod.rs @@ -13,7 +13,7 @@ use crate::webserver::get_json::{ delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state, get_time, get_timezones, get_version_web, list_saves, tank_info, }; -use crate::webserver::get_log::get_log; +use crate::webserver::get_log::{get_live_log, get_log}; use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; use crate::webserver::ota::ota_operations; use crate::webserver::post_json::{ @@ -64,7 +64,6 @@ impl Handler for HTTPRequestRouter { e })? } else { - crate::log::INTERCEPTOR.start_capture().await; match method { Method::Get => match path { "/favicon.ico" => serve_favicon(conn).await?, @@ -84,6 +83,14 @@ impl Handler for HTTPRequestRouter { "/timezones" => Some(get_timezones().await), "/moisture" => Some(get_live_moisture(conn).await), "/list_saves" => Some(list_saves(conn).await), + // /live_log accepts an optional ?after=N query parameter + p if p == "/live_log" || p.starts_with("/live_log?") => { + let after: Option = p + .find("after=") + .and_then(|pos| p[pos + 6..].split('&').next()) + .and_then(|s| s.parse().ok()); + Some(get_live_log(after).await) + } // /get_config accepts an optional ?saveidx=N query parameter p if p == "/get_config" || p.starts_with("/get_config?") => { let saveidx: Option = p @@ -167,7 +174,6 @@ impl Handler for HTTPRequestRouter { let response_time = Instant::now().duration_since(start).as_millis(); info!("\"{method} {path}\" {code} {response_time}ms"); - crate::log::INTERCEPTOR.stop_capture().await; Ok(()) } } @@ -265,17 +271,9 @@ where } }, Err(err) => { - let mut error_text = err.to_string(); + let error_text = err.to_string(); info!("error handling process {error_text}"); - if let Some(logs) = crate::log::INTERCEPTOR.stop_capture().await { - error_text.push_str("\n\nCaptured Logs:\n"); - for log in logs { - error_text.push_str(&log); - error_text.push('\n'); - } - } - conn.initiate_response( 500, Some("OK"), diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index 37e61fb..dbe8601 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -1,6 +1,17 @@ export interface LogArray extends Array { } +export interface LiveLogEntry { + seq: number, + text: string, +} + +export interface LiveLogResponse { + entries: LiveLogEntry[], + dropped: boolean, + next_seq: number, +} + export interface LogEntry { timestamp: string, message_id: number, diff --git a/Software/MainBoard/rust/src_webpack/src/log.html b/Software/MainBoard/rust/src_webpack/src/log.html index 05c6be7..3863bf4 100644 --- a/Software/MainBoard/rust/src_webpack/src/log.html +++ b/Software/MainBoard/rust/src_webpack/src/log.html @@ -1,7 +1,48 @@ - -
- -
\ No newline at end of file + +

Application Log

+
+ +

Live Log

+
diff --git a/Software/MainBoard/rust/src_webpack/src/log.ts b/Software/MainBoard/rust/src_webpack/src/log.ts index 1e38724..0b8b914 100644 --- a/Software/MainBoard/rust/src_webpack/src/log.ts +++ b/Software/MainBoard/rust/src_webpack/src/log.ts @@ -1,19 +1,38 @@ import { Controller } from "./main"; -import {LogArray, LogLocalisation} from "./api"; +import {LiveLogResponse, LogArray, LogLocalisation} from "./api"; + +const LIVE_LOG_POLL_INTERVAL_MS = 2000; export class LogView { private readonly logpanel: HTMLElement; - private readonly loadLog: HTMLButtonElement; + private readonly livelogpanel: HTMLElement; + private readonly accordionHeader: HTMLElement; loglocale: LogLocalisation | undefined; + private liveLogNextSeq: number | undefined = undefined; + private liveLogTimer: ReturnType | undefined = undefined; + private structuredLogLoaded = false; + constructor(controller: Controller) { (document.getElementById("logview") as HTMLElement).innerHTML = require('./log.html') as string; - this.logpanel = document.getElementById("logpanel") as HTMLElement - this.loadLog = document.getElementById("loadLog") as HTMLButtonElement + this.logpanel = document.getElementById("logpanel") as HTMLElement; + this.livelogpanel = document.getElementById("livelogpanel") as HTMLElement; + this.accordionHeader = document.getElementById("logAccordionHeader") as HTMLElement; - this.loadLog.onclick = () => { - controller.loadLog(); - } + this.accordionHeader.onclick = () => { + const isOpen = this.logpanel.style.display !== "none"; + if (isOpen) { + this.logpanel.style.display = "none"; + this.accordionHeader.classList.remove("open"); + } else { + this.logpanel.style.display = ""; + this.accordionHeader.classList.add("open"); + if (!this.structuredLogLoaded) { + this.structuredLogLoaded = true; + controller.loadLog(); + } + } + }; } setLogLocalisation(loglocale: LogLocalisation) { @@ -21,10 +40,10 @@ export class LogView { } setLog(logs: LogArray) { - this.logpanel.textContent = "" + this.logpanel.textContent = ""; logs.forEach(entry => { let message = this.loglocale!![entry.message_id]; - let template = message.message + let template = message.message; template = template.replace("${number_a}", entry.a.toString()); template = template.replace("${number_b}", entry.b.toString()); template = template.replace("${txt_short}", entry.txt_short.toString()); @@ -32,15 +51,67 @@ export class LogView { let ts = new Date(entry.timestamp); - let div = document.createElement("div") - let timestampDiv = document.createElement("div") - let messageDiv = document.createElement("div") + let div = document.createElement("div"); + let timestampDiv = document.createElement("div"); + let messageDiv = document.createElement("div"); timestampDiv.innerText = ts.toISOString(); messageDiv.innerText = template; - div.appendChild(timestampDiv) - div.appendChild(messageDiv) - this.logpanel.appendChild(div) - } - ) + div.appendChild(timestampDiv); + div.appendChild(messageDiv); + this.logpanel.appendChild(div); + }); } -} \ No newline at end of file + + startLivePoll(publicUrl: string) { + if (this.liveLogTimer !== undefined) { + return; + } + const poll = async () => { + try { + const url = this.liveLogNextSeq !== undefined + ? `${publicUrl}/live_log?after=${this.liveLogNextSeq}` + : `${publicUrl}/live_log`; + const response = await fetch(url); + const data = await response.json() as LiveLogResponse; + this.appendLiveLog(data); + } catch (_e) { + // network error — silently ignore, will retry next interval + } + this.liveLogTimer = setTimeout(poll, LIVE_LOG_POLL_INTERVAL_MS); + }; + // Kick off immediately + this.liveLogTimer = setTimeout(poll, 0); + } + + stopLivePoll() { + if (this.liveLogTimer !== undefined) { + clearTimeout(this.liveLogTimer); + this.liveLogTimer = undefined; + } + } + + private appendLiveLog(data: LiveLogResponse) { + const panel = this.livelogpanel; + const wasAtBottom = panel.scrollHeight - panel.scrollTop <= panel.clientHeight + 4; + + if (data.dropped) { + const marker = document.createElement("div"); + marker.className = "livelog-dropped"; + marker.textContent = "[..]"; + panel.appendChild(marker); + } + + for (const entry of data.entries) { + const line = document.createElement("div"); + line.textContent = entry.text; + panel.appendChild(line); + } + + this.liveLogNextSeq = data.next_seq; + + // Auto-scroll to bottom only if user was already at the bottom + if (wasAtBottom) { + panel.scrollTop = panel.scrollHeight; + } + } +} diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 5208998..79dcf0c 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -668,7 +668,8 @@ async function executeTasksSequentially() { } executeTasksSequentially().then(() => { - controller.progressview.removeProgress("initial") + controller.progressview.removeProgress("initial"); + controller.logView.startLivePoll(PUBLIC_URL); }); controller.progressview.removeProgress("rebooting");