"""Sensor Node: ticks independently, produces context for other nodes.""" import asyncio import logging import time from datetime import datetime from zoneinfo import ZoneInfo from .base import Node log = logging.getLogger("runtime") BERLIN = ZoneInfo("Europe/Berlin") class SensorNode(Node): name = "sensor" def __init__(self, send_hud): super().__init__(send_hud) self.tick_count = 0 self.running = False self._task: asyncio.Task | None = None self.interval = 5 self.readings: dict[str, dict] = {} self._last_user_activity: float = time.time() self._prev_memo_state: dict = {} self._was_idle = False # True when user crossed idle threshold self._idle_threshold = 30 # seconds before considered "away" self._browser_dashboard: list = [] # last reported by browser self._flags: list[dict] = [] # pending flags for Director def _now(self) -> datetime: return datetime.now(BERLIN) def _read_clock(self) -> dict: now = self._now() current = now.strftime("%H:%M") prev = self.readings.get("clock", {}).get("value") if current != prev: return {"value": current, "detail": now.strftime("%Y-%m-%d %H:%M:%S %A"), "changed_at": time.time()} return {} def _read_idle(self) -> dict: idle_s = time.time() - self._last_user_activity thresholds = [30, 60, 300, 600, 1800] prev_idle = self.readings.get("idle", {}).get("_raw", 0) for t in thresholds: if prev_idle < t <= idle_s: if idle_s < 60: label = f"{int(idle_s)}s" else: label = f"{int(idle_s // 60)}m{int(idle_s % 60)}s" return {"value": label, "_raw": idle_s, "changed_at": time.time()} if "idle" in self.readings: self.readings["idle"]["_raw"] = idle_s return {} def _read_memo_changes(self, memo_state: dict) -> dict: changes = [] for k, v in memo_state.items(): prev = self._prev_memo_state.get(k) if v != prev and prev is not None: changes.append(f"{k}: {prev} -> {v}") self._prev_memo_state = dict(memo_state) if changes: return {"value": "; ".join(changes), "changed_at": time.time()} return {} def update_browser_dashboard(self, dashboard: list): """Called when browser reports its current workspace state.""" self._browser_dashboard = dashboard or [] def _read_workspace_mismatch(self, server_controls: list) -> dict: """Compare server-side controls vs browser-reported controls.""" if not server_controls and not self._browser_dashboard: return {} server_btns = sorted(c.get("label", "") for c in server_controls if c.get("type") == "button") browser_btns = sorted(c.get("label", "") for c in self._browser_dashboard if c.get("type") == "button") if server_btns and server_btns != browser_btns: detail = f"server={server_btns} browser={browser_btns}" return {"value": "mismatch", "detail": detail, "changed_at": time.time()} if server_btns and server_btns == browser_btns: # Clear previous mismatch if self.readings.get("workspace", {}).get("value") == "mismatch": return {"value": "synced", "changed_at": time.time()} return {} def _check_idle_return(self) -> dict | None: """Detect when user returns after being idle. Returns flag or None.""" idle_s = time.time() - self._last_user_activity if idle_s >= self._idle_threshold and not self._was_idle: self._was_idle = True return None # return detection happens in note_user_activity def note_user_activity(self): idle_s = time.time() - self._last_user_activity returned_after = idle_s if self._was_idle else 0 self._last_user_activity = time.time() self.readings["idle"] = {"value": "active", "_raw": 0, "changed_at": time.time()} if returned_after > 0: self._was_idle = False if returned_after >= self._idle_threshold: if returned_after < 60: label = f"{int(returned_after)}s" else: label = f"{int(returned_after // 60)}m{int(returned_after % 60)}s" flag = {"type": "idle_return", "away_duration": label, "away_seconds": returned_after, "changed_at": time.time()} self._flags.append(flag) self.readings["idle_return"] = {"value": label, "changed_at": time.time()} log.info(f"[sensor] user returned after {label} idle") async def tick(self, memo_state: dict, server_controls: list = None): self.tick_count += 1 deltas = {} for name, reader in [("clock", self._read_clock), ("idle", self._read_idle)]: update = reader() if update: self.readings[name] = {**self.readings.get(name, {}), **update} deltas[name] = update.get("value") or update.get("detail") memo_update = self._read_memo_changes(memo_state) if memo_update: self.readings["memo_delta"] = memo_update deltas["memo_delta"] = memo_update["value"] # Workspace mismatch detection (S3* continuous audit) if server_controls is not None: ws_update = self._read_workspace_mismatch(server_controls) if ws_update: self.readings["workspace"] = ws_update deltas["workspace"] = ws_update.get("detail") or ws_update.get("value") if ws_update.get("value") == "mismatch": self._flags.append({"type": "workspace_mismatch", "detail": ws_update.get("detail", ""), "changed_at": time.time()}) # Track idle threshold crossing self._check_idle_return() if deltas: await self.hud("tick", tick=self.tick_count, deltas=deltas) def consume_flags(self) -> list[dict]: """Return and clear pending flags for Director.""" flags = self._flags[:] self._flags.clear() return flags async def _loop(self, get_memo_state, get_server_controls): self.running = True await self.hud("started", interval=self.interval) try: while self.running: await asyncio.sleep(self.interval) try: await self.tick(get_memo_state(), server_controls=get_server_controls()) except Exception as e: log.error(f"[sensor] tick error: {e}") except asyncio.CancelledError: pass finally: self.running = False await self.hud("stopped") def start(self, get_memo_state, get_server_controls=None): if self._task and not self._task.done(): return if get_server_controls is None: get_server_controls = lambda: [] self._task = asyncio.create_task(self._loop(get_memo_state, get_server_controls)) def stop(self): self.running = False if self._task: self._task.cancel() def get_context_lines(self) -> list[str]: if not self.readings: return ["Sensors: (no sensor node running)"] lines = [f"Sensors (tick #{self.tick_count}, {self.interval}s interval):"] for name, r in self.readings.items(): if name.startswith("_"): continue val = r.get("value", "?") detail = r.get("detail") age = time.time() - r.get("changed_at", time.time()) if age < 10: age_str = "just now" elif age < 60: age_str = f"{int(age)}s ago" else: age_str = f"{int(age // 60)}m ago" line = f"- {name}: {detail or val} [{age_str}]" lines.append(line) return lines