Technician tool v0.2

Imported from the Google Drive folder 004-technician_tool/v0.2.
This commit is contained in:
kkallas committed 2026-09-26 16:53:59 +02:00
1 parent e5a33a1c34
commit e8b72b743d
17 files changed
+2410 -283

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
"""Load saved logs for offline analysis.
Accepts both raw serial captures and this tool's session logs, whose lines
look like "[14:02:11.532] I (1234) TAG: msg" and whose host events look
like "[14:02:11.532] --- reset issued by tool ---".
"""
from __future__ import annotations
import re
from pathlib import Path
from .logline import LineKind, LogLine, parse_line, split_glued
_SESSION_PREFIX = re.compile(r"^\[(\d\d):(\d\d):(\d\d)\.(\d{3})\] ")
_HOST_EVENT = re.compile(r"^--- .* ---$")
def read_log_file(path: Path) -> list[LogLine]:
text = path.read_bytes().decode("utf-8", errors="replace")
out: list[LogLine] = []
for i, raw in enumerate(text.splitlines()):
host_time = i * 0.001 # raw capture: keep order, no wall clock
if m := _SESSION_PREFIX.match(raw):
h, mi, s, ms = (int(g) for g in m.groups())
host_time = h * 3600 + mi * 60 + s + ms / 1000
raw = raw[m.end():]
if _HOST_EVENT.match(raw):
out.append(LogLine(raw, LineKind.HOST, host_time))
continue
out.extend(parse_line(part, host_time) for part in split_glued(raw))
return out
+36 -30
View File
@@ -76,65 +76,71 @@ class LineFramer:
A partial line is released after `idle_flush_s` without new bytes so
prompts / unterminated output are not held back forever.
Lines are also split where a new log line starts in the middle
("I (2302) chip[DL]: I (2302) chip[DL]: Posting..."): the two S3 cores
interleave output, and the USB-Serial-JTAG FIFO keeps the tail of an old
line while no host reads, releasing it glued in front of the next live
output. After a (re)open, the fragment in front of the first such split
is stale and dropped. Timing cannot identify it, because the FIFO only
flushes when the board next prints, possibly seconds later.
"""
# Stale FIFO bytes arrive immediately after open; anything later is live.
STALE_WINDOW_S = 0.25
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
drop_first_partial: bool = False, now: float | None = None):
drop_first_partial: bool = False):
self._buf = bytearray()
self._last_rx = 0.0
self.idle_flush_s = idle_flush_s
self.max_line = max_line
# The S3 USB-Serial-JTAG FIFO keeps the tail of an old line while no
# host is reading and flushes it on open; drop bytes up to the first
# newline after a (re)connect so it is not glued to a fresh line.
# Only bytes arriving within STALE_WINDOW_S of open are considered
# stale, otherwise an idle board's next boot banner would be lost.
self._dropping = drop_first_partial
self._drop_deadline = (time.monotonic() if now is None else now) + self.STALE_WINDOW_S
self._first_line_pending = drop_first_partial
def cancel_drop(self) -> None:
self._dropping = False
self._first_line_pending = False
def feed(self, data: bytes, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now
if self._dropping and now > self._drop_deadline:
self._dropping = False
if data:
self._buf.extend(data)
self._last_rx = now
if self._dropping:
i = self._buf.find(b"\n")
if i < 0:
return []
del self._buf[: i + 1]
self._dropping = False
out: list[str] = []
while (i := self._buf.find(b"\n")) >= 0:
out.append(self._decode(self._buf[:i]))
out += self._emit(self._buf[:i])
del self._buf[: i + 1]
if len(self._buf) >= self.max_line:
out.append(self._decode(self._buf))
out += self._emit(self._buf)
self._buf.clear()
return out
def poll_idle(self, now: float | None = None) -> list[str]:
now = time.monotonic() if now is None else now
if self._buf and now - self._last_rx >= self.idle_flush_s:
line = self._decode(self._buf)
self._buf.clear()
return [line]
return self.flush()
return []
def flush(self) -> list[str]:
if not self._buf:
return []
line = self._decode(self._buf)
out = self._emit(self._buf)
self._buf.clear()
return [line]
return out
@staticmethod
def _decode(b: bytes | bytearray) -> str:
return bytes(b).decode("utf-8", errors="replace")
def _emit(self, raw: bytes | bytearray) -> list[str]:
parts = split_glued(bytes(raw).decode("utf-8", errors="replace"))
if self._first_line_pending:
self._first_line_pending = False
if len(parts) > 1:
parts = parts[1:] # stale FIFO fragment from before the open
return parts
# Start of a log line (optionally ANSI-coloured) or ROM banner.
_LINE_START = re.compile(r"(?:\x1b\[[0-9;]*m)?(?:[EWIDV] \(\d+\) \S|ESP-ROM:)")
def split_glued(text: str) -> list[str]:
# Left-to-right scan so an ANSI colour prefix stays with its line.
cuts = [m.start() for m in _LINE_START.finditer(text) if m.start() > 0]
if not cuts:
return [text]
bounds = [0, *cuts, len(text)]
return [text[a:b] for a, b in zip(bounds, bounds[1:]) if a < b]
+7
View File
@@ -33,6 +33,13 @@ class SessionLog:
self._fh.write(f"[{ts}] {line.text}\n")
self._fh.flush()
def write_report(self, text: str) -> None:
"""Append the diagnosis at the end of the session (for the firmware team)."""
if self._fh is None:
return
self._fh.write("\n===== Diagnosis at end of session =====\n" + text + "\n")
self._fh.flush()
def stop(self) -> None:
if self._fh is not None:
self._fh.close()