"""Append-only structured log for host-side BLE events. One JSON line per event, in `config/.ble_debug.log`. Written from the BLE thread; reads happen from anywhere. Trimmed to the most recent N lines on each write so the file doesn't grow forever. This is independent of Python's `logging` module so it survives across restarts and gets written even if the GUI crashes. """ import json import os import threading import time from pathlib import Path from utils.constants import APPDATA_DIR _LOG_PATH = Path(APPDATA_DIR) / ".ble_debug.log" _MAX_LINES = 2000 # trimmed when exceeded _lock = threading.Lock() def _trim_locked() -> None: try: with open(_LOG_PATH, "r", encoding="utf-8") as f: lines = f.readlines() except FileNotFoundError: return if len(lines) <= _MAX_LINES: return keep = lines[-_MAX_LINES:] tmp = _LOG_PATH.with_suffix(".tmp") with open(tmp, "w", encoding="utf-8") as f: f.writelines(keep) os.replace(tmp, _LOG_PATH) def event(_event_name: str, **fields) -> None: """Append a structured event line. First positional arg is the event name (renamed to avoid collision with a `kind` keyword that some call sites legitimately want to log). `fields` are arbitrary JSON-serializable extras. """ rec = { "t": time.time(), "ts": time.strftime("%H:%M:%S", time.localtime()), "kind": _event_name, } rec.update(fields) line = json.dumps(rec, default=str) + "\n" with _lock: try: _LOG_PATH.parent.mkdir(parents=True, exist_ok=True) with open(_LOG_PATH, "a", encoding="utf-8") as f: f.write(line) # Cheap probabilistic trim: only check size every ~50 lines. if int(rec["t"] * 1000) % 50 == 0: _trim_locked() except Exception: # Logging failures must not break BLE. pass # Mirror to stdout for live debugging. The variable was previously # named `kind` which raised NameError and silently suppressed every # stdout mirror via the broad except — fixed to use the local arg. try: print(f"[BLE.dbg {rec['ts']}] {_event_name} " f"{json.dumps(fields, default=str)}") except Exception: pass def path() -> str: return str(_LOG_PATH) def clear() -> None: with _lock: try: _LOG_PATH.unlink() except FileNotFoundError: pass