Passive monitoring is always on; CLI monitor becomes a timed capture

The on/off switch never made sense for a pure listener: recording is
now unconditional, so the toggle submenu, the persisted preference
(MonitorStore/monitor.json), and the header's ON/OFF framing are gone.
The monitor registers its hub subscription at startup — a device-free
step that cannot fail — and its run row opens lazily on the first
observation, so recording follows the hub wherever it opens (eagerly,
or deferred by connect_on_start) and silent sessions leave no empty
runs. The menu entry is now just the read side, "Heard nodes", and the
header shows live heard counters instead of a switch state.

meshterm monitor on the CLI drops --on/--off for a bounded foreground
capture (--seconds N, 0 = until Ctrl-C, mirroring chat listen): it
tails each overheard packet live and summarizes the window's heard
nodes when it ends, while persisting history exactly like a session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-10 02:07:23 -04:00
1 parent 12188afaa0
commit 0f49cf0a2d
9 files changed
+212 -298

No files matched your search

+34
View File
@@ -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 <tool> …`) 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.
+1 -1
View File
@@ -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. |
+2 -6
View File
@@ -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
-54
View File
@@ -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 (``<config_dir>/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)
+29 -84
View File
@@ -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()
+100 -91
View File
@@ -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
+24 -28
View File
@@ -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
+2 -2
View File
@@ -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
+20 -32
View File
@@ -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()