Files
kota-technician-tool/kota_tech/core/logline.py
T
kkallas e8b72b743d Technician tool v0.2
Imported from the Google Drive folder 004-technician_tool/v0.2.
2026-09-26 16:53:59 +02:00

147 lines
5.2 KiB
Python

"""Serial byte stream -> framed, cleaned, parsed log lines.
ESP-IDF log format: "<L> (<ms since boot>) <TAG>: <message>", optionally
wrapped in ANSI colour codes. ROM/bootloader output (boot banner, reset
reason) has no level prefix and is classified separately, since a boot
banner is the only reliable reboot marker.
"""
from __future__ import annotations
import re
import time
from dataclasses import dataclass, field
from enum import Enum
from .redact import redact
ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
ESP_LOG_RE = re.compile(r"^([EWIDV]) \((\d+)\) ([^:]{1,40}?): ?(.*)$")
# ESP32-S3 ROM prints e.g. "ESP-ROM:esp32s3-20210327" then
# "rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)".
BOOT_BANNER_RE = re.compile(r"^ESP-ROM:")
RESET_REASON_RE = re.compile(r"^rst:0x([0-9a-fA-F]+) \(([A-Z0-9_]+)\)")
PANIC_RE = re.compile(
r"Guru Meditation Error|abort\(\) was called|ESP_ERROR_CHECK failed|"
r"^Backtrace:|Rebooting\.\.\.$"
)
class LineKind(str, Enum):
LOG = "log" # ESP_LOGx line
BOOT = "boot" # ROM boot banner (start of a new boot)
RESET = "reset" # ROM reset-reason line
PANIC = "panic" # crash / abort output
RAW = "raw" # anything else (task list rows, line protocol, ...)
HOST = "host" # event generated by this tool (connect, reset, ...)
def host_event(text: str) -> LogLine:
return LogLine(f"--- {text} ---", LineKind.HOST)
@dataclass(frozen=True)
class LogLine:
text: str # cleaned + redacted
kind: LineKind
host_time: float = field(default_factory=time.time)
level: str | None = None # E/W/I/D/V for LOG lines
uptime_ms: int | None = None
tag: str | None = None
message: str | None = None
reset_reason: str | None = None
def clean(raw: str) -> str:
return ANSI_RE.sub("", raw).replace("\r", "").rstrip()
def parse_line(raw: str, host_time: float | None = None) -> LogLine:
text = redact(clean(raw))
t = time.time() if host_time is None else host_time
if m := ESP_LOG_RE.match(text):
level, ms, tag, msg = m.groups()
return LogLine(text, LineKind.LOG, t, level, int(ms), tag.strip(), msg)
if BOOT_BANNER_RE.match(text):
return LogLine(text, LineKind.BOOT, t)
if m := RESET_REASON_RE.match(text):
return LogLine(text, LineKind.RESET, t, reset_reason=m.group(2))
if PANIC_RE.search(text):
return LogLine(text, LineKind.PANIC, t)
return LogLine(text, LineKind.RAW, t)
class LineFramer:
"""Accumulates bytes and yields complete lines.
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.
"""
def __init__(self, idle_flush_s: float = 0.3, max_line: int = 4096,
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
self._first_line_pending = drop_first_partial
def cancel_drop(self) -> None:
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 data:
self._buf.extend(data)
self._last_rx = now
out: list[str] = []
while (i := self._buf.find(b"\n")) >= 0:
out += self._emit(self._buf[:i])
del self._buf[: i + 1]
if len(self._buf) >= self.max_line:
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:
return self.flush()
return []
def flush(self) -> list[str]:
if not self._buf:
return []
out = self._emit(self._buf)
self._buf.clear()
return out
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]