Files
MeshTerm/meshterm/core/contact_store.py
T
Jean-Pierre MartineauandClaude Opus 5 99cd616ae5 The purge keeps the contacts worth a slot, and archives the rest
A device's contact table is finite, and a busy mesh fills it with whatever
adverts arrived — mostly nodes heard once, in passing, four hops out — until
there is no room left to discover anyone new. The age ladder could only ask
how long ago; it had nothing to say about which of two equally quiet contacts
was the one you would miss.

So contacts are now rated. Six weighted terms, each normalised and summed:
direct correspondence heaviest at 30, then how lately heard (25), how often
(15), channel posting (12), hops (10) and distance (8). Additive rather than
multiplicative, because a product lets one zero annihilate a contact that is
strong everywhere else — a node advertising no position would have scored
nothing however much you talk to it.

The rule the whole thing rests on is that an unknown scores *neutral*. Half a
real mesh advertises no location, plenty of nodes are never overheard as a
relayed frame so carry no hop count, and a channel poster whose name two
contacts share can be attributed to neither — the wire gives a channel frame
no sender key at all. A term that cannot be computed takes the population
median of the contacts that could, so the decision falls to the axes that are
known. Scoring those as zero would have swept by how much metadata a node
happens to broadcast. It is also why the two least reliable terms carry the
two smallest weights, and why distance calibrates against this mesh's own
median spread rather than a constant: 50 km is far downtown and unremarkable
in a valley.

A newcomer gets 25 points decaying to nothing over a fortnight, because every
other term rewards accumulated evidence and a node first heard this morning is
otherwise arithmetically identical to one quiet for a year. Four things are
never candidates at all: a watched node, anyone you have sent a message to, a
repeater whose admin login is stored, and a contact MeshTerm has never heard —
that last one predates this history, and sweeping it would be purging by how
long the app has been running. They still rank, since a percentile that
dropped them would not be a rank against your contacts; they just consume no
kept slot, so starring a node never deepens the next sweep.

Only the percentile is ever displayed. A raw 47.3 means nothing without the
distribution it came from and would need a legend the moment the weights were
retuned.

The victims are archived, not deleted: off the device, which is the scarce
resource, and kept here with a stamp. Everything else was already independent
of the contact row — reception history, map position, transcripts — and the
store keeps the full key, so a restore is one write, which the chat screen
already performs on demand when a send is rejected for an unknown recipient.
merge_contacts learns to withhold them, or the union would put them straight
back and the sweep would look like it did nothing.

The old age ladder survives as the second section of the one picker, because
"everything I haven't heard in a year" is a plain predictable operation a
ranking cannot express. Both routes converge on the same preview — weakest
first, with each row's percentile and the two or three facts that put it
there — so an age rung reaching into contacts that rank perfectly well says so
before the typed confirm rather than after.

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

405 lines
18 KiB
Python

"""Persistence for contacts heard through MeshTerm, so they survive a device that forgets them.
Most companions keep their contact table in firmware, so MeshTerm reads contacts live from the
device and never has to remember them. A firmware-less radio bridge is the exception: it holds
its contact table only in RAM, so every contact is gone when the bridge process restarts — the
Contacts screen and the chat recipient list come up empty (or only as sparse as the adverts
heard so far this session), even though you were messaging those nodes last time.
This store is the durable backup for exactly that case. Every time MeshTerm reads the device's
contacts it records them here, keyed by the device's own public key so two radios keep separate
sets. When a list is then drawn, :func:`merge_contacts` unions the device's live contacts with
any remembered contact the device isn't currently reporting — so a forgetful device still shows
the nodes you know, and they stay messageable: a direct message addresses a node by a prefix of
its public key, which the remembered contact carries, so nothing has to be written back onto the
device to reach them.
The union is inert where it isn't needed: a firmware radio always reports its whole table, so
every remembered contact is already present and nothing is added — no device-type check required.
Recording never *removes* a contact on a device's empty read, so a bridge that just restarted
doesn't wipe the memory of what it knew.
A contact can also be **archived**, which is the opposite arrangement and the one the
Contacts sweep uses (see :mod:`~meshterm.core.contact_score`): the contact is deleted from
the *device*, freeing a slot in a companion's finite contact table, and kept here with an
:attr:`~RememberedContact.archived_at` stamp so nothing about it is actually lost.
:func:`merge_contacts` skips an archived contact — without that the union above would put it
straight back into every list, indistinguishable from a live one, and the sweep would appear
to have done nothing. What survives an archive is everything except the device row: the
node's whole reception history (untouched — it lives in the SQLite database, not here), its
direct-message transcript (keyed by key prefix, not by a contact row), and its full public
key, which is exactly what :meth:`~meshterm.core.connection.Device.add_contact` needs to put
it back. So a restore is one write, and the chat screen already performs it on demand when a
send is rejected for an unknown recipient (see
:class:`~meshterm.core.connection.ContactNotOnDeviceError`).
The one place to be careful is a firmware-less bridge, whose contact table is RAM-only and
for which this store *is* the memory: archiving there removes the contact from the lists for
real, and only a restore brings it back.
Like the other operator state (mutes, remembered channels, saved settings), this is global
machine state in a small JSON file (``<config_dir>/contacts.json``), not the per-invocation
SQLite database. Reads are served from memory after the first load; a write happens only when the
contact set actually changes, and flushes atomically.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from .models import Contact, advert_time
def _norm(pubkey: str) -> str:
"""Normalise a public key (device or contact) to the lowercase hex used as a key."""
return (pubkey or "").lower().removeprefix("0x")
def _opt_int(value: object) -> Optional[int]:
"""Coerce a JSON value to ``int``, or ``None`` if absent/unparseable."""
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _opt_float(value: object) -> Optional[float]:
"""Coerce a JSON value to ``float``, or ``None`` if absent/unparseable."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@dataclass(frozen=True)
class RememberedContact:
"""One contact MeshTerm remembers for a device: enough to list it and address a message.
Attributes:
public_key: The contact's full public key (lowercase hex) — how a direct message
addresses it, so it is what makes a remembered contact messageable.
name: The node's advertised name.
node_type: The advert type (see the ``NODE_TYPE_*`` constants), so the chat picker's
direct-messageable filter keeps behaving as it would for a live contact.
last_advert: Unix seconds of the node's most recent advert when last heard, for the
list's last-heard column; ``None`` when unknown.
lat: Last advertised latitude, if it shared one.
lon: Last advertised longitude, if it shared one.
archived_at: Unix seconds when this contact was swept off the device, or ``None``
while it is a live contact. An archived contact is remembered in full but kept
*out* of :func:`merge_contacts`, so it stops occupying a device slot without
being forgotten — and the stamp is what lets a list say how long ago it went.
"""
public_key: str
name: str
node_type: Optional[int] = None
last_advert: Optional[int] = None
lat: Optional[float] = None
lon: Optional[float] = None
archived_at: Optional[int] = None
@property
def archived(self) -> bool:
"""Whether this contact has been swept off the device but kept here."""
return self.archived_at is not None
@classmethod
def from_contact(cls, contact: Contact) -> "RememberedContact":
"""Distil a live :class:`~meshterm.core.models.Contact` into the fields we persist."""
epoch = int(contact.last_seen.timestamp()) if contact.last_seen else None
return cls(
public_key=_norm(contact.public_key),
name=contact.name,
node_type=contact.node_type,
last_advert=epoch,
lat=contact.lat,
lon=contact.lon,
)
def to_contact(self) -> Contact:
"""Rebuild a :class:`~meshterm.core.models.Contact` for the merged list.
The learned route is dropped (``route_hops=None``): a remembered contact floods until
the device relearns a path from received traffic, exactly as a freshly-heard one does.
The stored epoch re-enters through :func:`~meshterm.core.models.advert_time`, so a
future-stamped advert remembered before its sender's clock was corrected reads as
unknown rather than resurfacing as "heard in the future".
"""
last_seen = advert_time(self.last_advert)
return Contact(
name=self.name,
public_key=self.public_key,
key_prefix=self.public_key[:12],
last_seen=last_seen,
node_type=self.node_type,
lat=self.lat,
lon=self.lon,
route_hops=None,
)
class ContactStore:
"""Reads and writes the per-device set of remembered contacts, memory-first.
Interact through :meth:`contacts` (a device's remembered contacts) and :meth:`remember_all`
(record the contacts just read from a device — an upsert that never drops one on absence).
The backing map is loaded once on first access and kept in memory; a mutation persists the
whole map atomically, and only when something actually changed.
"""
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 the first remembered contact).
"""
self._path = path
self._devices: Optional[dict[str, dict[str, RememberedContact]]] = None
@property
def _state(self) -> dict[str, dict[str, RememberedContact]]:
"""The device -> {contact key -> remembered contact} map, loaded on first access."""
if self._devices is None:
self._devices = self._load()
return self._devices
def _load(self) -> dict[str, dict[str, RememberedContact]]:
"""Parse the file into the device map, or empty on a missing/corrupt file."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
devices: dict[str, dict[str, RememberedContact]] = {}
for pubkey, entries in (data.get("devices") or {}).items():
if not isinstance(entries, list):
continue
contacts: dict[str, RememberedContact] = {}
for entry in entries:
contact = _contact_from_json(entry)
if contact is not None:
contacts[contact.public_key] = contact
if contacts:
devices[_norm(str(pubkey))] = contacts
return devices
def contacts(self, device_pubkey: str) -> list[RememberedContact]:
"""The contacts remembered for a device (a copy, sorted by name), empty if none."""
remembered = self._state.get(_norm(device_pubkey), {})
return sorted(remembered.values(), key=lambda c: (c.name.lower(), c.public_key))
def remember_all(self, device_pubkey: str, contacts: list[Contact]) -> None:
"""Record the contacts just read from a device, upserting by public key.
A contact new to the device, or one whose fields changed (a rename, a fresher advert),
is stored; an unchanged one is left alone. A contact the device is *no longer* reporting
is **kept** — so a forgetful device's empty read never erases what it once knew. Persists
once, only if anything changed.
Args:
device_pubkey: The device's own public key.
contacts: The contacts just read from that device.
"""
dev = _norm(device_pubkey)
if not dev:
return
current = dict(self._state.get(dev, {}))
changed = False
for contact in contacts:
if not contact.public_key:
continue # unaddressable — nothing to remember it by
remembered = RememberedContact.from_contact(contact)
if current.get(remembered.public_key) != remembered:
current[remembered.public_key] = remembered
changed = True
if changed:
self._state[dev] = current
self._save()
def archived(self, device_pubkey: str) -> list[RememberedContact]:
"""The contacts archived off this device, most recently archived first.
The Contacts screen's ``Archived`` section. Ordered by when each was swept rather
than by name, because that is the question the section answers — *what did the last
sweep take?* — and a fresh sweep's work should be at the top of it.
"""
remembered = self._state.get(_norm(device_pubkey), {}).values()
return sorted(
(c for c in remembered if c.archived),
key=lambda c: (-(c.archived_at or 0), c.name.lower()),
)
def archive(self, device_pubkey: str, contact: Contact, *, when: int) -> None:
"""Mark one contact archived — remembered in full, but no longer merged into lists.
The store half of a sweep: the device half is
:meth:`~meshterm.core.connection.Device.remove_contact`, and this is what keeps the
removal from being a loss. The contact is *upserted* first, so archiving one the
store had never recorded (a live-only contact on a firmware radio, which is the
usual case) still remembers everything needed to put it back.
Args:
device_pubkey: The device's own public key.
contact: The contact being swept.
when: Unix seconds to stamp the archive with.
"""
dev = _norm(device_pubkey)
if not dev or not contact.public_key:
return # unaddressable — there would be nothing to restore it by
remembered = RememberedContact.from_contact(contact)
entry = RememberedContact(
public_key=remembered.public_key,
name=remembered.name,
node_type=remembered.node_type,
last_advert=remembered.last_advert,
lat=remembered.lat,
lon=remembered.lon,
archived_at=int(when),
)
current = dict(self._state.get(dev, {}))
if current.get(entry.public_key) == entry:
return
current[entry.public_key] = entry
self._state[dev] = current
self._save()
def restore(self, device_pubkey: str, contact_pubkey: str) -> Optional[RememberedContact]:
"""Clear one contact's archived mark, returning what was archived.
Only the *store* side: the caller writes the contact back onto the device (see
:meth:`~meshterm.core.connection.Device.add_contact`) and calls this once that
succeeded, so a failed write never leaves a contact listed as live on a device that
doesn't hold it.
Args:
device_pubkey: The device's own public key.
contact_pubkey: The contact to un-archive.
Returns:
The record as it was archived, or ``None`` if no archived contact matched.
"""
dev = _norm(device_pubkey)
key = _norm(contact_pubkey)
contacts = self._state.get(dev) or {}
entry = contacts.get(key)
if entry is None or not entry.archived:
return None
current = dict(contacts)
current[key] = RememberedContact(
public_key=entry.public_key,
name=entry.name,
node_type=entry.node_type,
last_advert=entry.last_advert,
lat=entry.lat,
lon=entry.lon,
archived_at=None,
)
self._state[dev] = current
self._save()
return entry
def forget(self, device_pubkey: str, contact_pubkey: str) -> None:
"""Drop one remembered contact; persist only a real change."""
dev = _norm(device_pubkey)
contacts = self._state.get(dev)
key = _norm(contact_pubkey)
if not contacts or key not in contacts:
return
contacts = {k: v for k, v in contacts.items() if k != key}
if contacts:
self._state[dev] = contacts
else:
del self._state[dev]
self._save()
def _save(self) -> None:
"""Persist the whole device map atomically (a crash mid-write keeps the old file)."""
data = {
"devices": {
pubkey: [_contact_to_json(c) for c in sorted(
contacts.values(), key=lambda c: (c.name.lower(), c.public_key)
)]
for pubkey, contacts in sorted(self._state.items())
if contacts
}
}
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)
def _contact_to_json(contact: RememberedContact) -> dict:
"""Serialise one remembered contact, omitting the fields it doesn't carry."""
entry: dict = {"public_key": contact.public_key, "name": contact.name}
if contact.node_type is not None:
entry["node_type"] = contact.node_type
if contact.last_advert is not None:
entry["last_advert"] = contact.last_advert
if contact.lat is not None:
entry["lat"] = contact.lat
if contact.lon is not None:
entry["lon"] = contact.lon
if contact.archived_at is not None:
entry["archived_at"] = contact.archived_at
return entry
def _contact_from_json(entry: object) -> Optional[RememberedContact]:
"""Parse one stored contact entry, or ``None`` if it is malformed (no key or name)."""
if not isinstance(entry, dict):
return None
pubkey = _norm(str(entry.get("public_key", "")))
name = entry.get("name")
if not pubkey or not isinstance(name, str):
return None
return RememberedContact(
public_key=pubkey,
name=name,
node_type=_opt_int(entry.get("node_type")),
last_advert=_opt_int(entry.get("last_advert")),
lat=_opt_float(entry.get("lat")),
lon=_opt_float(entry.get("lon")),
archived_at=_opt_int(entry.get("archived_at")),
)
def merge_contacts(
store: ContactStore, device_pubkey: str, live: list[Contact]
) -> list[Contact]:
"""Union a device's live contacts with any remembered ones it isn't currently reporting.
Live contacts pass through unchanged and first; a remembered contact whose key the device
already reports is left to the live entry (the fresher of the two), so nothing is
duplicated. A firmware radio reports its whole table, so this adds nothing there; a
forgetful device gets the missing contacts back, rebuilt from what was remembered.
Args:
store: The contact store to read remembered contacts from.
device_pubkey: The device's own public key.
live: The contacts the device is currently reporting.
Returns:
The live contacts followed by the remembered contacts the device is missing.
"""
present = {_norm(c.public_key) for c in live if c.public_key}
extra = [
remembered.to_contact()
for remembered in store.contacts(device_pubkey)
# An archived contact is deliberately withheld: it was swept off the device to free
# a slot, and merging it back would undo the sweep in the only place anyone looks.
if remembered.public_key
and not remembered.archived
and remembered.public_key not in present
]
return list(live) + extra