Files
MeshTerm/meshterm/services/monitor_service.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

295 lines
14 KiB
Python

"""The passive-monitor history logger.
Passive monitoring records every advert/telemetry frame the companion overhears to the
database, building the longitudinal history the coverage map and link-quality alerting
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. 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 hub subscription that records observations and the
counters shown live in the menu header.
"""
from __future__ import annotations
import asyncio
import time
from datetime import timedelta
from typing import TYPE_CHECKING
from ..core.connection import Unsubscribe
from ..core.events import EventKind, MeshEvent
from ..core.models import Observation, utcnow
if TYPE_CHECKING:
from ..context import AppContext
#: Seconds per bucket of the all-packet activity histogram — one minute, one braille
#: dot column of the header indicator and the dashboard's activity chart.
ACTIVITY_BUCKET_S = 60
#: How many one-minute buckets the histogram retains — six hours, deep enough that the
#: header and the dashboard can both stretch their charts to fill any realistic terminal
#: width (two buckets per character cell) and still be drawing history, not padding.
ACTIVITY_BUCKETS = 360
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:`start`, :meth:`stop`, :meth:`aclose`).
"""
def __init__(self, ctx: AppContext) -> None:
"""Initialize the (idle) service.
Args:
ctx: The shared application context (device, repository, logger).
"""
self._ctx = ctx
self._unsubscribe: Unsubscribe | None = None # hub subscription, when recording
self._count_unsubscribe: Unsubscribe | None = None # the all-packet counter's
self._run_id: int | None = None
self._session_count = 0
self._run_start_count = 0
# Observations queue off the event-loop callback onto this worker (see start()),
# so a burst of overheard packets writes to disk between repaints instead of
# blocking them — the same shape as ChatService's inbound queue.
self._queue: asyncio.Queue[Observation] | None = None
self._worker: asyncio.Future | None = None
# All-packet activity, bucketed by wall-clock minute (epoch // span → count).
# Every hub event counts — observations, messages, acks — because the header's
# indicator answers "is the mesh alive?", not "any mail?". Pruned as it rolls,
# so it never holds more than the six-hour window plus one closing bucket.
self._activity: dict[int, int] = {}
# The last histogram/flags tuples, keyed by what they derive from — the current
# minute bucket and (for the histogram) the packet-arrival stamp below. The header
# rebuilds both on every repaint of every screen; between packets and minute
# rolls that was 720 dict lookups a frame for identical tuples.
self._activity_stamp = 0
self._histogram_cache: tuple[int, int, tuple[int, ...]] = (-1, -1, ())
self._flags_cache: tuple[int, tuple[bool, ...]] = (-1, ())
# Tallies by packet class (advert/telemetry/packet/message/ack): seeded from
# stored history below, then fed live by the kind-unfiltered subscription —
# the dashboard's traffic panel, persistent across sessions.
self._kind_counts: dict[str, int] = {}
# Housekeeping: age out observations past the retention window once per
# session, so an always-recording database stays bounded (0 = keep forever).
preferences = getattr(ctx, "preferences", None)
days = (preferences.history_days if preferences is not None else 0) or 0
if days:
try:
pruned = ctx.repo.prune_observations(utcnow() - timedelta(days=days))
if pruned:
ctx.log.info("history housekeeping: pruned %s observations", pruned)
except Exception as exc: # noqa: BLE001 - housekeeping must never block startup
ctx.log.debug("history housekeeping failed: %s", exc)
# Total observations already in the database when the session began; the live
# "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()
# The minute this session began: buckets at or after it hold live traffic,
# older ones the seeded history — the boundary the activity charts colour by.
self._start_bucket = int(time.time() // ACTIVITY_BUCKET_S)
self._seed_from_history()
def _seed_from_history(self) -> None:
"""Warm the activity buckets and kind tallies from stored observations.
The dashboard's persistence: a fresh session opens mid-story — the activity
chart already showing the trailing six hours and the traffic panel its
all-history tallies — instead of an empty chart that only fills while the app
happens to be running. Live events then stack on top (they are *new* rows, so
nothing double-counts). Stored history holds observations only; messages and
acks resume counting from zero each session.
"""
try:
window = timedelta(seconds=ACTIVITY_BUCKET_S * ACTIVITY_BUCKETS)
for obs in self._ctx.repo.recent_observations(since=utcnow() - window):
bucket = int(obs.observed_at.timestamp() // ACTIVITY_BUCKET_S)
self._activity[bucket] = self._activity.get(bucket, 0) + 1
self._kind_counts = dict(self._ctx.repo.kind_counts())
except Exception as exc: # noqa: BLE001 - a cold start is worse than a blank chart
self._ctx.log.debug("monitor: history seed failed: %s", exc)
@property
def active(self) -> bool:
"""Whether observations are currently being recorded to history."""
return self._unsubscribe is not None
@property
def session_count(self) -> int:
"""Observations captured since this process started."""
return self._session_count
def total_count(self) -> int:
"""Return the total observations logged, all time (including this session)."""
return self._start_total + self._session_count
def activity_histogram(self) -> tuple[int, ...]:
"""All-packet counts per one-minute bucket over the trailing six hours.
Newest first — index 0 is the current minute — the order the chart widgets
expect (they draw "now" at the right edge). Seeded from stored observations at
session start and fed live by everything the hub fans out, so the header's
pulse and the dashboard's chart open mid-story after a restart. Consumers
slice however much of the window fits their chart and treat the rest as
history in reserve.
Returns:
:data:`ACTIVITY_BUCKETS` bucket counts.
"""
bucket = int(time.time() // ACTIVITY_BUCKET_S)
stamp = self._activity_stamp
cached_bucket, cached_stamp, histogram = self._histogram_cache
if bucket != cached_bucket or stamp != cached_stamp:
histogram = tuple(self._activity.get(bucket - i, 0) for i in range(ACTIVITY_BUCKETS))
self._histogram_cache = (bucket, stamp, histogram)
return histogram
def activity_session_flags(self) -> tuple[bool, ...]:
"""Whether each histogram bucket holds this session's own traffic.
Aligned with :meth:`activity_histogram` (newest first): ``True`` for buckets
at or after the session's first minute, ``False`` for the seeded history —
the split the activity charts use to draw live traffic green and a previous
session's grey.
Returns:
:data:`ACTIVITY_BUCKETS` flags, newest first.
"""
bucket = int(time.time() // ACTIVITY_BUCKET_S)
cached_bucket, flags = self._flags_cache
if bucket != cached_bucket:
flags = tuple(bucket - i >= self._start_bucket for i in range(ACTIVITY_BUCKETS))
self._flags_cache = (bucket, flags)
return flags
def kind_counts(self) -> dict[str, int]:
"""Tallies by packet class: advert/telemetry/packet buckets, message, ack.
Seeded from stored history at session start and grown live from there, so the
numbers describe everything the recorder retains, not just this session.
Observation classes come from the packet itself (``Observation.kind``) — a raw
``packet`` frame with a parsed payload class buckets as ``packet:<TYPENAME>``,
matching :meth:`~meshterm.persistence.repository.Repository.kind_counts`'s
stored seed — and messages and acks are their own classes. A copy, safe to
mutate.
"""
return dict(self._kind_counts)
def _count_packet(self, event: MeshEvent) -> None:
"""Land one packet in the current activity bucket (and prune scrolled-off ones)."""
bucket = int(time.time() // ACTIVITY_BUCKET_S)
self._activity[bucket] = self._activity.get(bucket, 0) + 1
self._activity_stamp += 1 # invalidates the memoized histogram tuple
obs = event.observation
kind = obs.kind if obs is not None else event.kind.value
if obs is not None and kind == "packet":
# Bucket a classed raw frame by its payload class (the stored seed's shape).
typename = (obs.raw or {}).get("payload_typename")
if typename:
kind = f"packet:{typename}"
self._kind_counts[kind] = self._kind_counts.get(kind, 0) + 1
if len(self._activity) > ACTIVITY_BUCKETS + 1:
cutoff = bucket - ACTIVITY_BUCKETS
for stale in [b for b in self._activity if b < cutoff]:
del self._activity[stale]
async def start(self) -> None:
"""Begin recording overheard observations to history. Idempotent.
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
self._run_start_count = self._session_count
self._queue = asyncio.Queue()
self._worker = asyncio.ensure_future(self._process_observations())
def on_event(event: MeshEvent) -> None:
# Runs on the event loop as packets arrive; keep it cheap and non-blocking. It
# only hands the observation to the worker queue — opening the run row and the
# database write happen off the worker, so a burst of overheard packets (every
# advert/telemetry/RX-log frame the mesh produces) can never stall the render
# and input loop the way a synchronous commit per packet would.
obs = event.observation
if obs is None:
return
self._session_count += 1
queue = self._queue
if queue is not None:
queue.put_nowait(obs)
self._unsubscribe = self._ctx.events.subscribe(on_event, EventKind.OBSERVATION)
# A second, kind-unfiltered subscription feeds the header's activity indicator:
# every packet the hub hears lands in a five-minute bucket, in memory only.
self._count_unsubscribe = self._ctx.events.subscribe(self._count_packet)
self._ctx.log.info("passive monitor recording")
async def _process_observations(self) -> None:
"""Serially record queued observations, opening the run row on the first one.
A single worker drains the queue so recording never races the run-row creation and
the database write never runs inline with the hub's synchronous event dispatch (see
:meth:`start`) — the same shape as :class:`~meshterm.services.chat_service.ChatService`'s
inbound worker.
"""
assert self._queue is not None
while True:
obs = await self._queue.get()
try:
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)
finally:
self._queue.task_done()
async def stop(self) -> None:
"""Stop recording to history and close the run record. Idempotent.
A no-op if not recording. The event hub keeps listening; only this service's
recording subscription and its recording worker are removed.
"""
if not self.active:
return
try:
assert self._unsubscribe is not None
self._unsubscribe()
finally:
self._unsubscribe = None
if self._count_unsubscribe is not None:
self._count_unsubscribe()
self._count_unsubscribe = None
if self._worker is not None:
self._worker.cancel()
try:
await self._worker
except asyncio.CancelledError:
pass
self._worker = None
self._queue = None
if self._run_id is not None:
captured = self._session_count - self._run_start_count
self._ctx.repo.finish_run(self._run_id, "ok", {"observations": captured})
self._ctx.log.info("passive monitor stopped (run %s, %s pkts)", self._run_id, captured)
self._run_id = None
async def aclose(self) -> None:
"""Stop capture at session end."""
await self.stop()