Files
MeshTerm/meshterm/core/advert_store.py
T
Jean-Pierre MartineauandClaude Fable 5 ce3c2d1e2e Background adverts on a per-device cadence, one send per pass
MeshTerm now announces the connected node by itself: a zero-hop advert
every 1/2/3/4 hours (default hourly) and a flood advert every
3/6/12/24/48/168 hours (default daily), each switchable off. The
cadences are staged and applied from a new Background adverts section
in the device-configuration editor, and persist per device (keyed by
public key) in adverts.json alongside the last-sent mark for each type
— so the countdown survives restarts, and a manual send from any
advert flow resets the same clock the scheduler reads.

The scheduler stays transmission-shy: at most one advert per 30 s pass
(a flood due together with a direct waits a pass), and a never-sent
device is armed to count from now rather than advertised immediately.
It holds no device subscription — each pass just skips when nothing is
connected — so reconnects need no special handling, and scripted CLI
runs never start it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 03:12:49 -04:00

222 lines
8.6 KiB
Python

"""Persistence for the background-advert schedule.
MeshTerm announces the connected node on a cadence — a zero-hop (direct) advert for the
immediate neighbourhood and a flood advert for the wider mesh — so the node stays fresh in
other nodes' contact lists without anyone thinking about it. This store remembers, per
device (keyed by its public key), the chosen cadence for each advert type and when one was
last sent, so the countdown survives restarts and *every* advert counts: a manual send from
the advert menu resets the same clock the scheduler reads (see
:class:`~meshterm.services.advert_scheduler.AdvertScheduler`).
Like the remembered devices (:mod:`meshterm.core.device_store`), this is global machine
state in a small JSON file (``<config_dir>/adverts.json``) rather than the per-invocation
SQLite database.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
from .models import utcnow
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
DIRECT_CADENCE_HOURS = (1, 2, 3, 4)
#: The flood cadence choices offered in the editor, in hours (3 h .. 1 week).
FLOOD_CADENCE_HOURS = (3, 6, 12, 24, 48, 168)
#: Defaults applied when a device has no stored policy: announce to neighbours hourly,
#: flood the wider mesh daily.
DEFAULT_DIRECT_HOURS = 1
DEFAULT_FLOOD_HOURS = 24
#: The stored cadence meaning "this advert type is off" (0 hours).
OFF = 0
@dataclass(slots=True)
class AdvertPolicy:
"""One device's background-advert schedule and its last-sent marks.
Attributes:
direct_hours: Hours between zero-hop adverts (:data:`OFF` disables them).
flood_hours: Hours between flood adverts (:data:`OFF` disables them).
last_direct: When a zero-hop advert (scheduled *or* manual) last went out.
last_flood: When a flood advert last went out.
"""
direct_hours: int = DEFAULT_DIRECT_HOURS
flood_hours: int = DEFAULT_FLOOD_HOURS
last_direct: Optional[datetime] = None
last_flood: Optional[datetime] = None
def cadence(self, flood: bool) -> int:
"""The cadence in hours for one advert type (:data:`OFF` when disabled)."""
return self.flood_hours if flood else self.direct_hours
def last_sent(self, flood: bool) -> Optional[datetime]:
"""When an advert of one type last went out, or ``None`` if never recorded."""
return self.last_flood if flood else self.last_direct
def due(self, flood: bool, now: Optional[datetime] = None) -> bool:
"""Whether an advert of one type is due at ``now``.
Never-sent is *not* due: the scheduler arms the clock by recording "now" on its
first look (see :meth:`AdvertStore.arm`), so a fresh install waits a full cadence
instead of transmitting the moment it starts — adverts are routine, not urgent,
and the quiet default is the polite one on a shared mesh.
Args:
flood: ``True`` for the flood clock, ``False`` for the zero-hop one.
now: The current time (defaults to :func:`~meshterm.core.models.utcnow`).
Returns:
``True`` when the type is enabled, armed, and its cadence has elapsed.
"""
hours = self.cadence(flood)
last = self.last_sent(flood)
if hours == OFF or last is None:
return False
return ((now or utcnow()) - last).total_seconds() >= hours * 3600
def cadence_label(hours: int) -> str:
"""Render a cadence for display: ``"off"``, ``"every hour"``, ``"every 2 h"``, ``"weekly"``."""
if hours == OFF:
return "off"
if hours == 1:
return "every hour"
if hours == 24:
return "daily"
if hours == 168:
return "weekly"
return f"every {hours} h"
class AdvertStore:
"""Reads and writes per-device background-advert schedules."""
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
@staticmethod
def _key(public_key: str) -> str:
"""Normalize a device public key into the storage key."""
return public_key.lower().removeprefix("0x")
def _load_all(self) -> dict[str, dict]:
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def load(self, public_key: str) -> AdvertPolicy:
"""Return the stored policy for a device, or the on-by-default policy.
Args:
public_key: The device's public key (hex).
Returns:
The device's :class:`AdvertPolicy`; defaults apply for anything unset.
"""
record = self._load_all().get(self._key(public_key))
if not isinstance(record, dict):
return AdvertPolicy()
return AdvertPolicy(
direct_hours=_as_hours(record.get("direct_hours"), DEFAULT_DIRECT_HOURS),
flood_hours=_as_hours(record.get("flood_hours"), DEFAULT_FLOOD_HOURS),
last_direct=_as_time(record.get("last_direct")),
last_flood=_as_time(record.get("last_flood")),
)
def set_cadence(self, public_key: str, *, flood: bool, hours: int) -> None:
"""Store one advert type's cadence for a device.
Args:
public_key: The device's public key (hex).
flood: ``True`` to set the flood cadence, ``False`` for the zero-hop one.
hours: The new cadence in hours (:data:`OFF` disables the type).
"""
self._update(public_key, {"flood_hours" if flood else "direct_hours": int(hours)})
def mark_sent(self, public_key: str, *, flood: bool, when: Optional[datetime] = None) -> None:
"""Record that an advert went out, resetting that type's countdown.
Called for scheduled *and* manual sends alike, so the next background advert
counts from the most recent announcement of that type, whoever triggered it.
Args:
public_key: The device's public key (hex).
flood: Which advert type went out.
when: The send time (defaults to now).
"""
stamp = (when or utcnow()).isoformat()
self._update(public_key, {"last_flood" if flood else "last_direct": stamp})
def arm(self, public_key: str, *, flood: bool, when: Optional[datetime] = None) -> None:
"""Start a never-sent advert type's countdown from ``when`` without sending.
Only writes when no last-sent mark exists, so it is safe to call on every
scheduler pass.
Args:
public_key: The device's public key (hex).
flood: Which advert type to arm.
when: The countdown start (defaults to now).
"""
if self.load(public_key).last_sent(flood) is None:
self.mark_sent(public_key, flood=flood, when=when)
def _update(self, public_key: str, fields: dict) -> None:
"""Merge ``fields`` into a device's record and persist the store."""
records = self._load_all()
key = self._key(public_key)
record = records.get(key)
if not isinstance(record, dict):
record = {}
record.update(fields)
records[key] = record
self._write(records)
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)
def _as_hours(value: object, default: int) -> int:
"""Coerce a stored cadence to a non-negative int, falling back to ``default``."""
try:
hours = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return default
return hours if hours >= 0 else default
def _as_time(value: object) -> Optional[datetime]:
"""Parse a stored ISO-8601 timestamp, or ``None`` if absent/corrupt.
A timestamp without a zone is treated as corrupt too: the store only ever writes
aware UTC stamps, and a naive one would poison the ``due`` arithmetic.
"""
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