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:
1 parent
12188afaa0
commit
0f49cf0a2d
9 files changed
+212
-298
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user