The prose of each docstring and comment in the core package now follows ASD-STE100 Simplified Technical English, with Canadian spelling: short sentences, simple verb tenses, no dashes or semicolons, and one word for one concept (docs/development/writing-style.md has the rules and the glossary). The meaning and each reason stay. The code does not change: the AST of each file, without its docstrings, is the same as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
496 lines
19 KiB
Python
496 lines
19 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""The store for the Watchtower: the watched nodes, their rules, and the alert log.
|
|
|
|
The Watchtower (refer to :mod:`meshterm.services.watchtower`) is a passive monitor. The
|
|
user stars the nodes that are important to the user: for example, the repeater on the
|
|
roof, or the gateway on the other side of the town. Then rules watch what the event hub
|
|
already hears (the event hub is always on).
|
|
|
|
For each watched node, this store keeps the rule settings and the last-heard mark from
|
|
which the silence rule counts. The key of a watched node is the canonical 12-hex key
|
|
prefix that the observations use. The store also keeps the rolling alert log and its
|
|
acknowledged state. Thus an alarm that MeshTerm raised in the night is still there in the
|
|
morning, also after a restart.
|
|
|
|
This store is global machine state in a small JSON file (``<config_dir>/watchtower.json``),
|
|
the same as the remembered devices (:mod:`meshterm.core.device_store`). It is not in the
|
|
SQLite database, which can be different for each run. After the first read, the store
|
|
gives all reads from memory, because the header badge reads the unacked count at each
|
|
paint. Packets cause the ``note_heard`` writes, so the store throttles them. Thus a busy
|
|
mesh does not cause continuous disk writes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import ClassVar
|
|
|
|
from .models import utcnow
|
|
|
|
#: The silence-rule choices that the editor offers, in hours (:data:`OFF` turns the rule
|
|
#: off).
|
|
SILENCE_CHOICES_H = (1, 3, 6, 12, 24, 48)
|
|
|
|
#: The default silence threshold. Half a day is sufficient for nodes that advertise only
|
|
#: approximately one time each day, and it gives no false alarm for a quiet afternoon.
|
|
#: This is the default of the code. A node that the user stars gets the value of the
|
|
#: ``watch_silence_hours`` preference, and the default of that preference is this value
|
|
#: (refer to :mod:`meshterm.core.preferences`).
|
|
DEFAULT_SILENCE_HOURS = 12
|
|
|
|
#: The stored value that means "this rule is off".
|
|
OFF = 0
|
|
|
|
#: How many alerts the log keeps (the store removes the oldest alert first). This is the
|
|
#: only default of the ``watch_alerts_kept`` preference. The registry refers to this name
|
|
#: and does not type the value again (:data:`meshterm.core.preferences.PREFERENCES`).
|
|
#: Thus the two values can never be different.
|
|
ALERT_CAP = 200
|
|
|
|
#: The minimum time between two disk flushes for the last-heard changes that packets
|
|
#: cause, in seconds.
|
|
_FLUSH_EVERY_S = 60.0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class WatchedNode:
|
|
"""One starred node: its rules, and the marks from which the rules count.
|
|
|
|
Attributes:
|
|
key: The canonical 12-hex key prefix of the node (the id that observations
|
|
carry).
|
|
name: The label to show. It changes each time the node is heard with a name.
|
|
silence_hours: The hours of silence before the alarm (:data:`OFF` turns the
|
|
alarm off).
|
|
snr_watch: Whether the SNR-sag rule watches the receptions of this node.
|
|
last_heard: When the node was last heard. The store sets it when the user stars
|
|
the node, so that the silence countdown starts immediately.
|
|
silent_since: Set while a silence alarm is active. Thus MeshTerm raises the
|
|
alarm one time, and arms it again only after the node is heard again.
|
|
node_type: The advertised type of the node when the user starred it (a
|
|
``NODE_TYPE_*`` constant), for the type glyph of the watchlist. ``None`` for
|
|
entries that were starred before this field existed (the screen then uses
|
|
the contact table).
|
|
"""
|
|
|
|
key: str
|
|
name: str
|
|
silence_hours: int = DEFAULT_SILENCE_HOURS
|
|
snr_watch: bool = True
|
|
last_heard: datetime | None = None
|
|
silent_since: datetime | None = None
|
|
node_type: int | None = None
|
|
|
|
#: The fields that this record held before. It must never hold them again with a
|
|
#: different meaning (refer to :data:`meshterm.core.preferences.RETIRED` for the
|
|
#: reason that a name is never used again).
|
|
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Alert:
|
|
"""One raised alert. The log keeps it until it goes off the end of the capped log.
|
|
|
|
Attributes:
|
|
ident: A monotonic id (stable across restarts) to acknowledge the alert.
|
|
when: When the rule raised the alert.
|
|
kind: ``silence`` / ``recovered`` / ``snr`` / ``new-node``.
|
|
label: The label of the node at that time.
|
|
message: The one-line text for the user.
|
|
acked: Whether the user acknowledged the alert (the header badge uses it).
|
|
"""
|
|
|
|
ident: int
|
|
when: datetime
|
|
kind: str
|
|
label: str
|
|
message: str
|
|
acked: bool = False
|
|
|
|
#: The fields that this record held before. It must never hold them again with a
|
|
#: different meaning (refer to :data:`meshterm.core.preferences.RETIRED` for the
|
|
#: reason that a name is never used again).
|
|
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _State:
|
|
"""The in-memory shape of the store (the same shape as the JSON file)."""
|
|
|
|
watched: dict[str, WatchedNode] = field(default_factory=dict)
|
|
alerts: list[Alert] = field(default_factory=list)
|
|
known: set[str] = field(default_factory=set)
|
|
new_node_alerts: bool = True
|
|
next_id: int = 1
|
|
|
|
#: The fields that this record held before. It must never hold them again with a
|
|
#: different meaning (refer to :data:`meshterm.core.preferences.RETIRED` for the
|
|
#: reason that a name is never used again).
|
|
RETIRED: ClassVar[frozenset[str]] = frozenset()
|
|
|
|
|
|
class WatchStore:
|
|
"""Reads and writes the state of the Watchtower, in memory first."""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
"""Open the store on the location of a JSON file.
|
|
|
|
Args:
|
|
path: Path to the JSON state file (made only at the first write).
|
|
"""
|
|
self._path = path
|
|
self._state: _State | None = None
|
|
self._dirty = False
|
|
self._last_flush = 0.0
|
|
|
|
# --- state ---------------------------------------------------------------------
|
|
|
|
@property
|
|
def state(self) -> _State:
|
|
"""The in-memory state, read from disk at the first access."""
|
|
if self._state is None:
|
|
self._state = self._load()
|
|
return self._state
|
|
|
|
def _load(self) -> _State:
|
|
"""Parse the file into a :class:`_State`, or return a new default at any problem."""
|
|
try:
|
|
data = json.loads(self._path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
data = {}
|
|
state = _State(
|
|
new_node_alerts=bool(data.get("new_node_alerts", True)),
|
|
next_id=max(1, _as_int(data.get("next_id"), 1)),
|
|
known={str(k) for k in data.get("known", []) if isinstance(k, str)},
|
|
)
|
|
watched = data.get("watched")
|
|
if isinstance(watched, dict):
|
|
for key, record in watched.items():
|
|
if not isinstance(record, dict):
|
|
continue
|
|
raw_type = record.get("node_type")
|
|
state.watched[str(key)] = WatchedNode(
|
|
key=str(key),
|
|
name=str(record.get("name") or key),
|
|
silence_hours=_as_int(record.get("silence_hours"), DEFAULT_SILENCE_HOURS),
|
|
snr_watch=bool(record.get("snr_watch", True)),
|
|
last_heard=_as_time(record.get("last_heard")),
|
|
silent_since=_as_time(record.get("silent_since")),
|
|
node_type=raw_type if isinstance(raw_type, int) and raw_type >= 0 else None,
|
|
)
|
|
for record in data.get("alerts", []):
|
|
if not isinstance(record, dict):
|
|
continue
|
|
when = _as_time(record.get("when"))
|
|
if when is None:
|
|
continue
|
|
state.alerts.append(
|
|
Alert(
|
|
ident=_as_int(record.get("ident"), 0),
|
|
when=when,
|
|
kind=str(record.get("kind") or "alert"),
|
|
label=str(record.get("label") or "?"),
|
|
message=str(record.get("message") or ""),
|
|
acked=bool(record.get("acked", False)),
|
|
)
|
|
)
|
|
return state
|
|
|
|
# --- watched nodes ---------------------------------------------------------------
|
|
|
|
def watched(self) -> dict[str, WatchedNode]:
|
|
"""The watched nodes by canonical id (the live mapping: use it mostly to read)."""
|
|
return self.state.watched
|
|
|
|
def is_watched(self, key: str) -> bool:
|
|
"""Whether ``key`` is on the watchlist."""
|
|
return key in self.state.watched
|
|
|
|
def watch(
|
|
self,
|
|
key: str,
|
|
name: str,
|
|
*,
|
|
last_seen: datetime | None = None,
|
|
node_type: int | None = None,
|
|
) -> None:
|
|
"""Star a node with the default rules.
|
|
|
|
The silence countdown starts immediately. It counts from the known ``last_seen``
|
|
of the contact when there is one, else from now. Thus a node that was already
|
|
quiet for a day causes an alarm at the next sweep. That is exactly why the user
|
|
starred it.
|
|
|
|
Args:
|
|
key: The canonical 12-hex key prefix of the node.
|
|
name: The label to show.
|
|
last_seen: When the node was last heard, if known.
|
|
node_type: The advertised type of the node, if known (for the glyph in the
|
|
watchlist).
|
|
"""
|
|
from .preferences import current as current_preferences
|
|
|
|
self.state.watched[key] = WatchedNode(
|
|
key=key,
|
|
name=name,
|
|
silence_hours=current_preferences().watch_silence_hours,
|
|
last_heard=last_seen or utcnow(),
|
|
node_type=node_type,
|
|
)
|
|
self._save()
|
|
|
|
def unwatch(self, key: str) -> None:
|
|
"""Remove a node from the watchlist (its past alerts stay in the log)."""
|
|
if self.state.watched.pop(key, None) is not None:
|
|
self._save()
|
|
|
|
def set_silence(self, key: str, hours: int) -> None:
|
|
"""Set the silence threshold of a watched node (:data:`OFF` turns the rule off)."""
|
|
entry = self.state.watched.get(key)
|
|
if entry is not None:
|
|
entry.silence_hours = int(hours)
|
|
self._save()
|
|
|
|
def set_snr_watch(self, key: str, on: bool) -> None:
|
|
"""Turn the SNR-sag rule on or off for a watched node."""
|
|
entry = self.state.watched.get(key)
|
|
if entry is not None:
|
|
entry.snr_watch = bool(on)
|
|
self._save()
|
|
|
|
def note_heard(
|
|
self, key: str, *, when: datetime | None = None, name: str | None = None
|
|
) -> None:
|
|
"""Store that a watched node was heard.
|
|
|
|
Packets cause this call, so the writes are throttled.
|
|
|
|
Args:
|
|
key: The canonical id of the node.
|
|
when: The reception time (the default is now). If this time is older than
|
|
the known time, the known time stays.
|
|
name: A name that was advertised recently. When it is given, it becomes the
|
|
label to show.
|
|
"""
|
|
entry = self.state.watched.get(key)
|
|
if entry is None:
|
|
return
|
|
stamp = when or utcnow()
|
|
if entry.last_heard is None or stamp > entry.last_heard:
|
|
entry.last_heard = stamp
|
|
if name:
|
|
entry.name = name
|
|
self._dirty = True
|
|
self.flush(only_if_due=True)
|
|
|
|
def mark_silent(self, key: str, when: datetime | None = None) -> None:
|
|
"""Latch the active silence alarm of a node: one alarm for each quiet period.
|
|
|
|
Packets and sweeps cause this call (the same as :meth:`note_heard`), so the write
|
|
is throttled. The save of the full state is synchronous on the event loop. The
|
|
Watchtower service flushes each pending change when it stops.
|
|
"""
|
|
entry = self.state.watched.get(key)
|
|
if entry is not None:
|
|
entry.silent_since = when or utcnow()
|
|
self._dirty = True
|
|
self.flush(only_if_due=True)
|
|
|
|
def clear_silent(self, key: str) -> None:
|
|
"""Arm the silence alarm of a node again, after the node was heard again.
|
|
|
|
Packets cause this call, so it is throttled the same as :meth:`mark_silent`. After
|
|
an outage, a burst of recovery packets must not cause one full-state disk write
|
|
for each packet.
|
|
"""
|
|
entry = self.state.watched.get(key)
|
|
if entry is not None and entry.silent_since is not None:
|
|
entry.silent_since = None
|
|
self._dirty = True
|
|
self.flush(only_if_due=True)
|
|
|
|
# --- the new-node baseline ---------------------------------------------------------
|
|
|
|
@property
|
|
def new_node_alerts(self) -> bool:
|
|
"""Whether a node that was never heard before raises an alert when it is heard."""
|
|
return self.state.new_node_alerts
|
|
|
|
def set_new_node_alerts(self, on: bool) -> None:
|
|
"""Turn the new-node rule on or off."""
|
|
self.state.new_node_alerts = bool(on)
|
|
self._save()
|
|
|
|
def known_contains(self, node: str) -> bool:
|
|
"""Whether ``node`` is already stored (it is never announced again)."""
|
|
return node in self.state.known
|
|
|
|
def remember_known(self, node: str) -> None:
|
|
"""Store ``node`` as heard, so that it is never announced as new more than one time."""
|
|
if node not in self.state.known:
|
|
self.state.known.add(node)
|
|
self._dirty = True
|
|
self.flush(only_if_due=True)
|
|
|
|
# --- alerts ---------------------------------------------------------------------
|
|
|
|
def add_alert(
|
|
self, kind: str, label: str, message: str, *, when: datetime | None = None
|
|
) -> Alert:
|
|
"""Add an alert to the end of the log (capped), and write it with the throttle.
|
|
|
|
Alerts come from the packet path. (In a new area of the mesh, MeshTerm can
|
|
announce a node that it never heard before for each packet, for some time.) The
|
|
save serializes the full state synchronously on the event loop. Thus this
|
|
function uses the same throttled flush as :meth:`note_heard`. The in-memory log
|
|
(and the header badge that uses it) changes immediately in all cases. The
|
|
Watchtower service flushes each pending write when it stops.
|
|
|
|
Args:
|
|
kind: The rule that raised the alert
|
|
(``silence``/``recovered``/``snr``/``new-node``).
|
|
label: The label of the node to show.
|
|
message: The one-line text for the user.
|
|
when: When the rule raised the alert (the default is now).
|
|
|
|
Returns:
|
|
The stored :class:`Alert`.
|
|
"""
|
|
state = self.state
|
|
alert = Alert(
|
|
ident=state.next_id,
|
|
when=when or utcnow(),
|
|
kind=kind,
|
|
label=label,
|
|
message=message,
|
|
)
|
|
state.next_id += 1
|
|
state.alerts.append(alert)
|
|
from .preferences import current as current_preferences
|
|
|
|
cap = current_preferences().watch_alerts_kept
|
|
if len(state.alerts) > cap:
|
|
del state.alerts[: len(state.alerts) - cap]
|
|
self._dirty = True
|
|
self.flush(only_if_due=True)
|
|
return alert
|
|
|
|
def alerts(self) -> list[Alert]:
|
|
"""The alert log, newest first (a copy, which is safe to slice)."""
|
|
return list(reversed(self.state.alerts))
|
|
|
|
def ack(self, ident: int) -> None:
|
|
"""Acknowledge one alert by its id."""
|
|
for alert in self.state.alerts:
|
|
if alert.ident == ident and not alert.acked:
|
|
alert.acked = True
|
|
self._save()
|
|
return
|
|
|
|
def ack_all(self) -> None:
|
|
"""Acknowledge all the alerts."""
|
|
changed = False
|
|
for alert in self.state.alerts:
|
|
if not alert.acked:
|
|
alert.acked = True
|
|
changed = True
|
|
if changed:
|
|
self._save()
|
|
|
|
def clear_acked(self) -> None:
|
|
"""Remove the acknowledged alerts from the log, and keep the live alerts."""
|
|
state = self.state
|
|
kept = [a for a in state.alerts if not a.acked]
|
|
if len(kept) != len(state.alerts):
|
|
state.alerts = kept
|
|
self._save()
|
|
|
|
def unacked_count(self) -> int:
|
|
"""How many alerts wait for the user: the number on the header badge."""
|
|
return sum(1 for a in self.state.alerts if not a.acked)
|
|
|
|
# --- write to disk ---------------------------------------------------------------
|
|
|
|
def flush(self, *, only_if_due: bool = False) -> None:
|
|
"""Write the pending throttled changes to disk.
|
|
|
|
Args:
|
|
only_if_due: Do nothing when the last flush was recent (the path that packets
|
|
use). A plain ``flush()`` writes each pending change immediately.
|
|
"""
|
|
if not self._dirty:
|
|
return
|
|
if only_if_due and time.monotonic() - self._last_flush < _FLUSH_EVERY_S:
|
|
return
|
|
self._save()
|
|
|
|
def _save(self) -> None:
|
|
"""Write the full state atomically.
|
|
|
|
If a crash occurs during the write, the old file stays.
|
|
"""
|
|
state = self.state
|
|
data = {
|
|
"new_node_alerts": state.new_node_alerts,
|
|
"next_id": state.next_id,
|
|
"known": sorted(state.known),
|
|
"watched": {
|
|
key: {
|
|
"name": e.name,
|
|
"silence_hours": e.silence_hours,
|
|
"snr_watch": e.snr_watch,
|
|
"last_heard": e.last_heard.isoformat() if e.last_heard else None,
|
|
"silent_since": e.silent_since.isoformat() if e.silent_since else None,
|
|
"node_type": e.node_type,
|
|
}
|
|
for key, e in state.watched.items()
|
|
},
|
|
"alerts": [
|
|
{
|
|
"ident": a.ident,
|
|
"when": a.when.isoformat(),
|
|
"kind": a.kind,
|
|
"label": a.label,
|
|
"message": a.message,
|
|
"acked": a.acked,
|
|
}
|
|
for a in state.alerts
|
|
],
|
|
}
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
tmp.replace(self._path)
|
|
self._dirty = False
|
|
self._last_flush = time.monotonic()
|
|
|
|
|
|
def _as_int(value: object, default: int) -> int:
|
|
"""Convert a stored number to a non-negative int, or return ``default`` if it fails."""
|
|
try:
|
|
number = int(value) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return number if number >= 0 else default
|
|
|
|
|
|
def _as_time(value: object) -> datetime | None:
|
|
"""Parse a stored ISO-8601 timestamp, or return ``None`` if it is absent or corrupt.
|
|
|
|
The function also reads a timestamp without a time zone as corrupt. The store only
|
|
writes aware UTC timestamps, and a naive timestamp makes the silence arithmetic wrong.
|
|
"""
|
|
if not isinstance(value, str):
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo is not None else None
|