Files
uv-k5-v3-emulator/tools/uvk5_logs.py
T
mckero 2667e046e8 Emulator: multiboot slots from the page, flash controller, portable tests
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting

slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
2026-10-01 14:54:34 +08:00

96 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""A bounded log buffer for the web UI.
Collects supervisor events, QEMU stderr, and firmware serial output (which the
machine model prints as "SERIAL <line>") so the browser has something to show.
In memory and bounded on purpose: this is a debugging aid inside a long-running
server, so an unbounded buffer would be a slow leak. Anyone wanting a permanent
record can redirect the server's own stderr to a file.
Every entry carries a monotonic `seq`, so a polling client can ask for "anything
after N" and get each line exactly once even when older entries have been evicted.
"""
import collections
import threading
import time
# Bytes that are safe to show as text. Everything else in a serial line means the
# wire is carrying binary, not output meant to be read.
_TEXT_BYTES = frozenset(range(0x20, 0x7f)) | {0x09}
def describe_line(raw: bytes) -> str:
"""A line of serial, or a summary when the bytes are not text.
Serial carries two very different things over one wire: the firmware's readable
output, and the CPS programming protocol, which is binary. Decoding the second
as text filled the pane with control characters and buried the first, so a line
that is mostly non-printable becomes its size plus a hex prefix instead.
"""
body = raw.rstrip(b"\r\n")
if not body:
return ""
unprintable = sum(1 for b in body if b not in _TEXT_BYTES)
if unprintable * 4 <= len(body):
return body.decode("utf-8", "replace")
head = body[:24].hex(" ")
tail = "" if len(body) <= 24 else f" … +{len(body) - 24} bytes"
return f"<binary {len(body)} bytes> {head}{tail}"
class LogBuffer:
def __init__(self, capacity: int = 500):
self._entries = collections.deque(maxlen=capacity)
self._lock = threading.Lock()
self._seq = 0
def add(self, source: str, text: str, ip: str = None):
"""Record one line. `ip` identifies the client that caused it.
The buffer is shared by every viewer, so without an attributed IP a log of
keypresses from two people is unreadable. Entries with no client behind
them -- firmware serial, QEMU stderr -- carry None.
"""
with self._lock:
self._seq += 1
self._entries.append({
"seq": self._seq,
"time": time.strftime("%H:%M:%S"),
"ip": ip,
"source": source,
"text": text,
})
def cursor(self) -> int:
with self._lock:
return self._seq
def entries(self, since: int = 0):
with self._lock:
return [e for e in self._entries if e["seq"] > since]
def pump_stream(self, stream, default_source: str = "qemu"):
"""Read a byte stream to EOF, one entry per line.
Lines the machine model tags with "SERIAL " are firmware output and are
recorded under their own source, so the UI can tell them apart from QEMU's
own chatter.
Decoding is lenient: serial bytes can be garbage before the firmware has
configured the port, and losing the whole stream to one bad byte would be
worse than showing it. A line that is mostly binary is summarised rather
than decoded -- see describe_line().
"""
for raw in iter(stream.readline, b""):
# Strip the model's tag before looking at the bytes: on a binary line the
# hex summary would otherwise hide the prefix and the line would lose its
# "serial" attribution.
source = default_source
if raw.startswith(b"SERIAL "):
source = "serial"
raw = raw[len(b"SERIAL "):]
line = describe_line(raw)
if line:
self.add(source, line)