Technician tool v0.1
Imported from the Google Drive folder 004-technician_tool/v0.1.
This commit is contained in:
commit
e5a33a1c34
28 files changed
+2988
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""KOTA sensorbox technician tool."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,17 @@
|
||||
import sys
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from .ui.main_window import MainWindow
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Whitespace-only changes.
@@ -0,0 +1,140 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
# 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):
|
||||
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
|
||||
|
||||
def cancel_drop(self) -> None:
|
||||
self._dropping = 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]))
|
||||
del self._buf[: i + 1]
|
||||
if len(self._buf) >= self.max_line:
|
||||
out.append(self._decode(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 []
|
||||
|
||||
def flush(self) -> list[str]:
|
||||
if not self._buf:
|
||||
return []
|
||||
line = self._decode(self._buf)
|
||||
self._buf.clear()
|
||||
return [line]
|
||||
|
||||
@staticmethod
|
||||
def _decode(b: bytes | bytearray) -> str:
|
||||
return bytes(b).decode("utf-8", errors="replace")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Secret redaction for anything shown on screen or written to disk.
|
||||
|
||||
Firmware prints the OTA token in clear text (ota_client.cpp:
|
||||
"OTA token loaded from NVS: %s", "received token: %s") and env.txt
|
||||
values may be echoed. Influx tokens are ~88-char base64 strings.
|
||||
Redaction is applied before a line leaves the serial layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
MASK = "<REDACTED>"
|
||||
|
||||
_RULES: list[re.Pattern[str]] = [
|
||||
# "... token loaded from NVS: X", "... received token: X", "token: X"
|
||||
re.compile(r"(?i)(\btoken\b[^:=\n]{0,40}[:=]\s*)(\S+)"),
|
||||
# HTTP auth header echo: "Authorization: Token X" / "Token X"
|
||||
re.compile(r"(\bToken\s+)([A-Za-z0-9_\-+/=]{16,})"),
|
||||
# env-style secrets: INFLUX_TOKEN=..., PASSWORD=...
|
||||
re.compile(r"(?i)(\b(?:INFLUX_TOKEN|PASSWORD|PASS|SECRET|API_KEY)\s*=\s*)(\S+)"),
|
||||
]
|
||||
# Bare long base64/url-safe blobs (Influx tokens end in "==").
|
||||
_BLOB = re.compile(r"(?<![A-Za-z0-9_\-+/])[A-Za-z0-9_\-+/]{40,}={0,2}(?![A-Za-z0-9_\-+/=])")
|
||||
# Pure hex runs (MACs, addresses, hashes) are not secrets.
|
||||
_BLOB_ALLOW = re.compile(r"^[0-9A-Fa-f]+$")
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
for rule in _RULES:
|
||||
text = rule.sub(lambda m: m.group(1) + MASK, text)
|
||||
|
||||
def _blob(m: re.Match[str]) -> str:
|
||||
s = m.group(0)
|
||||
return s if _BLOB_ALLOW.search(s) else MASK
|
||||
|
||||
return _BLOB.sub(_blob, text)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Per-connection session log on disk (already redacted lines only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .logline import LogLine
|
||||
|
||||
SESSIONS_DIR = Path.home() / "KotaTechnicianTool" / "sessions"
|
||||
|
||||
|
||||
class SessionLog:
|
||||
def __init__(self, root: Path = SESSIONS_DIR):
|
||||
self.root = root
|
||||
self.path: Path | None = None
|
||||
self._fh = None
|
||||
|
||||
def start(self, device_hint: str) -> Path:
|
||||
self.stop()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe = re.sub(r"[^A-Za-z0-9]+", "", device_hint)[:20] or "board"
|
||||
self.path = self.root / f"session_{stamp}_{safe}.log"
|
||||
self._fh = self.path.open("a", encoding="utf-8", newline="\n")
|
||||
return self.path
|
||||
|
||||
def write(self, line: LogLine) -> None:
|
||||
if self._fh is None:
|
||||
return
|
||||
ts = dt.datetime.fromtimestamp(line.host_time).strftime("%H:%M:%S.%f")[:-3]
|
||||
self._fh.write(f"[{ts}] {line.text}\n")
|
||||
self._fh.flush()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._fh is not None:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
Whitespace-only changes.
@@ -0,0 +1,192 @@
|
||||
"""Serial connection manager (GUI-independent).
|
||||
|
||||
One background thread owns the port: it opens, reads, frames and parses
|
||||
lines, executes queued commands (reset, write) and re-finds the board if
|
||||
the port vanishes (power switch, USB re-enumeration). Callbacks are
|
||||
invoked from that thread; the Qt layer marshals them to the UI thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import serial
|
||||
|
||||
from ..core.logline import LineFramer, LogLine, host_event, parse_line
|
||||
from .ports import PortInfo, find_port
|
||||
|
||||
READ_TIMEOUT_S = 0.1
|
||||
REOPEN_POLL_S = 0.5
|
||||
|
||||
|
||||
class State(str, enum.Enum):
|
||||
DISCONNECTED = "Disconnected"
|
||||
CONNECTED = "Connected"
|
||||
WAITING = "Waiting for board" # port lost; polling for it to return
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Target:
|
||||
device: str
|
||||
serial_number: str | None
|
||||
|
||||
|
||||
class SerialManager:
|
||||
def __init__(
|
||||
self,
|
||||
on_line: Callable[[LogLine], None],
|
||||
on_state: Callable[[State, str], None],
|
||||
baudrate: int = 115200, # ignored by native USB CDC, used for UART
|
||||
):
|
||||
self._on_line = on_line
|
||||
self._on_state = on_state
|
||||
self._baud = baudrate
|
||||
self._cmds: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
self._target: _Target | None = None
|
||||
self.state = State.DISCONNECTED
|
||||
|
||||
# ---- public API (any thread) -------------------------------------
|
||||
def connect(self, port: PortInfo) -> None:
|
||||
self.disconnect()
|
||||
self._target = _Target(port.device, port.serial_number)
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="serial-reader", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
def reset_board(self) -> None:
|
||||
"""Hard-reset the chip into the application (not download mode)."""
|
||||
self._cmds.put(("reset", None))
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._cmds.put(("write", data))
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return bool(self._thread and self._thread.is_alive())
|
||||
|
||||
# ---- reader thread -----------------------------------------------
|
||||
def _set_state(self, state: State, detail: str = "") -> None:
|
||||
if state != self.state:
|
||||
self.state = state
|
||||
self._on_state(state, detail)
|
||||
|
||||
def _open(self) -> serial.Serial | None:
|
||||
assert self._target
|
||||
info = find_port(self._target.serial_number, self._target.device)
|
||||
if info is None:
|
||||
return None
|
||||
# serial_for_url also accepts test URLs such as "loop://".
|
||||
ser = serial.serial_for_url(
|
||||
info.device, baudrate=self._baud, timeout=READ_TIMEOUT_S, do_not_open=True
|
||||
)
|
||||
# Deassert both lines before open. On USB-Serial-JTAG, RTS=1/DTR=0
|
||||
# holds the chip in reset and DTR=1 during reset selects download
|
||||
# mode; pyserial would otherwise assert both on open (Windows).
|
||||
ser.dtr = False
|
||||
ser.rts = False
|
||||
try:
|
||||
ser.open()
|
||||
except (serial.SerialException, OSError):
|
||||
return None
|
||||
self._target.device = info.device # COM number / node may change
|
||||
return ser
|
||||
|
||||
@staticmethod
|
||||
def _set_rts(ser: serial.Serial, state: bool) -> None:
|
||||
ser.rts = state
|
||||
# Windows usbser.sys only sends SET_CONTROL_LINE_STATE when DTR is
|
||||
# written, so an RTS-only change never reaches the chip. Re-write
|
||||
# DTR with its current value to push the new RTS (esptool does the
|
||||
# same). Harmless on macOS.
|
||||
ser.dtr = ser.dtr
|
||||
|
||||
@classmethod
|
||||
def _hard_reset(cls, ser: serial.Serial) -> None:
|
||||
# Same sequence as esptool HardReset for USB-Serial-JTAG: pulse
|
||||
# EN via RTS with DTR (GPIO0) released, long enough for the USB
|
||||
# peripheral to settle.
|
||||
ser.dtr = False
|
||||
cls._set_rts(ser, True)
|
||||
time.sleep(0.2)
|
||||
cls._set_rts(ser, False)
|
||||
time.sleep(0.2)
|
||||
|
||||
def _run(self) -> None:
|
||||
framer = LineFramer()
|
||||
ser: serial.Serial | None = None
|
||||
announced_wait = False
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
if ser is None:
|
||||
ser = self._open()
|
||||
if ser is None:
|
||||
if not announced_wait:
|
||||
self._set_state(State.WAITING, "Plug in / power on the sensorbox")
|
||||
announced_wait = True
|
||||
self._drain_cmds(None)
|
||||
time.sleep(REOPEN_POLL_S)
|
||||
continue
|
||||
announced_wait = False
|
||||
framer = LineFramer(drop_first_partial=True)
|
||||
self._set_state(State.CONNECTED, ser.port)
|
||||
self._on_line(host_event(f"connected to {ser.port}"))
|
||||
|
||||
try:
|
||||
if self._drain_cmds(ser):
|
||||
framer.cancel_drop() # output after a reset is always live
|
||||
data = ser.read(ser.in_waiting or 1)
|
||||
except (serial.SerialException, OSError) as exc:
|
||||
for raw in framer.flush():
|
||||
self._on_line(parse_line(raw))
|
||||
self._on_line(host_event(f"port lost ({exc.__class__.__name__}); waiting for board"))
|
||||
self._close(ser)
|
||||
ser = None
|
||||
continue
|
||||
|
||||
for raw in framer.feed(data) + framer.poll_idle():
|
||||
self._on_line(parse_line(raw))
|
||||
finally:
|
||||
for raw in framer.flush():
|
||||
self._on_line(parse_line(raw))
|
||||
if ser is not None:
|
||||
self._close(ser)
|
||||
self._on_line(host_event("disconnected"))
|
||||
self._set_state(State.DISCONNECTED)
|
||||
|
||||
def _drain_cmds(self, ser: serial.Serial | None) -> bool:
|
||||
"""Execute queued commands; returns True if the board was reset."""
|
||||
did_reset = False
|
||||
while True:
|
||||
try:
|
||||
cmd, arg = self._cmds.get_nowait()
|
||||
except queue.Empty:
|
||||
return did_reset
|
||||
if ser is None:
|
||||
self._on_line(host_event(f"'{cmd}' ignored: board not connected"))
|
||||
continue
|
||||
if cmd == "reset":
|
||||
self._on_line(host_event("reset issued by tool"))
|
||||
self._hard_reset(ser)
|
||||
did_reset = True
|
||||
elif cmd == "write":
|
||||
ser.write(arg) # type: ignore[arg-type]
|
||||
|
||||
@staticmethod
|
||||
def _close(ser: serial.Serial) -> None:
|
||||
try:
|
||||
ser.close()
|
||||
except (serial.SerialException, OSError):
|
||||
pass
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Serial port discovery.
|
||||
|
||||
Technicians connect through the ESP32-S3 native USB (USB-Serial-JTAG,
|
||||
VID 0x303A / PID 0x1001). Its USB serial number is the chip MAC, which
|
||||
is stable across resets and power cycles, so it is used to re-find the
|
||||
same box after the port disappears (power switch toggled).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from serial.tools import list_ports
|
||||
|
||||
ESPRESSIF_VID = 0x303A
|
||||
USB_SERIAL_JTAG_PID = 0x1001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortInfo:
|
||||
device: str
|
||||
description: str
|
||||
vid: int | None
|
||||
pid: int | None
|
||||
serial_number: str | None
|
||||
|
||||
@property
|
||||
def is_sensorbox(self) -> bool:
|
||||
return self.vid == ESPRESSIF_VID
|
||||
|
||||
@property
|
||||
def mac(self) -> str | None:
|
||||
"""USB-Serial-JTAG reports the MAC as 'AA:BB:CC:DD:EE:FF'."""
|
||||
sn = self.serial_number or ""
|
||||
parts = sn.split(":")
|
||||
if len(parts) == 6 and all(len(p) == 2 for p in parts):
|
||||
return sn.upper()
|
||||
return None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
if self.is_sensorbox:
|
||||
kind = "Sensorbox (native USB)" if self.pid == USB_SERIAL_JTAG_PID else "Espressif USB"
|
||||
return f"{self.device} - {kind}" + (f" [{self.mac}]" if self.mac else "")
|
||||
return f"{self.device} - {self.description}"
|
||||
|
||||
|
||||
def _from_pyserial(p) -> PortInfo:
|
||||
return PortInfo(p.device, p.description or "", p.vid, p.pid, p.serial_number)
|
||||
|
||||
|
||||
def list_serial_ports(include_all: bool = False) -> list[PortInfo]:
|
||||
ports = [_from_pyserial(p) for p in list_ports.comports()]
|
||||
if sys.platform == "darwin":
|
||||
# Each macOS device appears twice; /dev/tty.* blocks on open waiting
|
||||
# for carrier detect, /dev/cu.* is the right one for us.
|
||||
ports = [p for p in ports if not p.device.startswith("/dev/tty.")]
|
||||
if not include_all:
|
||||
ports = [p for p in ports if p.is_sensorbox]
|
||||
# Sensorboxes first, then by name.
|
||||
return sorted(ports, key=lambda p: (not p.is_sensorbox, p.device))
|
||||
|
||||
|
||||
def find_port(serial_number: str | None, device: str | None) -> PortInfo | None:
|
||||
"""Re-locate a board: prefer the USB serial number (MAC), fall back to name."""
|
||||
ports = list_serial_ports(include_all=True)
|
||||
if serial_number:
|
||||
for p in ports:
|
||||
if p.serial_number == serial_number:
|
||||
return p
|
||||
if device:
|
||||
for p in ports:
|
||||
if p.device == device:
|
||||
return p
|
||||
return None
|
||||
Whitespace-only changes.
@@ -0,0 +1,83 @@
|
||||
"""Quick-start cheat sheet shown on launch.
|
||||
|
||||
Targets the DEPLOYED firmware: new_system.bin, app "kota-sensorboard"
|
||||
1.0, built Jun 29 2026 11:03:32, ELF SHA256 45fa9d5b9... (verified on a
|
||||
live board). That build has no Wi-Fi setup portal / SSID-on-SD support;
|
||||
those exist only in the newer, unbuilt source. The status LED is always
|
||||
SOLID; the separate small LED toggles every second while alive.
|
||||
"""
|
||||
|
||||
# (swatch colour, LED name, meaning, first action)
|
||||
LED_TABLE = [
|
||||
("#2e9e44", "Green", "Everything OK.", "Nothing to do."),
|
||||
("#8e44ad", "Purple",
|
||||
"No Internet / InfluxDB or OTA server unreachable.",
|
||||
"Check the box is commissioned in the Matter app (sticker QR code) and the "
|
||||
"site Wi-Fi has Internet. Board reboots itself after ~2.5 min of purple."),
|
||||
("#d64541", "Red",
|
||||
"Sensor or hardware fault (BME688, SEN66, AS7341, microphone, flicker) "
|
||||
"or failed self-check after update.",
|
||||
"Connect serial - the log names the failing sensor. Check sensor cables."),
|
||||
("#2f6fd6", "Blue",
|
||||
"Clock (RTC DS1307) failed to start. The RTC is the first I2C device, "
|
||||
"so a broken I2C cable also shows blue.",
|
||||
"Check the I2C harness first, then the RTC module / coin cell."),
|
||||
("#e6c229", "Yellow", "Firmware update downloading.", "Do not switch off. Wait."),
|
||||
("#f08c1a", "Orange", "Self-check after an update (~2.5 min).",
|
||||
"Wait; should turn green (or red on failure)."),
|
||||
("#f4f4f4", "White",
|
||||
"SD card missing/unreadable or ENV.TXT incomplete - or a factory reset is "
|
||||
"in progress (Matter button held 5 s).",
|
||||
"If nobody pressed the Matter button: the SD card is inside the case - "
|
||||
"unscrew, re-seat or re-provision it."),
|
||||
("#444444", "Off", "Firmware did not start (or crashed very early).",
|
||||
"Check power switch & USB cable. If the small LED is also dark: re-flash."),
|
||||
]
|
||||
|
||||
HINTS = [
|
||||
"<b>Colour keeps changing / LED goes dark every few seconds</b> = reboot loop. "
|
||||
"Connect serial and press <i>Reset & capture</i>: the last lines before the "
|
||||
"reboot name the cause.",
|
||||
"<b>Small LED next to the RGB LED</b> blinks once per second while the "
|
||||
"firmware runs. Not blinking = firmware hung or not running.",
|
||||
"<b>Outside the case</b>: power switch and the Matter button. Holding the "
|
||||
"Matter button <b>5 s</b> factory-resets the box (it forgets its Matter "
|
||||
"network and must be commissioned again with the sticker QR code).",
|
||||
"<b>Inside the case</b> (unscrew): RESET and BOOT buttons and the SD card. "
|
||||
"Normally not needed - the tool resets the board over USB.",
|
||||
"<b>Gathering messages for the first ~2 minutes</b> are normal "
|
||||
"(the air-quality sensor warms up for 120 s).",
|
||||
]
|
||||
|
||||
STEPS = [
|
||||
"Switch the sensorbox <b>power switch ON</b>.",
|
||||
"Connect the laptop to the box's <b>native USB</b> port with a data-capable cable.",
|
||||
"Open the <b>Serial monitor</b> tab, choose the sensorbox port, press "
|
||||
"<b>Connect</b>.",
|
||||
"Press <b>Reset & capture</b> to record a full boot log from the first line.",
|
||||
"Note the RGB LED colour and compare with the table below.",
|
||||
]
|
||||
|
||||
|
||||
def cheatsheet_html() -> str:
|
||||
rows = "".join(
|
||||
f"<tr><td style='padding:6px'><span style='background:{c};"
|
||||
f"border:1px solid #888'> </span> <b>{name}</b></td>"
|
||||
f"<td style='padding:6px'>{meaning}</td><td style='padding:6px'>{action}</td></tr>"
|
||||
for c, name, meaning, action in LED_TABLE
|
||||
)
|
||||
steps = "".join(f"<li>{s}</li>" for s in STEPS)
|
||||
hints = "".join(f"<li>{h}</li>" for h in HINTS)
|
||||
return f"""
|
||||
<h2>Sensorbox quick start</h2>
|
||||
<ol>{steps}</ol>
|
||||
<h3>RGB status LED (always solid colour)</h3>
|
||||
<table border='1' cellspacing='0' cellpadding='0' width='100%'>
|
||||
<tr><th align='left' style='padding:6px'>LED</th>
|
||||
<th align='left' style='padding:6px'>Most likely meaning</th>
|
||||
<th align='left' style='padding:6px'>First action</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
<h3>Good to know</h3>
|
||||
<ul>{hints}</ul>
|
||||
"""
|
||||
@@ -0,0 +1,234 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QTextCharFormat, QTextCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QTabWidget,
|
||||
QTextBrowser,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .. import __version__
|
||||
from ..core.logline import LineKind, LogLine
|
||||
from ..core.session import SessionLog
|
||||
from ..serialio.manager import SerialManager, State
|
||||
from ..serialio.ports import PortInfo, list_serial_ports
|
||||
from .cheatsheet import cheatsheet_html
|
||||
|
||||
MAX_LINES = 20000
|
||||
LEVEL_FILTERS = {"All lines": None, "Warnings + errors": {"W", "E"}, "Errors only": {"E"}}
|
||||
COLORS = {
|
||||
"E": "#ff6b6b",
|
||||
"W": "#f5c451",
|
||||
"I": "#d4d4d4",
|
||||
LineKind.BOOT: "#6fc3ff",
|
||||
LineKind.RESET: "#6fc3ff",
|
||||
LineKind.PANIC: "#ff4dd2",
|
||||
LineKind.HOST: "#7ee07e",
|
||||
LineKind.RAW: "#a8a8a8",
|
||||
}
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Marshals SerialManager callbacks (reader thread) onto the UI thread."""
|
||||
|
||||
line = Signal(object)
|
||||
state = Signal(object, str)
|
||||
|
||||
|
||||
class SerialTab(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self._bridge = _Bridge()
|
||||
self._bridge.line.connect(self._on_line)
|
||||
self._bridge.state.connect(self._on_state)
|
||||
self._manager = SerialManager(self._bridge.line.emit, self._bridge.state.emit)
|
||||
self._session = SessionLog()
|
||||
self._ports: list[PortInfo] = []
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
|
||||
# --- controls
|
||||
self.port_combo = QComboBox(minimumWidth=320)
|
||||
self.all_ports = QCheckBox("Show all ports")
|
||||
self.refresh_btn = QPushButton("Refresh")
|
||||
self.connect_btn = QPushButton("Connect")
|
||||
self.reset_btn = QPushButton("Reset && capture")
|
||||
self.reset_btn.setToolTip("Reboot the sensorbox and record the log from the first boot line")
|
||||
self.filter_combo = QComboBox()
|
||||
self.filter_combo.addItems(LEVEL_FILTERS)
|
||||
self.clear_btn = QPushButton("Clear view")
|
||||
self.save_btn = QPushButton("Save view as...")
|
||||
self.folder_btn = QPushButton("Open logs folder")
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
for w in (QLabel("Port:"), self.port_combo, self.all_ports, self.refresh_btn,
|
||||
self.connect_btn, self.reset_btn):
|
||||
row1.addWidget(w)
|
||||
row1.addStretch(1)
|
||||
row2 = QHBoxLayout()
|
||||
for w in (QLabel("Show:"), self.filter_combo, self.clear_btn, self.save_btn, self.folder_btn):
|
||||
row2.addWidget(w)
|
||||
row2.addStretch(1)
|
||||
|
||||
self.view = QPlainTextEdit(readOnly=True)
|
||||
self.view.setMaximumBlockCount(MAX_LINES)
|
||||
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
||||
mono = QFont("Menlo" if QFont("Menlo").exactMatch() else "Consolas")
|
||||
mono.setStyleHint(QFont.StyleHint.Monospace)
|
||||
mono.setPointSize(10)
|
||||
self.view.setFont(mono)
|
||||
self.view.setStyleSheet("QPlainTextEdit { background:#1e1e1e; color:#d4d4d4; }")
|
||||
|
||||
self.status = QLabel()
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addLayout(row1)
|
||||
layout.addLayout(row2)
|
||||
layout.addWidget(self.view, 1)
|
||||
layout.addWidget(self.status)
|
||||
|
||||
# --- wiring
|
||||
self.refresh_btn.clicked.connect(self.refresh_ports)
|
||||
self.all_ports.toggled.connect(self.refresh_ports)
|
||||
self.connect_btn.clicked.connect(self._toggle_connect)
|
||||
self.reset_btn.clicked.connect(self._manager.reset_board)
|
||||
self.clear_btn.clicked.connect(self.view.clear)
|
||||
self.save_btn.clicked.connect(self._save_view)
|
||||
self.folder_btn.clicked.connect(self._open_folder)
|
||||
|
||||
# Auto-detect a box being plugged in while idle.
|
||||
self._poll = QTimer(self, interval=2000)
|
||||
self._poll.timeout.connect(self._auto_refresh)
|
||||
self._poll.start()
|
||||
|
||||
self.refresh_ports()
|
||||
self._on_state(State.DISCONNECTED, "")
|
||||
|
||||
# ---- ports -------------------------------------------------------
|
||||
def refresh_ports(self) -> None:
|
||||
current = self.port_combo.currentData()
|
||||
self._ports = list_serial_ports(include_all=self.all_ports.isChecked())
|
||||
self.port_combo.clear()
|
||||
for p in self._ports:
|
||||
self.port_combo.addItem(p.label, p.device)
|
||||
if not self._ports:
|
||||
self.port_combo.addItem("No sensorbox found - check USB cable and power switch", None)
|
||||
idx = self.port_combo.findData(current)
|
||||
self.port_combo.setCurrentIndex(max(idx, 0))
|
||||
|
||||
def _auto_refresh(self) -> None:
|
||||
if self._manager.is_running:
|
||||
return
|
||||
devices = [p.device for p in list_serial_ports(self.all_ports.isChecked())]
|
||||
if devices != [p.device for p in self._ports]:
|
||||
self.refresh_ports()
|
||||
|
||||
def _selected_port(self) -> PortInfo | None:
|
||||
dev = self.port_combo.currentData()
|
||||
return next((p for p in self._ports if p.device == dev), None)
|
||||
|
||||
# ---- connection --------------------------------------------------
|
||||
def _toggle_connect(self) -> None:
|
||||
if self._manager.is_running:
|
||||
# Session file is closed in _on_state(DISCONNECTED), after the
|
||||
# reader thread's final queued lines have been written.
|
||||
self._manager.disconnect()
|
||||
return
|
||||
port = self._selected_port()
|
||||
if port is None:
|
||||
return
|
||||
self._counts = {"E": 0, "W": 0, "boots": 0}
|
||||
self._session.start(port.mac or port.device)
|
||||
self._manager.connect(port)
|
||||
|
||||
def _on_state(self, state: State, detail: str) -> None:
|
||||
running = state != State.DISCONNECTED
|
||||
if not running:
|
||||
self._session.stop()
|
||||
self.connect_btn.setText("Disconnect" if running else "Connect")
|
||||
self.reset_btn.setEnabled(state == State.CONNECTED)
|
||||
self.port_combo.setEnabled(not running)
|
||||
self.all_ports.setEnabled(not running)
|
||||
self.refresh_btn.setEnabled(not running)
|
||||
self._state_text = f"{state.value}" + (f" ({detail})" if detail else "")
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
sess = f" | Session log: {self._session.path}" if self._session.path else ""
|
||||
c = self._counts
|
||||
self.status.setText(
|
||||
f"{getattr(self, '_state_text', '')} | boots seen: {c['boots']} "
|
||||
f"errors: {c['E']} warnings: {c['W']}{sess}"
|
||||
)
|
||||
|
||||
# ---- lines -------------------------------------------------------
|
||||
def _on_line(self, line: LogLine) -> None:
|
||||
self._session.write(line)
|
||||
if line.kind == LineKind.BOOT:
|
||||
self._counts["boots"] += 1
|
||||
if line.level in ("E", "W"):
|
||||
self._counts[line.level] += 1
|
||||
self._update_status()
|
||||
|
||||
allowed = LEVEL_FILTERS[self.filter_combo.currentText()]
|
||||
if allowed is not None and line.kind != LineKind.HOST and not (
|
||||
line.level in allowed or line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.RESET)
|
||||
):
|
||||
return
|
||||
self._append(line)
|
||||
|
||||
def _append(self, line: LogLine) -> None:
|
||||
sb = self.view.verticalScrollBar()
|
||||
at_bottom = sb.value() >= sb.maximum() - 4
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setForeground(QColor(COLORS.get(line.level) or COLORS.get(line.kind, "#d4d4d4")))
|
||||
if line.kind in (LineKind.PANIC, LineKind.BOOT, LineKind.HOST):
|
||||
fmt.setFontWeight(QFont.Weight.Bold)
|
||||
cur = QTextCursor(self.view.document())
|
||||
cur.movePosition(QTextCursor.MoveOperation.End)
|
||||
if not self.view.document().isEmpty():
|
||||
cur.insertBlock()
|
||||
cur.insertText(line.text, fmt)
|
||||
if at_bottom:
|
||||
sb.setValue(sb.maximum())
|
||||
|
||||
# ---- misc --------------------------------------------------------
|
||||
def _save_view(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(self, "Save log view", "sensorbox_log.txt", "Text (*.txt)")
|
||||
if path:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(self.view.toPlainText())
|
||||
|
||||
def _open_folder(self) -> None:
|
||||
self._session.root.mkdir(parents=True, exist_ok=True)
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._session.root)))
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._manager.disconnect()
|
||||
self._session.stop()
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle(f"KOTA Sensorbox Technician Tool v{__version__}")
|
||||
self.resize(1200, 800)
|
||||
tabs = QTabWidget()
|
||||
guide = QTextBrowser(openExternalLinks=True)
|
||||
guide.setHtml(cheatsheet_html())
|
||||
self.serial_tab = SerialTab()
|
||||
tabs.addTab(guide, "Quick start")
|
||||
tabs.addTab(self.serial_tab, "Serial monitor")
|
||||
self.setCentralWidget(tabs)
|
||||
|
||||
def closeEvent(self, event): # noqa: N802 (Qt naming)
|
||||
self.serial_tab.shutdown()
|
||||
super().closeEvent(event)
|
||||
Reference in new issue
Block a user