diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..b01ac37 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,34 @@ +--- +name: verify +description: How to build, launch, and drive MeshTerm to verify changes at runtime. +--- + +# Verifying MeshTerm changes + +## Launch + +- Venv interpreter: `$DEV_VENV_PYTHON` +- Run the app as a module: `python -m meshterm …` (no install step needed). +- `--mock` swaps in the `MockDevice` simulator (four fake nodes streaming + adverts/telemetry, packets within a second) — no hardware required. It still + writes to the **real default database**, so expect run ids / totals to grow. +- Real hardware is available too: two BLE MeshCore companions + (`$DEV_BLE_COMPANION_PAIRED` is PIN-protected, `$DEV_BLE_COMPANION_OPEN` is open). + +## Surfaces + +- **CLI subcommands** (`meshterm --mock …`) run in a plain console and + are fully drivable from the PowerShell tool. Good first stop for any change + that has a CLI path. `--help` / bad flags exercise Typer validation. +- **Interactive TUI** (bare `meshterm`) is a prompt_toolkit full-screen app. + It CANNOT be driven headlessly on this machine: without a real Windows + console it dies with `NoConsoleScreenBufferError` (no winpty/pexpect in the + venv). Verify TUI-only behavior via the CLI-reachable pieces and note the + gap; the user runs the TUI on real hardware themselves. + +## Gotchas + +- Time-unbounded commands (e.g. `monitor` / `chat listen` with `--seconds 0`, + the default) block until Ctrl-C — always pass an explicit `--seconds N`. +- INFO log lines (event hub / monitor) print to the console in CLI mode; + that's normal outside the full-screen session. diff --git a/README.md b/README.md index b2c1d52..643fd0a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ turned into interactive HTML visualizations. | **TX-power optimization** | ✅ working | Sweep transmit power (coarse + refine), converge on the best signal, optionally apply, render an interactive chart. | | **Link budget** | ✅ working | Predict time-on-air, receiver sensitivity, range, and duty-cycle/dwell headroom for a radio config — offline, no radio needed. | | **Chat** | ✅ working | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. Every message is logged to SQLite, unread counts show in the menu header, and `chat send/history/list` script the same from the CLI. Channels are picked here; edit them in `config`. | -| **Passive monitor** | ✅ working | Toggle a non-blocking background logger that records every overheard advert/telemetry (SNR, RSSI, location) to a longitudinal history while you keep using the app; on/off is remembered between sessions and live packet counts show in the menu. | +| **Passive monitor** | ✅ working | Always-on background logger that records every overheard advert/telemetry (SNR, RSSI, location) to a longitudinal history while you use the app; live packet counts show in the menu header, the heard-node summary is a menu item, and `meshterm monitor --seconds 60` captures a bounded window from the CLI. | | **Link health** | ✅ working | Compare a target's recent traces to its rolling baseline and flag SNR/reliability regressions, end-to-end and per hop. | | **Route map** | ✅ working | Aggregate trace history into an interactive route-stability graph — which links are stable, which the mesh flaps between, with churn metrics. | | **Path optimization** | 🚧 planned | Discover and score mesh paths from neighbors outward. | diff --git a/meshterm/context.py b/meshterm/context.py index 958e2f6..b26e7d2 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -187,16 +187,12 @@ class AppContext: """Return the session's passive-monitor service, creating it on first use. The service is built lazily so the (cheap) database read for the "total" - observation count and the on/off preference load happen only once, when - monitoring is first referenced. + observation count happens only once, when monitoring is first referenced. """ if self._monitor is None: - from .core.monitor_store import MonitorStore from .services.monitor_service import MonitorService - self._monitor = MonitorService( - self, MonitorStore(self.settings.config_dir / "monitor.json") - ) + self._monitor = MonitorService(self) return self._monitor @property diff --git a/meshterm/core/monitor_store.py b/meshterm/core/monitor_store.py deleted file mode 100644 index 9dc2534..0000000 --- a/meshterm/core/monitor_store.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Persistence for the passive-monitor on/off preference. - -Whether background monitoring is enabled is remembered between sessions in a small JSON -file in the config directory (``/monitor.json``), following the same pattern -as :class:`~meshterm.core.device_store.DeviceStore`: it is machine/user state managed by -the app rather than user-authored config, and it must survive a per-invocation ``--db`` -override, so it lives beside the config rather than in the swappable database. - -The preference defaults to *off*: a fresh install never captures until the user opts in. -""" - -from __future__ import annotations - -import json -from pathlib import Path - - -class MonitorStore: - """Reads and writes the remembered passive-monitor on/off preference.""" - - def __init__(self, path: Path) -> None: - """Open the store against a JSON file location. - - Args: - path: Path to the JSON state file (created lazily on first write). - """ - self._path = path - - def load_enabled(self) -> bool: - """Return whether monitoring is enabled, defaulting to ``False``. - - A missing or corrupt file is treated as "disabled" rather than an error, so a - stray edit never blocks startup and never silently starts capturing. - - Returns: - ``True`` if monitoring was left enabled, otherwise ``False``. - """ - try: - data = json.loads(self._path.read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return bool(data.get("enabled", False)) if isinstance(data, dict) else False - - def save_enabled(self, enabled: bool) -> None: - """Persist the monitoring on/off preference. - - Args: - enabled: Whether background monitoring should be on for future sessions. - """ - self._path.parent.mkdir(parents=True, exist_ok=True) - # Atomic-ish replace so a crash mid-write can't leave a truncated file. - tmp = self._path.with_suffix(self._path.suffix + ".tmp") - tmp.write_text(json.dumps({"enabled": bool(enabled)}, indent=2), encoding="utf-8") - tmp.replace(self._path) diff --git a/meshterm/services/monitor_service.py b/meshterm/services/monitor_service.py index 56db628..cdf229e 100644 --- a/meshterm/services/monitor_service.py +++ b/meshterm/services/monitor_service.py @@ -5,13 +5,14 @@ database, building the longitudinal history the coverage map and link-quality al read back. It is not a listener in its own right: the always-on :class:`~meshterm.services.event_hub.EventHub` (``ctx.events``) does the listening, and this service is simply one of its subscribers — the one that writes observations to the -database. Listening is therefore unconditional; the on/off preference this service owns -controls only whether what's heard is *persisted to history*. +database. Recording is always on: there is no user-facing switch. The subscription is +registered at session start without touching the device, so observations flow the moment +the hub is pumping — including when the hub itself opens lazily (``connect_on_start`` +off, or no device selected yet). The service is session-scoped state on the :class:`~meshterm.context.AppContext` -(``ctx.monitor``). It owns the on/off preference (persisted via -:class:`~meshterm.core.monitor_store.MonitorStore`), the hub subscription that records -observations, and the counters shown live in the menu. +(``ctx.monitor``). It owns the hub subscription that records observations and the +counters shown live in the menu header. """ from __future__ import annotations @@ -23,27 +24,22 @@ from ..core.events import EventKind, MeshEvent if TYPE_CHECKING: from ..context import AppContext - from ..core.monitor_store import MonitorStore class MonitorService: """Records overheard packets to history as a subscriber of the always-on event hub. Attributes are private; interact through the properties and the async lifecycle - methods (:meth:`enable`, :meth:`disable`, :meth:`toggle`, :meth:`start`, :meth:`stop`, - :meth:`aclose`). + methods (:meth:`start`, :meth:`stop`, :meth:`aclose`). """ - def __init__(self, ctx: "AppContext", store: "MonitorStore") -> None: - """Initialize the service and load the persisted on/off preference. + def __init__(self, ctx: "AppContext") -> None: + """Initialize the (idle) service. Args: ctx: The shared application context (device, repository, logger). - store: Persistence for the on/off preference. """ self._ctx = ctx - self._store = store - self._enabled = store.load_enabled() self._unsubscribe: Optional[Unsubscribe] = None # hub subscription, when recording self._run_id: Optional[int] = None self._session_count = 0 @@ -52,12 +48,6 @@ class MonitorService: # "total" is this plus what we capture this session (this process is the only # writer during an interactive session), avoiding a DB count on every repaint. self._start_total = ctx.repo.observation_count() - self._error: Optional[str] = None - - @property - def enabled(self) -> bool: - """Whether monitoring is the desired state (persisted across sessions).""" - return self._enabled @property def active(self) -> bool: @@ -69,11 +59,6 @@ class MonitorService: """Observations captured since this process started.""" return self._session_count - @property - def last_error(self) -> Optional[str]: - """The reason the last :meth:`start` attempt failed, if any.""" - return self._error - def total_count(self) -> int: """Return the total observations logged, all time (including this session).""" return self._start_total + self._session_count @@ -81,41 +66,31 @@ class MonitorService: def status_text(self) -> str: """Return a compact one-line status for the live menu header. + Monitoring has no off state, so this reports activity rather than a switch: + capture counts while the hub is pumping, or a waiting note until a device link + gives it something to hear. + Returns: - A glyph-prefixed summary: capture counts when active, a waiting note when - enabled but not yet capturing, or an off marker. + A glyph-prefixed summary line. """ - if self.active: + if self._ctx.events.active: return ( - f"● monitor ON · {self._session_count} this session " + f"● heard {self._session_count} this session " f"· {self.total_count()} total" ) - if self._enabled: - return "● monitor ON (waiting for a device)" - return "○ monitor OFF" + return f"○ heard {self.total_count()} all-time · waiting for a device" async def start(self) -> None: """Begin recording overheard observations to history. Idempotent. - A no-op if already recording. Ensures the always-on event hub is running (which - opens the device connection and may raise if no device can be selected), then - subscribes to its observation stream and opens a ``monitor`` run for the recorded - observations to link to. - - Raises: - Exception: Propagates any device/hub error after remembering it; the on/off - preference is left unchanged so the caller can surface the problem. + Registers the recording subscription on the event hub without touching the + device, so it is safe (and cheap) to call before any connection exists; + observations flow as soon as the hub is pumping. The backing ``monitor`` run row + is opened lazily on the first observation, so a session that hears nothing + leaves no empty run in history. """ if self.active: return - try: - await self._ctx.events.start() - except Exception as exc: # noqa: BLE001 - remember why, then re-raise - self._error = str(exc) - raise - run_id = self._ctx.repo.start_run( - "monitor", {"mode": "background"}, self._ctx.profile_name - ) self._run_start_count = self._session_count def on_event(event: MeshEvent) -> None: @@ -126,14 +101,16 @@ class MonitorService: return self._session_count += 1 try: - self._ctx.repo.record_observation(run_id, obs) + if self._run_id is None: + self._run_id = self._ctx.repo.start_run( + "monitor", {"mode": "background"}, self._ctx.profile_name + ) + self._ctx.repo.record_observation(self._run_id, obs) except Exception as exc: # noqa: BLE001 - never let logging break capture self._ctx.log.debug("monitor: failed to record observation: %s", exc) self._unsubscribe = self._ctx.events.subscribe(on_event, EventKind.OBSERVATION) - self._run_id = run_id - self._error = None - self._ctx.log.info("passive monitor recording (run %s)", run_id) + self._ctx.log.info("passive monitor recording") async def stop(self) -> None: """Stop recording to history and close the run record. Idempotent. @@ -154,38 +131,6 @@ class MonitorService: self._ctx.log.info("passive monitor stopped (run %s, %s pkts)", self._run_id, captured) self._run_id = None - async def enable(self) -> None: - """Turn monitoring on, persist the preference, and start capturing. - - Raises: - Exception: If capture could not start (e.g. no device); the preference is - still persisted as *on* so it resumes once a device is available. - """ - self._enabled = True - self._store.save_enabled(True) - await self.start() - - async def disable(self) -> None: - """Turn monitoring off, persist the preference, and stop capturing.""" - self._enabled = False - self._store.save_enabled(False) - await self.stop() - - async def toggle(self) -> bool: - """Flip the on/off state, starting or stopping capture accordingly. - - Returns: - The new enabled state (``True`` if now on). - - Raises: - Exception: Propagates a failure to start capture when turning on. - """ - if self._enabled: - await self.disable() - else: - await self.enable() - return self._enabled - async def aclose(self) -> None: - """Stop capture at session end without changing the persisted preference.""" + """Stop capture at session end.""" await self.stop() diff --git a/meshterm/tools/monitor.py b/meshterm/tools/monitor.py index c95bfe5..e115429 100644 --- a/meshterm/tools/monitor.py +++ b/meshterm/tools/monitor.py @@ -1,76 +1,56 @@ -"""The ``monitor`` tool: control the passive background logger and review its history. +"""The ``monitor`` tool: review what the always-on passive monitor has heard. Passive monitoring records every advert and telemetry frame the companion overhears — with SNR, RSSI, and any shared location — to the database, building the longitudinal history that the coverage map and link-quality alerting read back. It transmits nothing; it only listens. -Listening itself is always on: the session-wide +Recording is always on: the session-wide :class:`~meshterm.services.event_hub.EventHub` overhears every packet, and -:class:`~meshterm.services.monitor_service.MonitorService` (``ctx.monitor``) records -them to history as one of its subscribers. This tool is the control panel for that -recording: toggling it on/off (a preference remembered between sessions) and reviewing the -heard-node history. The live packet counters are shown in the main-menu header, not here. +:class:`~meshterm.services.monitor_service.MonitorService` (``ctx.monitor``) writes them +to history as one of its subscribers, from the moment the radio opens. There is nothing +to switch, so in the menu this tool is purely the read side: the all-time heard-node +summary (the live packet counters are shown in the main-menu header, not here). On the +CLI it is instead a bounded foreground capture — ``meshterm monitor --seconds 60`` tails +each overheard packet to the console and summarizes the window when it ends. """ from __future__ import annotations -from typing import Any, Optional +import asyncio +from typing import Any import typer from rich.table import Table from rich.text import Text from ..context import AppContext -from ..core.models import HeardNode -from ..ui.tui import Choice, Separator +from ..core.events import EventKind, MeshEvent +from ..core.models import HeardNode, Observation from .base import Tool, ToolResult, register @register class MonitorTool(Tool): - """Toggle the passive background monitor and review the nodes it has heard.""" + """Review the nodes the always-on passive monitor has heard.""" name = "monitor" - title = "Passive monitor" - help = "Toggle passive background monitoring and review heard nodes" + title = "Heard nodes" + help = "Review every node the passive monitor has overheard" category = "Diagnostics" order = 20 - async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]: - """Show the monitor status and offer to toggle it or review heard nodes. - - Args: - ctx: Shared application context. - - Returns: - A parameter dict naming the chosen ``action``, or ``None`` if cancelled. - """ - monitor = ctx.monitor - toggle_label = "Turn monitoring OFF" if monitor.enabled else "Turn monitoring ON" - choice = await ctx.ui.select( - f"Passive monitor — {monitor.status_text()}", - [ - Choice(toggle_label, value="toggle"), - Choice("View heard nodes (all time)", value="view"), - Separator(" "), - Choice("Back", value="__back__"), - ], - ) - if choice in (None, "__back__"): - return None - return {"action": choice} - async def execute(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: - """Run the control-panel action directly, without a logged ``runs`` row. + """Run directly, without a logged ``runs`` row. - The monitor entry is a control panel, not a measurement, so it is deliberately - not wrapped in run-logging (unlike the base :meth:`Tool.execute`). The background - capture session records its own ``monitor`` run instead. + The heard-node review is a read of existing history, not a measurement, so it is + deliberately not wrapped in run-logging (unlike the base :meth:`Tool.execute`). + The CLI capture's observations are recorded under the monitor service's own + ``monitor`` run instead. Args: ctx: Shared application context. - params: The chosen ``action``. + params: Parameters for this invocation. Returns: The :class:`ToolResult` from :meth:`run`. @@ -78,47 +58,95 @@ class MonitorTool(Tool): return await self.run(ctx, params) async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult: - """Dispatch the selected control-panel action. + """Show the heard-node review, or run the CLI's bounded capture. Args: ctx: Shared application context. - params: ``action`` — ``"toggle"`` or ``"view"``. + params: Empty for the menu's review; ``action="capture"`` plus ``seconds`` + for the CLI capture. Returns: A :class:`ToolResult` describing the outcome. """ - if params.get("action") == "view": - return self._view(ctx) - return await self._toggle(ctx) + if params.get("action") == "capture": + return await self._capture(ctx, params) + return self._view(ctx) @staticmethod - async def _toggle(ctx: AppContext) -> ToolResult: - """Flip background monitoring on or off, surfacing any start failure. + async def _capture(ctx: AppContext, params: dict[str, Any]) -> ToolResult: + """Record overheard packets in the foreground for a bounded window (CLI only). + + Connects the device, ensures history recording and the event hub are running, + and tails each overheard packet to the console until the window ends (or the + user interrupts). Observations are persisted by the monitor service exactly as + in an interactive session; this adds a live console view and a window-scoped + heard-node summary on top. Args: ctx: Shared application context. + params: ``seconds`` — how long to capture (``0``/``None`` = until Ctrl-C). Returns: - A :class:`ToolResult` reporting the new state. + A :class:`ToolResult` with the window's packet and node counts. """ - try: - now_on = await ctx.monitor.toggle() - except Exception as exc: # noqa: BLE001 - report cleanly, keep the menu alive - return ToolResult( - summary={"enabled": ctx.monitor.enabled, "active": ctx.monitor.active, - "error": str(exc)}, - message=f"[warn]monitoring enabled but capture couldn't start:[/warn] {exc}", + seconds = int(params.get("seconds") or 0) + await ctx.device() # surface connection problems before announcing the capture + await ctx.monitor.start() # register history recording before the hub pumps + await ctx.events.start() + ctx.console.print( + "[muted]monitoring" + f"{f' for {seconds}s' if seconds else ' — press Ctrl-C to stop'}…[/muted]" + ) + seen: list[Observation] = [] + + def on_observation(event: MeshEvent) -> None: + obs = event.observation + if obs is None: + return + seen.append(obs) + stamp = obs.observed_at.astimezone().strftime("%H:%M:%S") + who = obs.name or obs.node or "?" + snr = f" [muted]{obs.snr:+.1f} dB[/muted]" if obs.snr is not None else "" + rssi = f" [muted]{obs.rssi:.0f} dBm[/muted]" if obs.rssi is not None else "" + loc = " [ok]●[/ok]" if obs.lat is not None else "" + ctx.console.print( + f"[muted]{stamp}[/muted] [accent]{who}[/accent]{snr}{rssi}{loc}" ) - # No confirmation message: the new state is already shown live in the header's - # monitor indicator, so a result window here would just be redundant noise. - return ToolResult(summary={"enabled": now_on, "active": ctx.monitor.active}) + + unsubscribe = ctx.events.subscribe(on_observation, EventKind.OBSERVATION) + try: + if seconds: + await asyncio.sleep(seconds) + else: + await asyncio.Event().wait() # until Ctrl-C / cancellation + except (KeyboardInterrupt, asyncio.CancelledError): # pragma: no cover - interactive + pass + finally: + unsubscribe() + await ctx.monitor.stop() # close the run row so the capture is a full record + + by_node: dict[str, list[Observation]] = {} + for obs in seen: + by_node.setdefault(obs.node, []).append(obs) + nodes = [HeardNode.from_observations(node, group) for node, group in by_node.items()] + if nodes: + nodes.sort(key=lambda n: n.last_seen, reverse=True) + ctx.ui.show(_heard_table(nodes, lambda _node: None)) + return ToolResult( + summary={"seconds": seconds, "packets": len(seen), "nodes": len(nodes)}, + message=( + f"[ok]✓[/ok] heard [brand]{len(seen)}[/brand] " + f"packet{'' if len(seen) == 1 else 's'} from " + f"[brand]{len(nodes)}[/brand] node{'' if len(nodes) == 1 else 's'}" + ), + ) @staticmethod def _view(ctx: AppContext) -> ToolResult: """Render the all-time heard-node summary from stored observations. Reads only the database, so it needs no device connection and works whether or - not monitoring is currently active. + not packets are currently arriving. Args: ctx: Shared application context. @@ -131,7 +159,9 @@ class MonitorTool(Tool): # Names come from the stored observations, so no device lookup is needed. ctx.ui.show(_heard_table(heard, lambda _node: None)) else: - ctx.ui.note("[muted]no packets heard yet — turn monitoring on[/muted]") + ctx.ui.note( + "[muted]no packets heard yet — history accumulates while MeshTerm runs[/muted]" + ) packets = sum(n.count for n in heard) located = sum(1 for n in heard if n.has_location) @@ -148,44 +178,23 @@ class MonitorTool(Tool): ) def register_cli(self, app: typer.Typer) -> None: - """Register the ``monitor`` subcommand. + """Register the ``monitor`` subcommand (the bounded foreground capture). Args: app: The Typer application. """ - from ..core.monitor_store import MonitorStore + from ..cli import run_tool_command - @app.command(name=self.name, help=self.help) + @app.command( + name=self.name, + help="Capture overheard packets live for a while and summarize them", + ) def _monitor( - on: bool = typer.Option( - False, "--on", help="Enable background monitoring for future sessions" - ), - off: bool = typer.Option( - False, "--off", help="Disable background monitoring" + seconds: int = typer.Option( + 0, "--seconds", "-s", help="How long to capture (0 = until Ctrl-C)" ), ) -> None: - from ..cli import _state - - if on and off: - raise typer.BadParameter("Pass only one of --on / --off.") - assert _state is not None # set by the callback before any subcommand runs - ctx = _state - store = MonitorStore(ctx.settings.config_dir / "monitor.json") - - if on or off: - store.save_enabled(on) - state = "[ok]on[/ok]" if on else "[muted]off[/muted]" - ctx.console.print( - f"[ok]✓[/ok] background monitoring set {state} for future " - "interactive sessions" - ) - return - # No flags: report the current preference and history size. - enabled = "[ok]on[/ok]" if store.load_enabled() else "[muted]off[/muted]" - ctx.console.print( - f"background monitoring is {enabled} · " - f"[brand]{ctx.repo.observation_count()}[/brand] observations logged all-time" - ) + run_tool_command(self, {"action": "capture", "seconds": seconds}) def _heard_table(heard: list[HeardNode], resolve) -> Table: # noqa: ANN001 diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 88a7f0e..684b64c 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -97,8 +97,8 @@ def _silence_console_logging() -> Iterator[None]: The full-screen session owns the terminal via prompt_toolkit; any handler that writes log lines to the same console (the :class:`RichHandler` installed by - :func:`configure_logging`) corrupts the frame — most visibly when toggling the - monitor, which connects the device and emits INFO records on demand. File logging is + :func:`configure_logging`) corrupts the frame — most visibly when the monitor + connects the device at startup and emits INFO records. File logging is untouched, so the JSON-lines record stays complete; the handlers are restored on exit. """ logger = get_logger() @@ -132,11 +132,11 @@ def _header(ctx: AppContext) -> Text: target = "[muted]no device[/muted]" unread = ctx.chat.unread_total() chat_segment = f" · [accent]✉ {unread} unread[/accent]" if unread else "" - # Colour only the leading status glyph (● / ○) — green when the monitor is on - # (enabled), red when off — leaving the rest of the text muted as before. + # Colour only the leading status glyph (● / ○) — green while packets are being + # heard and recorded, muted while the hub still waits for a device link. status = ctx.monitor.status_text() glyph, rest = status[:1], status[1:] - glyph_style = "ok" if ctx.monitor.enabled else "err" + glyph_style = "ok" if ctx.events.active else "muted" # The frame crops this to a single line (see frame.compose_base), so a narrow terminal # shows what fits and chops the rest rather than wrapping onto a second row. return Text.from_markup( @@ -395,36 +395,32 @@ async def _startup(ctx: AppContext) -> bool: async def _resume_monitor(ctx: AppContext) -> None: - """Start always-on background listening, and resume history recording if enabled. + """Start always-on background listening and history recording. - A MeshCore client always listens while it runs, so by default the event hub is - started unconditionally at launch; if the passive-monitor preference is on, history - recording is resumed on top of it. The ``connect_on_start`` setting can defer that - eager connect — when it is off, the hub is left idle here and opens lazily instead - (when recording is turned on, or a tool first needs the radio). A failure to start - (typically no companion device selected) is non-fatal: the preference stays on, so - recording resumes automatically once a device is available. The header reflects the - resulting state, so nothing needs to be printed here. + Recording has no switch: the monitor registers its hub subscription up front — a + device-free, in-process step that cannot fail — so every overheard packet is logged + from the moment the radio opens. The hub itself (a MeshCore client must always be + listening) is started eagerly here unless the ``connect_on_start`` setting defers + it, in which case it opens lazily when a tool first needs the radio and recording + picks up then. A failure to start the hub (typically no companion device selected) + is non-fatal; the header reflects the resulting state, so nothing needs to be + printed here. Args: ctx: The shared application context. """ - if ctx.settings.connect_on_start: - try: - await ctx.events.start() - except Exception: # noqa: BLE001 - surface via the header, don't crash the menu - pass - # Record inbound messages from launch so the inbox and unread badge stay current - # even before the chat screen is opened. Deferred (like the hub) when connect on - # start is off; the chat screen starts it lazily then. - try: - await ctx.chat.start() - except Exception: # noqa: BLE001 - surface via the header, don't crash the menu - pass - if not ctx.monitor.enabled: + await ctx.monitor.start() + if not ctx.settings.connect_on_start: return try: - await ctx.monitor.start() + await ctx.events.start() + except Exception: # noqa: BLE001 - surface via the header, don't crash the menu + pass + # Record inbound messages from launch so the inbox and unread badge stay current + # even before the chat screen is opened. Deferred (like the hub) when connect on + # start is off; the chat screen starts it lazily then. + try: + await ctx.chat.start() except Exception: # noqa: BLE001 - surface via the header, don't crash the menu pass diff --git a/tests/test_disconnect.py b/tests/test_disconnect.py index cd496c6..5d74f07 100644 --- a/tests/test_disconnect.py +++ b/tests/test_disconnect.py @@ -302,7 +302,7 @@ async def test_reconnect_rebuilds_device_and_restores_services(tmp_path: Path) - ctx = _make_ctx(tmp_path) try: await ctx.events.start() - await ctx.monitor.enable() # start recording (also starts the hub) + await ctx.monitor.start() # start recording on the already-running hub await ctx.chat.start() original = await ctx.device() assert ctx.events.active and ctx.monitor.active and ctx.chat.active @@ -400,7 +400,7 @@ async def test_reconnect_restores_services_after_failed_attempts( ctx = _make_ctx(tmp_path) try: await ctx.events.start() - await ctx.monitor.enable() + await ctx.monitor.start() await ctx.chat.start() await ctx.device() assert ctx.events.active and ctx.monitor.active and ctx.chat.active diff --git a/tests/test_monitor.py b/tests/test_monitor.py index eb629f2..f4821b0 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -11,8 +11,6 @@ import logging from datetime import timedelta from pathlib import Path -import pytest - from meshterm.core.connection import ( _MOCK_MONITOR_INTERVAL_S as _MOCK_INTERVAL, ) @@ -23,7 +21,6 @@ from meshterm.core.connection import ( observation_from_event, ) from meshterm.core.models import HeardNode, Observation, utcnow -from meshterm.core.monitor_store import MonitorStore from meshterm.persistence.repository import Repository from meshterm.services.monitor_service import MonitorService @@ -258,36 +255,24 @@ def test_observation_count_totals_every_run(tmp_path: Path) -> None: repo.close() -def test_monitor_store_defaults_off_and_persists(tmp_path: Path) -> None: - """The preference defaults to off and round-trips through a fresh store instance.""" - path = tmp_path / "monitor.json" - assert MonitorStore(path).load_enabled() is False # nothing saved yet → off - MonitorStore(path).save_enabled(True) - assert MonitorStore(path).load_enabled() is True # remembered across instances - MonitorStore(path).save_enabled(False) - assert MonitorStore(path).load_enabled() is False - - -async def test_monitor_service_captures_in_background(tmp_path: Path) -> None: - """Enabling starts a live subscription that logs observations without blocking.""" +async def test_monitor_service_records_while_hub_pumps(tmp_path: Path) -> None: + """start() subscribes before the hub opens, then logs observations once it pumps.""" repo = Repository(tmp_path / "svc.db") ctx = _StubContext(repo, MockDevice()) - store = MonitorStore(tmp_path / "monitor.json") - service = MonitorService(ctx, store) + service = MonitorService(ctx) - assert not service.enabled and not service.active - await service.enable() - assert service.enabled and service.active - assert store.load_enabled() is True # preference persisted for next session + assert not service.active + await service.start() # device-free: no connection has been opened yet + assert service.active + await ctx.events.start() # the hub opens; recording catches the initial burst await asyncio.sleep(_MOCK_INTERVAL * 3) # let packets stream in while we "do other work" assert service.session_count > 0 # Started from an empty database, so total equals what this session captured. assert service.total_count() == service.session_count - await service.disable() - assert not service.enabled and not service.active - assert store.load_enabled() is False + await service.stop() + assert not service.active assert repo.observation_count() == service.session_count # everything was logged frozen = service.session_count @@ -301,19 +286,22 @@ async def test_monitor_service_captures_in_background(tmp_path: Path) -> None: repo.close() -async def test_monitor_service_enable_without_device_keeps_preference(tmp_path: Path) -> None: - """If capture can't start, the on preference is still remembered so it can resume.""" +async def test_monitor_service_start_without_device_is_safe(tmp_path: Path) -> None: + """Recording can start before any device exists, and a silent session leaves no run.""" class _NoDevice(_StubContext): async def device(self) -> MockDevice: raise ValueError("no companion device selected") repo = Repository(tmp_path / "nodev.db") - service = MonitorService(_NoDevice(repo, MockDevice()), MonitorStore(tmp_path / "m.json")) + service = MonitorService(_NoDevice(repo, MockDevice())) - with pytest.raises(ValueError): - await service.enable() - assert service.enabled is True # preference stays on to resume later - assert service.active is False - assert service.last_error == "no companion device selected" + await service.start() # never touches the device, so this cannot fail + assert service.active + assert service.session_count == 0 + + await service.stop() + assert not service.active + assert repo.observation_count() == 0 + assert repo.list_runs() == [] # the run row is opened lazily, so none was created repo.close()