Files
Jean-Pierre MartineauandClaude Opus 5.5 9ae0947717 A shared name goes to the newest key unless two keys used it within a week
Greying every name that two keys ever used was too strict: a reflash with an
erase makes a new key, and 34 names on this mesh have one, JP's own among
them. Smog went grey this morning because of two keys silent since September.

The newest key now gets the name, and the name is grey only when another key
used it within name_overlap_days (a new Display preference, "Shared names",
default 7) of it: two live claimants. A contact that was never heard never
wins. On this machine's real data, LOG.IC 3R goes to its active key 47368a7c,
Smog to fa331c83, and 8 of 883 names stay grey.

The stored names come with their times from the one scan node_names() already
ran (node_names_with_times), so opening a chat costs no second scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 09:49:21 -04:00

394 lines
17 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Repeated traces with duty-cycle pacing, and helpers to parse traces.
:func:`run_traces` is the measurement primitive on which the TX optimizer and the path
probe are built. It runs a trace N times with pacing, can report the progress, and stores
each trace. The ``trace`` tool itself intentionally does *not* loop. It transmits exactly
one trace each time that it runs, because repeaters penalize (and can blacklist) nodes
that send bursts of traffic. But it uses the path parsing and the node-name resolution of
this module.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from datetime import datetime, timedelta, timezone
from ..core import preferences
from ..core.connection import Device
from ..core.models import Contact, NameKeyResolver, NodeResolver, TraceResult
# The trace arithmetic is in ``core``, because the device layer uses it to size its own
# sends. This module exports the names again, so that the old public names of the runner
# continue to work.
from ..core.tracing import ( # noqa: F401 - exported again for the callers of this module
TRACE_TIMEOUT_BASE_S,
TRACE_TIMEOUT_CEILING_S,
TRACE_TIMEOUT_FLOOD_S,
TRACE_TIMEOUT_PER_HOP_S,
path_hash_flags,
trace_timeout,
)
ProgressCallback = Callable[[int, int, TraceResult], None]
_HEX_DIGITS = frozenset("0123456789abcdef")
def make_node_resolver(
contacts: list[Contact] | None,
stored_names: dict[str, str] | None = None,
) -> NodeResolver:
"""Build a resolver that names a trace hop from its hash.
Trace replies identify each repeater only by a short hash: the first bytes of its
public key. This resolver changes that hash into the name of the contact when we know
the node. Thus the results show names instead of hex that has no meaning for the
user. For an unknown node, it uses the raw hash. ``stored_names`` adds the names that
the recorder *ever* heard (the latest advertised name of each node in the
repository) to the contact list of the device. Thus a node that the companion never
added, or forgot later, still resolves. The contacts are first in the list, thus
they win.
Args:
contacts: The known contacts to resolve against.
stored_names: The fallback names, indexed by stored node id (a hex key prefix).
Returns:
A callable that takes a hop label. It returns a contact name when the hash
matches a known contact, else the label with no change (``None`` = our device,
which passes through with no change).
"""
# (name, public_key, key_prefix), all in lower case, for prefix matches that ignore
# case.
entries: list[tuple[str, str, str]] = []
for c in contacts or []:
pub = (c.public_key or "").lower().removeprefix("0x")
prefix = (c.key_prefix or "").lower().removeprefix("0x")
if c.name and (pub or prefix):
entries.append((c.name, pub, prefix))
for node, name in (stored_names or {}).items():
ident = node.lower().removeprefix("0x")
if name and ident:
entries.append((name, "", ident))
# Cached for each label: the render paths ask for the same few hop hashes at each
# paint (for each hop, each row, and each frame). ``entries`` does not change during
# the life of this closure. Thus the resolver scans each different label exactly one
# time.
memo: dict[str, str | None] = {}
def resolve(label: str | None) -> str | None:
if not label:
return label
if label in memo:
return memo[label]
needle = label.lower().removeprefix("0x")
result = label
for name, pub, prefix in entries:
# The hash is a prefix of the key of the node. Match it against the full
# public key, or against the stored prefix (which can be shorter) in either
# direction.
if pub and pub.startswith(needle):
result = name
break
if prefix and (prefix.startswith(needle) or needle.startswith(prefix)):
result = name
break
memo[label] = result
return result
return resolve
def make_node_type_resolver(
contacts: list[Contact] | None,
) -> Callable[[str | None], int | None]:
"""Build a resolver that gives the *node type* of a hop from its hash.
This is the type equivalent of :func:`make_node_resolver`. It changes a trace or relay
hash into the advertised node type of the contact (a ``NODE_TYPE_*`` constant). Thus a
route graph can mark a repeater with its own glyph, instead of a generic dot. It
matches the hash against the key of each contact as the name resolver does: as a
prefix, in either direction. A hash that we cannot identify, or a contact with no type,
resolves to ``None``.
Args:
contacts: The known contacts to resolve against.
Returns:
A callable that takes a hop hash and returns its node type, or ``None`` when it is
unknown.
"""
entries: list[tuple[str, int]] = []
for c in contacts or []:
ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
if ident and c.node_type is not None:
entries.append((ident, c.node_type))
memo: dict[str, int | None] = {} # cached for each label, as in make_node_resolver
def type_of(label: str | None) -> int | None:
if not label:
return None
if label in memo:
return memo[label]
needle = label.lower().removeprefix("0x")
result = None
for ident, node_type in entries:
if ident.startswith(needle) or needle.startswith(ident):
result = node_type
break
memo[label] = result
return result
return type_of
def make_key_resolver(contacts: list[Contact] | None) -> NodeResolver:
"""Build a resolver that expands the stored key prefix of a node to its full public key.
The observations of the recorder identify a node only by a short key prefix (the part
that was heard on the air). But a contact on the device has the full public key of the
node. This resolver changes the stored prefix back into the full key. Thus a screen can
show as much of the key as fits, and does not stop at the twelve stored hex digits. A
prefix that does not start the key of any contact resolves to itself, and the stored
prefix stays.
Args:
contacts: The known contacts to resolve against.
Returns:
A callable that takes a stored key prefix. It returns the full public key of the
contact whose key starts with that prefix, or the prefix with no change when no
key matches.
"""
keys = [pub for c in contacts or [] if (pub := (c.public_key or "").lower().removeprefix("0x"))]
memo: dict[str, str] = {} # cached for each label, as in make_node_resolver
def resolve(label: str | None) -> str | None:
if not label:
return label
if label in memo:
return memo[label]
needle = label.lower().removeprefix("0x")
result = next((pub for pub in keys if pub.startswith(needle)), label)
memo[label] = result
return result
return resolve
def make_name_key_resolver(
contacts: list[Contact] | None,
stored_names: dict[str, str] | None = None,
*,
heard: Mapping[str, datetime] | None = None,
overlap_days: int | None = None,
) -> NameKeyResolver:
"""Build a resolver that finds the key of a display name.
The app-wide colour rule takes the hue of each name from the key of the node. Some
places in the UI (the inline sender of a channel message, an ``@mention``, the origin
of a route graph) have only a *name*. This resolver changes that name (casefolded)
back into a key. The contacts of the device give the canonical key of a node. Then the
stored names of the recorder add the nodes that the companion never added as
contacts. Thus a known node that is not a contact still gets its own hue. A name that
no node has resolves to ``None``. The caller renders it in the grey of an unknown node,
because colour is only for identities with a key.
**When more than one key uses a name, the key heard most recently gets it**, unless
another of those keys used the name within ``overlap_days`` of it. Then the name
resolves to ``None``. A name is a label that the owner of a key chooses. A new key for
the same name is usual: a reflash with an erase makes a new identity, and an old key
that is silent for weeks is the earlier identity of the same owner. But a channel
message carries only the name of its sender, as text that each holder of the channel
secret can write. When two keys use the name at the same time, MeshTerm cannot know
which one sent the message, and a choice between the two is a guess that the hue, the
map (^L), and the node page (^O) then show as a fact.
This rule replaced two others. First, the last contact in the list won: a second
"LOG.IC 3R" with a new key then took the hue and the map of the first. Then each name
that two keys ever used was grey: an old key, silent since September, then made "Smog"
grey in October (JP, 2026-10-11). A key with no time (a contact that was never heard)
never wins against a key with a time. When no key of a name has a time, the name is
unsure. Two ids are one node when one is a prefix of the other: the full key of a
contact and the stored id of the same node are one claim, not two.
Args:
contacts: The known contacts to resolve against (name → public key/key prefix).
Their ``last_seen`` is when each was last heard.
stored_names: The latest advertised name of each stored node id in the recorder
(:meth:`~meshterm.persistence.repository.Repository.node_names`). This
function inverts it into a fallback from name → node id.
heard: When each stored node id last used its name
(:meth:`~meshterm.persistence.repository.Repository.node_names_with_times`).
overlap_days: The window, in days. ``None`` reads the ``name_overlap_days``
preference.
Returns:
A callable that takes a display name and returns the best key hex that we have
for it, or ``None`` when the name matches no known node, or two that use it at
the same time.
"""
if overlap_days is None:
overlap_days = preferences.current().name_overlap_days
window = timedelta(days=max(0, overlap_days))
times = heard or {}
# The nodes that use each name: one [id, last time] entry for each node.
claims: dict[str, list[list]] = {}
def claim(name: str, ident: str, when: datetime | None, *, canonical: bool) -> None:
held = claims.setdefault(name.casefold(), [])
when = _aware(when)
for entry in held:
if entry[0].startswith(ident) or ident.startswith(entry[0]):
if canonical:
entry[0] = ident # the key of a contact replaces the stored id
if when is not None and (entry[1] is None or when > entry[1]):
entry[1] = when
return
held.append([ident, when])
for node, name in (stored_names or {}).items():
ident = node.lower().removeprefix("0x")
if name and ident:
claim(name, ident, times.get(node), canonical=False)
for c in contacts or []:
ident = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
if c.name and ident:
claim(c.name, ident, c.last_seen, canonical=True)
keys: dict[str, str] = {}
for name, held in claims.items():
if len(held) == 1:
keys[name] = held[0][0]
continue
dated = [entry for entry in held if entry[1] is not None]
if not dated:
continue # nothing tells which key uses the name now
newest = max(when for _ident, when in dated)
live = [ident for ident, when in dated if when >= newest - window]
if len(live) == 1:
keys[name] = live[0]
def key_of(name: str) -> str | None:
if not name:
return None
return keys.get(name.casefold())
return key_of
def _aware(when: datetime | None) -> datetime | None:
"""``when`` as an aware time. A time with no zone is UTC, as MeshTerm stores each time."""
if when is None or when.tzinfo is not None:
return when
return when.replace(tzinfo=timezone.utc)
def parse_trace_path(spec: str, contacts: list[Contact] | None = None) -> str:
"""Parse a path spec from the user into the hex path string that ``send_trace`` expects.
The MeshCore trace protocol forces a route through a list of repeaters. The address of
each repeater is the first bytes of its public key (the "path hash"). This follows the
comma-separated path field in the MeshCore mobile apps, for example ``"3d,f2,3d"``.
Each comma-separated token can be a contact name (resolved to its key prefix, and the
case is ignored) or a raw hex key prefix. One spec can have both types. The path-hash
*width* comes from the hex tokens that you type (each hop uses the same width).
Contact names are truncated to that width. If the spec has only contact names and no
hex token, the full key prefix of each name is used.
Args:
spec: A comma-separated path, for example ``"3d5f7a,Alice,f2a1b3"``.
contacts: The known contacts, used to resolve names to key prefixes.
Returns:
A comma-separated hex string of prefixes with the same width, for example
``"3d5f7a,d4e5f6,f2a1b3"``.
Raises:
ValueError: If a token is not a known contact and not valid hex, if the hex
tokens have different widths, or if the spec has no usable hops.
"""
by_name = {c.name.casefold(): c.key_prefix for c in (contacts or [])}
# (hex_text, is_name) for each hop. Names keep their full prefix until the width is
# known.
tokens: list[tuple[str, bool]] = []
for raw in spec.split(","):
token = raw.strip()
if not token:
continue
key = token.casefold()
if key in by_name:
tokens.append((by_name[key].lower().removeprefix("0x"), True))
else:
tokens.append((token.lower().removeprefix("0x"), False))
if not tokens:
raise ValueError("path is empty")
# The explicit hex tokens set the width, and names are truncated to match. With names
# only, use the (uniform) length of the resolved prefixes.
hex_widths = {len(text) for text, is_name in tokens if not is_name}
if len(hex_widths) > 1:
raise ValueError("all hops must use the same number of hex digits")
width = hex_widths.pop() if hex_widths else max(len(text) for text, _ in tokens)
if width == 0 or width % 2:
raise ValueError("hex key prefixes must have an even number of digits")
hops: list[str] = []
for text, is_name in tokens:
hop = text[:width] if is_name else text
if len(hop) != width or any(ch not in _HEX_DIGITS for ch in hop):
raise ValueError(f"{text!r} is not a known contact or a {width // 2}-byte hex prefix")
hops.append(hop)
return ",".join(hops)
async def run_traces(
device: Device,
target: str,
*,
samples: int = 5,
path: str | None = None,
cooldown_s: float = 1.0,
timeout: float | None = None,
on_result: ProgressCallback | None = None,
persist: Callable[[TraceResult], Awaitable[None] | None] | None = None,
) -> list[TraceResult]:
"""Run ``samples`` traces to ``target``, with pacing between transmissions.
Args:
device: The connected device through which to trace.
target: The name or key prefix of the destination node.
samples: The number of traces to run.
path: An optional explicit path to force on each trace.
cooldown_s: The delay between traces, to obey the duty cycle of the radio.
timeout: The reply timeout for each trace, in seconds. With ``None`` (the
default), each trace sets its own wait for the route through
:func:`trace_timeout`.
on_result: An optional callback, called as ``(completed, total, result)`` after
each trace, for example to move a progress bar.
persist: An optional callback to store each trace (sync or async).
Returns:
The list of the :class:`TraceResult` objects, in order.
"""
results: list[TraceResult] = []
for i in range(samples):
result = await device.run_trace(target, path=path, timeout=timeout)
results.append(result)
if persist is not None:
maybe = persist(result)
if asyncio.iscoroutine(maybe):
await maybe
if on_result is not None:
on_result(i + 1, samples, result)
if i < samples - 1 and cooldown_s > 0:
await asyncio.sleep(cooldown_s)
return results