Add live log buffering support and endpoint; enhance log display functionality.

This commit is contained in:
Kai Börnert
2026-04-27 15:04:05 +02:00
parent 3fa8077b81
commit f0c9ed4e7f
9 changed files with 274 additions and 66 deletions

View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component>
</project>

View File

@@ -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<Device> = 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);

View File

@@ -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<u64>) -> (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<CriticalSectionRawMutex, ()>,
// Blocking mutex for the actual data to be used in sync log()
sync_capture: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<Option<Vec<String>>>>,
live_log: BlockingMutex<CriticalSectionRawMutex, core::cell::RefCell<LiveLogBuffer>>,
}
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<Vec<String>> {
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<u64>) -> (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);
});
}
}

View File

@@ -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<T, const N: usize>(
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<LiveLogEntry>,
dropped: bool,
next_seq: u64,
}
pub(crate) async fn get_live_log(after: Option<u64>) -> FatResult<Option<String>> {
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)?))
}

View File

@@ -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<u64> = 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<usize> = 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"),

View File

@@ -1,6 +1,17 @@
export interface LogArray extends Array<LogEntry> {
}
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,

View File

@@ -1,7 +1,48 @@
<style>
#livelogpanel {
font-family: monospace;
font-size: 0.85em;
background: #1a1a1a;
color: #d4d4d4;
padding: 8px;
max-height: 300px;
overflow-y: auto;
border: 1px solid #444;
white-space: pre-wrap;
word-break: break-all;
}
.livelog-dropped {
color: #f0a500;
font-style: italic;
}
.log-accordion-header {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.log-accordion-header::before {
content: "▶";
font-size: 0.75em;
transition: transform 0.15s;
display: inline-block;
}
.log-accordion-header.open::before {
transform: rotate(90deg);
}
#logpanel {
display: none;
}
</style>
<button id="loadLog">Load Logs</button>
<div id="logpanel">
</div>
<h4 id="logAccordionHeader" class="log-accordion-header">Application Log</h4>
<div id="logpanel"></div>
<h4>Live Log</h4>
<div id="livelogpanel"></div>

View File

@@ -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<typeof setTimeout> | 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);
});
}
}
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;
}
}
}

View File

@@ -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");