"""Tails the lidar/camera/recording log files for the browser log panel. The original PyQt5 scan GUIs had a single unified QPlainTextEdit showing all subprocess stdout with a [tag] prefix — this is the web equivalent. Each ManagedProcess re-opens its log file in "w" mode on every start (see process_manager.py), so a tailer must detect truncation (file shrank since last read) and reset to the top rather than seeking past EOF forever. """ from __future__ import annotations from pathlib import Path from typing import Iterable class LogTailer: def __init__(self, sources: dict[str, Path]): self._sources = sources self._offsets: dict[str, int] = {tag: 0 for tag in sources} def read_tail(self, tag: str, max_lines: int = 200) -> list[str]: """Backlog for a fresh websocket connection — last N lines, and advances this tag's offset to end-of-file so read_new() only reports genuinely new lines afterwards.""" path = self._sources.get(tag) if path is None or not path.exists(): return [] try: with open(path, "r", errors="replace") as f: lines = f.readlines() self._offsets[tag] = f.tell() except OSError: return [] return [ln.rstrip("\n") for ln in lines[-max_lines:]] def read_new(self) -> Iterable[tuple[str, str]]: """Yields (tag, line) for every line appended since the last call.""" for tag, path in self._sources.items(): if not path.exists(): continue try: size = path.stat().st_size if size < self._offsets.get(tag, 0): self._offsets[tag] = 0 # file was truncated (process restarted) with open(path, "r", errors="replace") as f: f.seek(self._offsets[tag]) new_text = f.read() self._offsets[tag] = f.tell() except OSError: continue if not new_text: continue for line in new_text.splitlines(): if line: yield tag, line