Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1053 lines
50 KiB
Python
1053 lines
50 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The observed mesh topology: a link graph distilled from every path we ever received.
|
||
|
||
MeshCore never tells us the network's shape outright, but almost everything we *receive*
|
||
carries a fragment of it: a successful trace demonstrably crossed every link in its path
|
||
(out and back, with an SNR reading at each hop), the firmware's per-contact ``out_path``
|
||
is a route distilled from received floods, and — when the companion's packet logging is
|
||
on — every overheard frame reports the relay chain it rode in on. A fourth fragment can
|
||
be *asked for*: a repeater we hold admin rights on reports its own neighbour table (who
|
||
it hears directly, at what SNR) when queried over the mesh — a second vantage point that
|
||
reveals links, and whole nodes, our radio has never received anything from. This module
|
||
folds those fragments into one undirected evidence graph and answers the questions the
|
||
trace path composer asks of it:
|
||
|
||
* *who neighbours whom* — :meth:`MeshTopology.next_hops`, the suggestion list while
|
||
composing a path hop by hop, sorted by the strongest observed link first;
|
||
* *how might we reach a target* — :meth:`MeshTopology.scenarios`, ranked candidate
|
||
outbound paths (the device's learned route, the direct shot, and the best alternatives
|
||
the evidence supports) ready to be traced or probed.
|
||
|
||
Links are deliberately undirected: radio paths work both ways, and every reading — an SNR
|
||
measured at either end, a route learned from the opposite direction — is evidence for the
|
||
same physical link. Node identity is canonicalized against the contact list, because the
|
||
same node appears in different sources at different hash widths (a 1-byte trace hop, a
|
||
12-hex observation id, a full public key).
|
||
|
||
Everything here is pure data-in/data-out: the callers supply repository rows and contacts,
|
||
so the graph is unit-testable without a device or a database.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import bisect
|
||
import heapq
|
||
import math
|
||
import statistics
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
from itertools import chain, pairwise
|
||
|
||
from ..core.models import Contact, utcnow
|
||
from ..persistence.repository import NeighbourLink, PacketPath, TracedPath
|
||
|
||
#: Canonical id for our own device in the graph (its 12-hex key prefix when known).
|
||
#: Kept distinct from hop hashes by construction: it is derived from the full public key.
|
||
|
||
#: Half-life, in days, of link evidence: a reading a week old counts half as much as one
|
||
#: from just now. Old links stay suggestible but fresh observations dominate the ranking.
|
||
_EVIDENCE_HALF_LIFE_DAYS = 7.0
|
||
|
||
#: SNR range mapped onto the link-quality factor: -15 dB (barely readable) scores the
|
||
#: floor, +10 dB (excellent) the ceiling. Matches the trace screen's quality-bar span.
|
||
_SNR_FLOOR_DB = -15.0
|
||
_SNR_CEIL_DB = 10.0
|
||
_QUALITY_MIN = 0.15
|
||
_QUALITY_MAX = 1.5
|
||
|
||
#: Per-hop cost added when ranking candidate paths, so a marginally "stronger" long way
|
||
#: around doesn't beat a short path — every extra hop is real airtime and a real failure
|
||
#: point (and a trace crosses it twice: out and back).
|
||
_HOP_PENALTY = 0.35
|
||
|
||
#: Bounds for scenario generation: how many alternatives to offer and how deep a
|
||
#: suggested route may go (each hop is crossed twice by the trace's boomerang).
|
||
_MAX_SCENARIOS = 5
|
||
_MAX_SCENARIO_HOPS = 5
|
||
|
||
#: The bar an ambiguous short hash must clear before it folds onto one of the nodes it
|
||
#: could name (see :meth:`MeshTopology._corroborated_owner`): how many *discriminating*
|
||
#: neighbours — ones that link to exactly one candidate — have to vote for the winner, and
|
||
#: how far ahead of the runner-up it has to finish. Both are set to leave a contested stub
|
||
#: alone: two independent neighbours agreeing with nothing dissenting is evidence, one is a
|
||
#: coincidence, and a close second means the hash is probably pooling two nodes' traffic.
|
||
_MIN_CORROBORATION = 2
|
||
_CORROBORATION_MARGIN = 3
|
||
|
||
#: The full canonical node-id width in hex digits (6 bytes of key). An id this wide is never
|
||
#: under-specified, so it can neither be folded onto another nor extend one — which is what
|
||
#: lets the prefix settle walk only the ids *below* this width (see
|
||
#: :meth:`MeshTopology.coalesce_prefixes`).
|
||
_CANONICAL_WIDTH = 12
|
||
|
||
_HEX_DIGITS = frozenset("0123456789abcdef")
|
||
|
||
#: Distinguishes "memoized as None" from "not memoized" in the identity caches.
|
||
_MISS = object()
|
||
|
||
|
||
def is_path_hash(value: str) -> bool:
|
||
"""Whether ``value`` could be a path hash: non-empty hex, a whole number of bytes.
|
||
|
||
THE test, shared by everything that has to tell a hop id from a name or a graph
|
||
sentinel. Even length because a hash is bytes, never a half one — which is also what
|
||
keeps a one-character endpoint marker out.
|
||
"""
|
||
return bool(value) and len(value) % 2 == 0 and all(c in _HEX_DIGITS for c in value)
|
||
|
||
|
||
def render_forced_spec(hops: tuple[str, ...], target_hash: str, width_bytes: int) -> str:
|
||
"""Render a symmetric forced-path spec: outbound hops, the target, the mirror back.
|
||
|
||
The MeshCore trace protocol has no separate "return path" field — the whole
|
||
boomerang (out to the target, back to us) is one path the repeaters walk in
|
||
order, so the spec must spell out the return hops explicitly, not just the
|
||
outbound ones. This renders the *symmetric* round trip — the return leg is the
|
||
outbound hops in reverse, appended here rather than asked of the caller. A
|
||
hand-composed route that comes home a different way is rendered verbatim by
|
||
:func:`render_custom_spec` instead.
|
||
|
||
Args:
|
||
hops: Intermediate repeaters, in order from us outward, excluding both
|
||
endpoints (empty = trace the target directly, no forced hops).
|
||
target_hash: The target's hex hash (any width ≥ 1 byte).
|
||
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8);
|
||
shrunk via :func:`collapse_width` when a hop's known hash is narrower.
|
||
|
||
Returns:
|
||
A comma-separated hex spec, e.g. ``"3d,f2,3d"`` for one forced hop.
|
||
"""
|
||
width = collapse_width(*hops, target_hash, ceiling=width_bytes)
|
||
outbound = [h[: width * 2] for h in (*hops, target_hash)]
|
||
outbound.extend(h[: width * 2] for h in reversed(hops))
|
||
return ",".join(outbound)
|
||
|
||
|
||
def render_custom_spec(hops: tuple[str, ...], width_bytes: int) -> str:
|
||
"""Render a hand-composed (asymmetric) path spec exactly as given.
|
||
|
||
The counterpart of :func:`render_forced_spec` for a route the user spelled out in
|
||
full — out to the target and back home by whatever way they chose. Nothing is
|
||
appended or mirrored: the hops *are* the path, and the trace protocol happily
|
||
walks any sequence that ends within earshot of us. Only the shared width rules
|
||
apply.
|
||
|
||
Args:
|
||
hops: Every hop of the route in walk order, the target among them, excluding
|
||
our own device at both ends (the reply lands on us off the final hop).
|
||
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8);
|
||
shrunk via :func:`collapse_width` when a hop's known hash is narrower.
|
||
|
||
Returns:
|
||
A comma-separated hex spec, e.g. ``"3d,f2,27"`` — or ``""`` with no hops
|
||
(there is no meaningful zero-hop custom route).
|
||
"""
|
||
if not hops:
|
||
return ""
|
||
width = collapse_width(*hops, ceiling=width_bytes)
|
||
return ",".join(h[: width * 2] for h in hops)
|
||
|
||
|
||
def collapse_width(*hashes: str, ceiling: int) -> int:
|
||
"""The widest representable per-hop hash width every given hash can honour.
|
||
|
||
Trace paths transmit hops at a uniform width of 1, 2, 4, or 8 bytes. Canonical node
|
||
ids are usually 6 bytes, but an unidentified hop may be as short as the width it was
|
||
once traced at — the spec must shrink to what its narrowest hash can supply.
|
||
|
||
Args:
|
||
*hashes: The hex hashes destined for one spec.
|
||
ceiling: The width the caller would prefer (bytes, itself 1/2/4/8).
|
||
|
||
Returns:
|
||
The largest of 1/2/4/8 that is ≤ ``ceiling`` and ≤ every hash's byte length.
|
||
"""
|
||
limit = min([ceiling, *(len(h) // 2 for h in hashes if h)] or [ceiling])
|
||
return max(s for s in (1, 2, 4, 8) if s <= max(limit, 1))
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Link:
|
||
"""The accumulated evidence for one undirected link between two nodes.
|
||
|
||
Attributes:
|
||
a: Canonical id of one endpoint (the lexically smaller, so the pair is a key).
|
||
b: Canonical id of the other endpoint.
|
||
samples: How many independent readings crossed this link, any source.
|
||
snrs: The SNR readings (dB) attributed to the link, either direction.
|
||
last_seen: When the link was most recently observed.
|
||
sources: Which evidence classes saw it (``trace`` / ``route`` / ``packet`` /
|
||
``neighbour``).
|
||
"""
|
||
|
||
a: str
|
||
b: str
|
||
samples: int = 0
|
||
snrs: list[float] = field(default_factory=list)
|
||
last_seen: datetime | None = None
|
||
sources: set[str] = field(default_factory=set)
|
||
|
||
@property
|
||
def median_snr(self) -> float | None:
|
||
"""Median SNR (dB) across the link's readings, or ``None`` without any."""
|
||
return statistics.median(self.snrs) if self.snrs else None
|
||
|
||
def strength(self, now: datetime | None = None) -> float:
|
||
"""Score the link's observed reliability for ranking (higher = stronger).
|
||
|
||
Three factors multiply: *evidence* (log-scaled sample count, so ten sightings
|
||
beat one but a thousand don't drown everything), *recency* (exponential decay
|
||
with a :data:`one-week half-life <_EVIDENCE_HALF_LIFE_DAYS>`), and *quality*
|
||
(median SNR mapped linearly from -15 dB → :data:`_QUALITY_MIN` to +10 dB →
|
||
beyond 1.0; links with no SNR reading — e.g. known only from a firmware route —
|
||
score a neutral 1.0 rather than being punished for missing data).
|
||
|
||
Args:
|
||
now: The reference time for recency (defaults to the current time).
|
||
|
||
Returns:
|
||
The link's strength score, ``> 0``.
|
||
"""
|
||
now = now or utcnow()
|
||
evidence = 1.0 + math.log2(1 + self.samples)
|
||
recency = 1.0
|
||
if self.last_seen is not None and getattr(self.last_seen, "tzinfo", None):
|
||
age_days = max(0.0, (now - self.last_seen).total_seconds() / 86400.0)
|
||
recency = 0.5 ** (age_days / _EVIDENCE_HALF_LIFE_DAYS)
|
||
snr = self.median_snr
|
||
quality = 1.0
|
||
if snr is not None:
|
||
span = _SNR_CEIL_DB - _SNR_FLOOR_DB
|
||
frac = (snr - _SNR_FLOOR_DB) / span
|
||
reach = _QUALITY_MIN + frac * (_QUALITY_MAX - _QUALITY_MIN)
|
||
quality = min(_QUALITY_MAX, max(_QUALITY_MIN, reach))
|
||
return evidence * recency * quality
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class HopSuggestion:
|
||
"""One suggested next hop while composing a path.
|
||
|
||
Attributes:
|
||
node: Canonical id of the suggested node.
|
||
link: The evidence for the link from the path's current tail to ``node``.
|
||
strength: The link's precomputed strength (the sort key), so the UI can render
|
||
a stable ranking without re-scoring per repaint.
|
||
"""
|
||
|
||
node: str
|
||
link: Link
|
||
strength: float
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class PathScenario:
|
||
"""One candidate outbound route to a target, ready to trace.
|
||
|
||
A scenario stores only the *outbound* leg — the return is always the symmetric
|
||
mirror, so there's nothing to choose there — but :meth:`spec` renders the full
|
||
boomerang, since the trace protocol has no separate return-path field: the whole
|
||
out-and-back route is one spec the repeaters walk in order.
|
||
|
||
Attributes:
|
||
label: Short human description of where the route came from.
|
||
hops: Canonical ids of the intermediate repeaters, in order from us outward —
|
||
excluding both endpoints (empty = trace the target directly).
|
||
source: The evidence class that proposed it (``device`` / ``direct`` /
|
||
``observed``).
|
||
score: Ranking score (higher first): the path's weakest-link strength, hop count
|
||
penalized. Direct/device scenarios with no observed evidence score 0.
|
||
weakest_snr: The lowest per-link median SNR (dB) along the route, when known —
|
||
the bottleneck the trace is expected to measure.
|
||
samples: Total readings across the route's links (a rough confidence figure).
|
||
"""
|
||
|
||
label: str
|
||
hops: tuple[str, ...]
|
||
source: str
|
||
score: float
|
||
weakest_snr: float | None = None
|
||
samples: int = 0
|
||
|
||
def spec(self, target_hash: str, width_bytes: int) -> str:
|
||
"""Render the scenario as a forced-path spec, return leg included.
|
||
|
||
See :func:`render_forced_spec` for the width-collapsing and return-leg rules.
|
||
|
||
Args:
|
||
target_hash: The target's hex hash (any width ≥ 1 byte).
|
||
width_bytes: Preferred per-hop path-hash width in bytes (1, 2, 4, or 8).
|
||
|
||
Returns:
|
||
A comma-separated hex spec, e.g. ``"3d,f2,3d"``.
|
||
"""
|
||
return render_forced_spec(self.hops, target_hash, width_bytes)
|
||
|
||
|
||
class MeshTopology:
|
||
"""The evidence graph over observed mesh links, queryable for routing suggestions.
|
||
|
||
Build one with :func:`build_topology`; instances are cheap, immutable-in-spirit
|
||
snapshots — rebuild rather than mutate when fresh evidence lands.
|
||
"""
|
||
|
||
def __init__(self, self_id: str, contacts: list[Contact]) -> None:
|
||
"""Create an empty graph rooted at our own node.
|
||
|
||
Args:
|
||
self_id: Canonical id of our own device (12-hex key prefix, lowercased).
|
||
contacts: Known contacts, used to canonicalize hop hashes of any width to
|
||
stable node ids and to name nodes for display.
|
||
"""
|
||
self.self_id = self_id.lower().removeprefix("0x")[:12]
|
||
self._links: dict[tuple[str, str], Link] = {}
|
||
self._now = utcnow()
|
||
# Contact index for canonicalization: full lowercased public keys (falling back
|
||
# to the stored prefix) mapped to the 12-hex canonical id and display name.
|
||
self._known: list[tuple[str, str, str]] = [] # (full_key, canonical, name)
|
||
for c in contacts:
|
||
key = (c.public_key or c.key_prefix or "").lower().removeprefix("0x")
|
||
if key:
|
||
self._known.append((key, key[:12], c.name))
|
||
# ``_known`` never changes after construction, and a build feeds the same few
|
||
# dozen distinct hashes through canonical() thousands of times (every hop of
|
||
# every stored packet path), so both identity lookups memoize per instance.
|
||
self._canonical_memo: dict[str, str | None] = {}
|
||
self._name_memo: dict[str, str | None] = {}
|
||
# Route search results per (target, k), valid for one graph shape: every link
|
||
# mutation bumps the version, so a memoized Yen's run can never outlive the
|
||
# evidence it ranked. Callers ask for the same target twice per screen open
|
||
# (suggested() and scenarios() both run the search), and the search is the
|
||
# single priciest part of a topology-backed open.
|
||
self._graph_version = 0
|
||
self._routes_memo: dict[tuple[str, int, int], list[tuple[str, ...]]] = {}
|
||
|
||
# --- identity ----------------------------------------------------------------
|
||
|
||
def canonical(self, hop: str | None) -> str | None:
|
||
"""Collapse a hop hash of any width onto a stable node id.
|
||
|
||
Trace hops arrive at the command's path-hash width (1-8 bytes), observation ids
|
||
at 12 hex, routes at the region's width — all naming the same nodes. A hash that
|
||
prefix-matches exactly one known contact adopts that contact's 12-hex id; an
|
||
ambiguous or unknown hash stays as itself, so evidence about nodes we can't
|
||
identify is kept (under the short id) rather than guessed onto the wrong node.
|
||
|
||
Args:
|
||
hop: The raw hex hash (any case, optional ``0x``), or ``None``/empty.
|
||
|
||
Returns:
|
||
The canonical id, or ``None`` for a missing/non-hex hop (non-hex covers
|
||
legacy simulator artefacts like ``hop0``).
|
||
"""
|
||
if not hop:
|
||
return None
|
||
cached = self._canonical_memo.get(hop, _MISS)
|
||
if cached is not _MISS:
|
||
return cached
|
||
needle = hop.lower().removeprefix("0x")
|
||
if not is_path_hash(needle):
|
||
result: str | None = None
|
||
else:
|
||
matches = {
|
||
canonical
|
||
for key, canonical, _name in self._known
|
||
if key.startswith(needle) or needle.startswith(key[:12])
|
||
}
|
||
result = next(iter(matches)) if len(matches) == 1 else needle[:12]
|
||
self._canonical_memo[hop] = result
|
||
return result
|
||
|
||
def display_name(self, node: str) -> str | None:
|
||
"""The contact name for a canonical id, or ``None`` when unknown."""
|
||
cached = self._name_memo.get(node, _MISS)
|
||
if cached is not _MISS:
|
||
return cached
|
||
result = next((name for _key, canonical, name in self._known if canonical == node), None)
|
||
self._name_memo[node] = result
|
||
return result
|
||
|
||
# --- construction ------------------------------------------------------------
|
||
|
||
def add_walk(
|
||
self,
|
||
nodes: list[str | None],
|
||
*,
|
||
snrs: list[float | None] | None = None,
|
||
when: datetime | None = None,
|
||
source: str,
|
||
) -> None:
|
||
"""Record one walked node sequence as a set of link readings.
|
||
|
||
Consecutive nodes become one reading each on their undirected link. ``nodes``
|
||
holds canonical ids (``None`` entries — unresolvable hops — break the chain, so
|
||
no false adjacency is invented across them). Self-loops (a hash repeated
|
||
back-to-back, e.g. a manually forced there-and-back) are skipped.
|
||
|
||
Args:
|
||
nodes: The walked sequence, endpoints included, in order.
|
||
snrs: Optional per-link SNR readings aligned with the links (``snrs[i]``
|
||
belongs to the ``nodes[i] → nodes[i+1]`` link, measured at arrival).
|
||
when: When the walk was observed (stamps every touched link's recency).
|
||
source: Evidence class tag (``trace`` / ``route`` / ``packet`` /
|
||
``neighbour``).
|
||
"""
|
||
self._graph_version += 1 # link evidence moved; memoized route searches expire
|
||
for i in range(len(nodes) - 1):
|
||
a, b = nodes[i], nodes[i + 1]
|
||
if not a or not b or a == b:
|
||
continue
|
||
key = (a, b) if a < b else (b, a)
|
||
link = self._links.get(key)
|
||
if link is None:
|
||
link = self._links[key] = Link(a=key[0], b=key[1])
|
||
link.samples += 1
|
||
link.sources.add(source)
|
||
snr = snrs[i] if snrs and i < len(snrs) else None
|
||
if snr is not None:
|
||
link.snrs.append(snr)
|
||
if when is not None and (link.last_seen is None or when > link.last_seen):
|
||
link.last_seen = when
|
||
|
||
def coalesce_prefixes(self) -> None:
|
||
"""Fold each under-specified node id into the longer id it can only be.
|
||
|
||
The same physical node reaches the graph at different hash widths: a trace hop
|
||
logged at 1 byte (``27``), a packet relay at 3 (``27d439``), a full 6-byte
|
||
contact id (``27d4396a2967``). :meth:`canonical` widens a hop onto a contact id
|
||
only when the prefix names *exactly one* contact — but the mesh holds two nodes
|
||
whose keys both open ``27`` (``27d4…`` and ``27e4…``), so the bare ``27`` hop
|
||
stays short and stands beside the wide ``27d4396a2967`` as a phantom second node.
|
||
Left alone that doubles the node on every surface the graph feeds — the mesh walk
|
||
draws it twice, the route graph fans two lanes to one repeater, and the scenario
|
||
ranker offers a redundant weaker route through the stub.
|
||
|
||
This closes the gap using the evidence the graph *actually holds* rather than the
|
||
whole contact list, in two passes of descending confidence:
|
||
|
||
1. **By prefix alone** (:meth:`_next_prefix_merge`) — a short id is merged into a
|
||
longer node id present in the graph when it is a strict prefix of **exactly
|
||
one** of them (following a prefix chain — ``65`` → ``6532`` → ``6532eb`` — to
|
||
its longest end). Nothing is inferred: the short can only mean that one node.
|
||
2. **By corroboration** (:meth:`_next_corroborated_merge`) — a short id that opens
|
||
two distinct longer nodes (``c5`` → ``c5bc…`` and ``c5ba…``) is ambiguous by
|
||
prefix, but the graph usually knows which one it is anyway: the stub carries the
|
||
links of whichever node it really was, so its *neighbourhood* names the owner.
|
||
Neighbours that can tell the candidates apart vote, and a decisive result folds
|
||
the stub onto the winner. See that method for the bar a vote has to clear.
|
||
|
||
The passes interleave: the graph changes with every merge, so an ambiguity can
|
||
resolve itself once a neighbouring stub folds, and the certain pass is always
|
||
re-run before the inferring one gets another turn.
|
||
|
||
A short id that opens no longer node at all (one we have only ever heard narrowly)
|
||
keeps its width — it is one node, merely under-named — and so does one whose vote
|
||
stays contested. Merging folds the short id's links into the wide id's, summing
|
||
samples, pooling SNR readings and sources, and keeping the freshest sighting, so
|
||
the wide node inherits every reading the stub had gathered. Idempotent: a second
|
||
call finds nothing left to merge.
|
||
|
||
Called once at the end of :func:`build_topology`, so every consumer sees each node
|
||
once. Safe for our own node (its 12-hex id is never a short prefix candidate).
|
||
|
||
**One neighbour table is built and then carried across the merges**, rather than the
|
||
whole graph being walked afresh on every turn. Three separate passes over every link
|
||
used to run per merge — deriving the node set, building the neighbour table the
|
||
corroboration vote reads, and hunting down the links that name the id being folded —
|
||
and on the device's mesh that is ~1150 links walked three times over, some eighty
|
||
times: the largest remaining cost of opening the mesh walk. All three questions are
|
||
answered by the same table, and a merge's effect on it is knowable exactly where it
|
||
happens (see :meth:`_merge_node`), so the table is the graph's live node set and
|
||
adjacency for the whole settle. Its keys *are* the node ids: a node exists here
|
||
precisely while it holds a link.
|
||
"""
|
||
adjacency = self._adjacency()
|
||
while True:
|
||
# Only an under-specified id can fold, and a full-width one extends nothing —
|
||
# so both passes walk just the short ids, shortest first (a 65 → 6532 → 6532eb
|
||
# chain collapses from the tail end inward over successive turns), and a graph
|
||
# of fully named nodes settles without either of them looking at a single link.
|
||
#
|
||
# Ties break lexicographically, which is what makes a settle *repeatable*.
|
||
# Ordering by length alone leaves ids of equal width in whatever order the node
|
||
# set happened to iterate, and CPython randomizes string hashing per process —
|
||
# so two runs over the same evidence could take the merges in different orders
|
||
# and, where a fold changes what a later one sees, arrive at graphs that differ.
|
||
shorts = sorted(
|
||
(node for node in adjacency if len(node) < _CANONICAL_WIDTH),
|
||
key=lambda node: (len(node), node),
|
||
)
|
||
if not shorts:
|
||
return
|
||
ordered = sorted(adjacency)
|
||
merge = self._next_prefix_merge(shorts, ordered) or self._next_corroborated_merge(
|
||
shorts, ordered, adjacency
|
||
)
|
||
if merge is None:
|
||
return
|
||
self._merge_node(*merge, adjacency=adjacency)
|
||
|
||
def _node_ids(self) -> set[str]:
|
||
"""Every node id that currently appears as a link endpoint."""
|
||
return set(chain.from_iterable(self._links))
|
||
|
||
def _next_prefix_merge(self, shorts: list[str], ordered: list[str]) -> tuple[str, str] | None:
|
||
"""The next ``(short, long)`` pair to fold, or ``None`` when none remains.
|
||
|
||
A short id (under a full 6-byte canonical width) folds when the graph's longer
|
||
ids that extend it all lie on one prefix chain — i.e. the longest of them starts
|
||
with every other — so the short can only mean that one node.
|
||
|
||
Args:
|
||
shorts: The under-specified node ids, shortest first — so a ``65`` → ``6532``
|
||
→ ``6532eb`` chain collapses from the tail end inward over successive calls.
|
||
ordered: Every node id, sorted lexicographically (see :meth:`_extensions`).
|
||
"""
|
||
for short in shorts:
|
||
exts = self._extensions(short, ordered)
|
||
if not exts:
|
||
continue
|
||
longest = max(exts, key=len)
|
||
if all(longest.startswith(ext) for ext in exts): # one node, not two
|
||
return short, longest
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extensions(short: str, ordered: list[str]) -> list[str]:
|
||
"""Every node id in ``ordered`` that strictly extends the under-specified ``short``.
|
||
|
||
Empty for a full-width canonical id (12 hex is never under-specified) and for a
|
||
short id nothing in the graph extends.
|
||
|
||
``ordered`` must be the node ids sorted lexicographically, which is what makes this
|
||
cheap: every id sharing a prefix sorts into one contiguous run, so the extensions
|
||
are a slice found by binary search rather than a scan of the whole graph. That
|
||
matters because the caller asks this question once per node, and its own caller
|
||
starts the whole pass again after every merge — a mesh with ~1100 links and a couple
|
||
of hundred merges to settle turned a linear scan here into ~24 million ``len``
|
||
calls and sixteen seconds of opening the mesh walk.
|
||
"""
|
||
if len(short) >= _CANONICAL_WIDTH:
|
||
return []
|
||
out: list[str] = []
|
||
width = len(short)
|
||
for i in range(bisect.bisect_left(ordered, short), len(ordered)):
|
||
other = ordered[i]
|
||
if not other.startswith(short):
|
||
break # past the run: nothing further can share the prefix
|
||
if len(other) > width:
|
||
out.append(other)
|
||
return out
|
||
|
||
def _next_corroborated_merge(
|
||
self, shorts: list[str], ordered: list[str], adjacency: dict[str, set[str]]
|
||
) -> tuple[str, str] | None:
|
||
"""The next ambiguous stub the *neighbourhood evidence* resolves, or ``None``.
|
||
|
||
Where :meth:`_next_prefix_merge` folds only what the hash alone settles, this
|
||
answers the case it walks away from: a short id like ``bf`` that opens two real
|
||
nodes (``bf61f2…`` and ``bfbeef…``). Prefix-wise that is a coin toss — but the stub
|
||
is not an empty label. It carries the links of whichever node it actually was, so
|
||
the company it keeps names its owner: every neighbour that can *tell the candidates
|
||
apart* — one it links to that links to exactly one candidate — is a vote, and the
|
||
rest (neighbours both candidates share, which two repeaters in the same city have
|
||
plenty of) abstain rather than drown the signal in local density.
|
||
|
||
The bar is deliberately high, because a wrong merge does what this whole routine
|
||
exists to prevent — attributes one node's evidence to another. The winner must draw
|
||
at least :data:`_MIN_CORROBORATION` discriminating votes *and* lead the runner-up by
|
||
:data:`_CORROBORATION_MARGIN`×, so a genuinely contested stub (one whose neighbours
|
||
point both ways, i.e. one that really is two nodes' traffic pooled under one hash)
|
||
stays exactly where it was: standing on its own, under-named but honest.
|
||
|
||
Shortest ids are offered first, matching :meth:`_next_prefix_merge`, and only the
|
||
*maximal* candidates run — a chain's inner links (``f0`` → ``f062eb`` →
|
||
``f062eb…``) are the same node, so they never split their own vote.
|
||
|
||
Args:
|
||
shorts: The under-specified node ids, shortest first.
|
||
ordered: Every node id, sorted lexicographically (see :meth:`_extensions`).
|
||
adjacency: The graph's live neighbour table, carried by the settle loop that
|
||
calls this (see :meth:`coalesce_prefixes`).
|
||
"""
|
||
for short in shorts:
|
||
exts = self._extensions(short, ordered)
|
||
candidates = [
|
||
ext
|
||
for ext in exts
|
||
if not any(other != ext and other.startswith(ext) for other in exts)
|
||
]
|
||
if len(candidates) < 2: # settled (or ignored) by the certain pass
|
||
continue
|
||
owner = self._corroborated_owner(short, candidates, adjacency)
|
||
if owner is not None:
|
||
return short, owner
|
||
return None
|
||
|
||
def _adjacency(self) -> dict[str, set[str]]:
|
||
"""The graph as neighbour sets, for the questions link pairs answer awkwardly."""
|
||
adjacency: dict[str, set[str]] = {}
|
||
for a, b in self._links:
|
||
adjacency.setdefault(a, set()).add(b)
|
||
adjacency.setdefault(b, set()).add(a)
|
||
return adjacency
|
||
|
||
@staticmethod
|
||
def _corroborated_owner(
|
||
short: str, candidates: list[str], adjacency: dict[str, set[str]]
|
||
) -> str | None:
|
||
"""Which candidate the stub's discriminating neighbours elect, if any.
|
||
|
||
A neighbour votes only when it separates the field — it neighbours exactly one
|
||
candidate — so shared company counts for nothing and the ``short``/candidate ids
|
||
themselves never vote for their own case. ``None`` means no decisive winner: too
|
||
few votes, or a runner-up close enough that the stub may well be both nodes'
|
||
traffic under one hash.
|
||
|
||
Args:
|
||
short: The under-specified id being resolved.
|
||
candidates: The distinct node ids it could name (two or more).
|
||
adjacency: Neighbour sets over the whole graph (see :meth:`_adjacency`).
|
||
|
||
Returns:
|
||
The elected candidate, or ``None`` to leave the stub standing.
|
||
"""
|
||
field = set(candidates) | {short}
|
||
votes = dict.fromkeys(candidates, 0)
|
||
for neighbour in adjacency.get(short, ()) - field:
|
||
electors = [c for c in candidates if neighbour in adjacency.get(c, ())]
|
||
if len(electors) == 1:
|
||
votes[electors[0]] += 1
|
||
ranked = sorted(votes.items(), key=lambda pair: (-pair[1], pair[0]))
|
||
(winner, top), (_runner, second) = ranked[0], ranked[1]
|
||
if top < _MIN_CORROBORATION or top < _CORROBORATION_MARGIN * second:
|
||
return None
|
||
return winner
|
||
|
||
def _merge_node(
|
||
self, src: str, dst: str, *, adjacency: dict[str, set[str]] | None = None
|
||
) -> None:
|
||
"""Relabel every link touching ``src`` onto ``dst``, folding shared links together.
|
||
|
||
Reached through the neighbour table rather than by scanning the graph: a link is
|
||
addressed by its two endpoints, so ``src``'s neighbours name its links directly —
|
||
a handful of dict lookups instead of a walk over every link in the mesh, on every
|
||
one of a settle's many merges.
|
||
|
||
Args:
|
||
src: The under-specified id being folded away.
|
||
dst: The node id it can only have meant.
|
||
adjacency: The caller's live neighbour table, **updated in place** to match the
|
||
reshaped graph: ``src`` leaves it, ``dst`` inherits ``src``'s neighbours,
|
||
and ``dst`` itself leaves in the one corner where every link ``src`` had ran
|
||
to ``dst`` — each collapses to a self-loop and is discarded, so unless
|
||
``dst`` held a link of its own it goes with them. No other node can lose its
|
||
last link here: a relabelled link keeps its far end. Omit it for a one-off
|
||
merge and the table is built (and thrown away) here.
|
||
"""
|
||
if adjacency is None:
|
||
adjacency = self._adjacency()
|
||
self._graph_version += 1 # the graph is being reshaped; memoized searches expire
|
||
for far in adjacency.pop(src, ()):
|
||
link = self._links.pop((src, far) if src < far else (far, src), None)
|
||
if link is None:
|
||
continue
|
||
adjacency[far].discard(src)
|
||
if far == dst: # a src→dst link (src prefixes dst) collapses to a self-loop
|
||
continue
|
||
new_key = (dst, far) if dst < far else (far, dst)
|
||
existing = self._links.get(new_key)
|
||
if existing is None:
|
||
self._links[new_key] = Link(
|
||
a=new_key[0],
|
||
b=new_key[1],
|
||
samples=link.samples,
|
||
snrs=list(link.snrs),
|
||
last_seen=link.last_seen,
|
||
sources=set(link.sources),
|
||
)
|
||
else:
|
||
existing.samples += link.samples
|
||
existing.snrs.extend(link.snrs)
|
||
existing.sources |= link.sources
|
||
if link.last_seen is not None and (
|
||
existing.last_seen is None or link.last_seen > existing.last_seen
|
||
):
|
||
existing.last_seen = link.last_seen
|
||
adjacency.setdefault(dst, set()).add(far)
|
||
adjacency[far].add(dst)
|
||
if not adjacency.get(dst, ()):
|
||
adjacency.pop(dst, None) # every link it had ran to the stub now folded away
|
||
|
||
# --- queries -----------------------------------------------------------------
|
||
|
||
@property
|
||
def link_count(self) -> int:
|
||
"""How many distinct links the graph holds evidence for."""
|
||
return len(self._links)
|
||
|
||
def links(self) -> list[Link]:
|
||
"""Every link the graph holds evidence for (no particular order).
|
||
|
||
The whole-graph view the Mesh walk draws; path queries should prefer
|
||
:meth:`next_hops` / :meth:`scenarios`, which rank and filter.
|
||
"""
|
||
return list(self._links.values())
|
||
|
||
def link(self, a: str, b: str) -> Link | None:
|
||
"""The evidence for the undirected link between ``a`` and ``b``, if any."""
|
||
return self._links.get((a, b) if a < b else (b, a))
|
||
|
||
def next_hops(self, tail: str, *, exclude: frozenset[str] = frozenset()) -> list[HopSuggestion]:
|
||
"""Suggest nodes reachable from ``tail``, strongest observed link first.
|
||
|
||
This is the composer's step-by-step feed: standing at the path's current tail,
|
||
every node the evidence says ``tail`` can hear (links are bidirectional, so a
|
||
path observed in either direction qualifies) — minus the nodes already used.
|
||
|
||
Args:
|
||
tail: Canonical id the path currently ends at (our own id to start).
|
||
exclude: Canonical ids to omit (nodes already in the path; revisiting one
|
||
is never useful and duplicated hops are dropped by repeaters anyway).
|
||
|
||
Returns:
|
||
Suggestions sorted by descending link strength.
|
||
"""
|
||
out: list[HopSuggestion] = []
|
||
for (a, b), link in self._links.items():
|
||
other = b if a == tail else a if b == tail else None
|
||
if other is None or other in exclude:
|
||
continue
|
||
out.append(HopSuggestion(node=other, link=link, strength=link.strength(self._now)))
|
||
out.sort(key=lambda s: (-s.strength, s.node))
|
||
return out
|
||
|
||
def scenarios(
|
||
self, target: str, *, device_route: tuple[str, ...] | None = None
|
||
) -> list[PathScenario]:
|
||
"""Rank the candidate outbound routes for reaching ``target``.
|
||
|
||
Three families, deduplicated in this priority order:
|
||
|
||
1. **device** — the route the firmware itself learned from received floods (what
|
||
an auto-routed trace would walk), when the caller supplies one;
|
||
2. **direct** — no repeaters at all, always offered as the baseline;
|
||
3. **observed** — the strongest simple paths through the evidence graph
|
||
(weakest-link scoring with a per-hop penalty), which is where a path the
|
||
device never learned — but the data supports — comes from.
|
||
|
||
Every scenario stores an *outbound* leg only — the return is always those hops
|
||
mirrored, so there's nothing to rank there — but :meth:`PathScenario.spec`
|
||
renders the full boomerang; intermediate links are implicitly crossed twice.
|
||
|
||
Args:
|
||
target: The target's canonical id.
|
||
device_route: The firmware's learned route (canonical intermediate hops),
|
||
if the contact has one.
|
||
|
||
Returns:
|
||
Scenarios ranked best-evidence first (the device route, when present, is
|
||
always listed first regardless of score — it is what "auto" would do).
|
||
"""
|
||
seen: set[tuple[str, ...]] = set()
|
||
out: list[PathScenario] = []
|
||
|
||
def add(label: str, hops: tuple[str, ...], source: str) -> None:
|
||
if hops in seen or len(hops) > _MAX_SCENARIO_HOPS or target in hops:
|
||
return
|
||
seen.add(hops)
|
||
score, weakest, samples = self._score_route(hops, target)
|
||
out.append(
|
||
PathScenario(
|
||
label=label,
|
||
hops=hops,
|
||
source=source,
|
||
score=score,
|
||
weakest_snr=weakest,
|
||
samples=samples,
|
||
)
|
||
)
|
||
|
||
if device_route is not None:
|
||
add("device route", device_route, "device")
|
||
add("direct", (), "direct")
|
||
for hops in self._best_routes(target, k=_MAX_SCENARIOS):
|
||
add("observed path", hops, "observed")
|
||
|
||
# Rank observed evidence, but keep the device route pinned on top: it is the
|
||
# reference point every alternative is compared against.
|
||
head = [s for s in out if s.source == "device"]
|
||
rest = sorted(
|
||
(s for s in out if s.source != "device"),
|
||
key=lambda s: (-s.score, len(s.hops)),
|
||
)
|
||
return (head + rest)[:_MAX_SCENARIOS]
|
||
|
||
def suggested(self, target: str) -> PathScenario | None:
|
||
"""The single best *evidence-backed* outbound route to ``target``, if one exists.
|
||
|
||
The data-driven answer to "what's the best way to reach this node?": the highest-
|
||
scoring **observed** scenario — a route through repeaters the recorder has actually
|
||
watched carry traffic (traces, overheard relay chains, fetched neighbour tables),
|
||
ranked weakest-link-first with a per-hop penalty and its links' sample counts and
|
||
median SNR behind the score. It deliberately ignores the *device* and *direct*
|
||
families :meth:`scenarios` also offers: the firmware's learned route is what an
|
||
auto-routed trace already walks (the caller prefers it when it has one), and a
|
||
direct shot is the trivial fallback — neither is a *suggestion* the observations
|
||
made. A node the evidence can only reach directly (or not at all) yields ``None``,
|
||
so the caller keeps whatever default it would have used.
|
||
|
||
Args:
|
||
target: The target's canonical id.
|
||
|
||
Returns:
|
||
The best observed :class:`PathScenario` (always with at least one intermediate
|
||
hop and a positive, evidence-backed score), or ``None`` when the observations
|
||
support nothing better than a direct route.
|
||
"""
|
||
for scenario in self.scenarios(target):
|
||
if scenario.source == "observed" and scenario.hops and scenario.score > 0:
|
||
return scenario
|
||
return None
|
||
|
||
# --- internals -----------------------------------------------------------------
|
||
|
||
def _score_route(self, hops: tuple[str, ...], target: str) -> tuple[float, float | None, int]:
|
||
"""Score one outbound route by its weakest observed link.
|
||
|
||
A chain is only as reliable as its weakest link, so the route's score is the
|
||
minimum link strength along ``us → hops… → target``, discounted per hop (see
|
||
:data:`_HOP_PENALTY`). A route containing a link we have *no* evidence for
|
||
scores 0 — offerable, but ranked below anything actually observed.
|
||
|
||
Args:
|
||
hops: Intermediate canonical ids (may be empty for a direct route).
|
||
target: The destination's canonical id.
|
||
|
||
Returns:
|
||
``(score, weakest_median_snr, total_samples)``.
|
||
"""
|
||
chain = [self.self_id, *hops, target]
|
||
weakest_strength: float | None = None
|
||
weakest_snr: float | None = None
|
||
samples = 0
|
||
for a, b in pairwise(chain):
|
||
link = self.link(a, b)
|
||
if link is None:
|
||
return 0.0, None, samples
|
||
strength = link.strength(self._now)
|
||
samples += link.samples
|
||
if weakest_strength is None or strength < weakest_strength:
|
||
weakest_strength = strength
|
||
snr = link.median_snr
|
||
if snr is not None and (weakest_snr is None or snr < weakest_snr):
|
||
weakest_snr = snr
|
||
score = (weakest_strength or 0.0) / (1.0 + _HOP_PENALTY * len(hops))
|
||
return score, weakest_snr, samples
|
||
|
||
def _best_routes(self, target: str, *, k: int) -> list[tuple[str, ...]]:
|
||
"""Find up to ``k`` strong simple paths from us to ``target``, cheapest first.
|
||
|
||
Yen's k-shortest-loopless-paths over the evidence graph. Each link costs
|
||
``1/strength + _HOP_PENALTY`` — a weak or roundabout way scores dearer — and the
|
||
routes come back ranked by total cost, cheapest first (the candidate set
|
||
:meth:`scenarios` then re-scores by weakest link). Simple (loopless) paths only,
|
||
since revisiting a node is never useful, and only routes within
|
||
:data:`_MAX_SCENARIO_HOPS` intermediate hops — a longer chain is more airtime and
|
||
more failure points than a trace should carry (and it is crossed twice).
|
||
|
||
Yen's builds on plain Dijkstra rather than enumerating simple paths with a
|
||
best-first frontier, because that frontier is pathological on a real mesh graph.
|
||
A well-heard core has cheap links everywhere, so a partial-path search *floods*
|
||
it — every wandering prefix through the core costs less than the one weak link a
|
||
distant target sits behind — exploring millions of dead-end prefixes before it
|
||
reaches the target (tens of seconds for a busy node). Yen's instead finds the
|
||
single shortest path, then derives each next-shortest by rerouting around one
|
||
edge of the previous one, so the work stays polynomial in the graph size however
|
||
dense the core: the same routes, in the same cost order (ties aside), in
|
||
milliseconds.
|
||
|
||
Args:
|
||
target: The destination's canonical id.
|
||
k: Maximum number of routes to return.
|
||
|
||
Returns:
|
||
The intermediate-hop tuples of the found routes (endpoints stripped),
|
||
cheapest total-cost first. Memoized per graph version (see
|
||
``_routes_memo``) — callers treat the list as read-only, which they do
|
||
(both consumers only iterate it).
|
||
"""
|
||
memo_key = (target, k, self._graph_version)
|
||
memoized = self._routes_memo.get(memo_key)
|
||
if memoized is not None:
|
||
return memoized
|
||
neighbors: dict[str, list[tuple[str, float]]] = {}
|
||
for (a, b), link in self._links.items():
|
||
cost = 1.0 / max(link.strength(self._now), 1e-6) + _HOP_PENALTY
|
||
neighbors.setdefault(a, []).append((b, cost))
|
||
neighbors.setdefault(b, []).append((a, cost))
|
||
|
||
def shortest(
|
||
source: str, banned_nodes: set[str], banned_edges: set[tuple[str, str]]
|
||
) -> tuple[float, list[str]] | None:
|
||
"""Dijkstra ``source``→``target`` avoiding the banned nodes/edges, or ``None``."""
|
||
dist: dict[str, float] = {source: 0.0}
|
||
prev: dict[str, str] = {}
|
||
heap: list[tuple[float, str]] = [(0.0, source)]
|
||
done: set[str] = set()
|
||
while heap:
|
||
d, u = heapq.heappop(heap)
|
||
if u in done:
|
||
continue
|
||
done.add(u)
|
||
if u == target:
|
||
break
|
||
for v, cost in neighbors.get(u, ()):
|
||
if v in banned_nodes or (u, v) in banned_edges:
|
||
continue
|
||
nd = d + cost
|
||
if nd < dist.get(v, math.inf):
|
||
dist[v] = nd
|
||
prev[v] = u
|
||
heapq.heappush(heap, (nd, v))
|
||
if target not in dist:
|
||
return None
|
||
path = [target]
|
||
while path[-1] != source:
|
||
path.append(prev[path[-1]])
|
||
path.reverse()
|
||
return dist[target], path
|
||
|
||
def path_cost(path: list[str]) -> float:
|
||
total = 0.0
|
||
for a, b in pairwise(path):
|
||
total += min((c for v, c in neighbors.get(a, ()) if v == b), default=math.inf)
|
||
return total
|
||
|
||
first = shortest(self.self_id, set(), set())
|
||
if first is None or len(first[1]) - 2 > _MAX_SCENARIO_HOPS:
|
||
self._routes_memo[memo_key] = []
|
||
return []
|
||
accepted: list[list[str]] = [first[1]]
|
||
# Spur candidates, a min-heap by total cost (counter tiebreaker so equal-cost
|
||
# candidates never fall through to comparing the path lists themselves).
|
||
candidates: list[tuple[float, int, list[str]]] = []
|
||
seen: set[tuple[str, ...]] = {tuple(first[1])}
|
||
counter = 0
|
||
while len(accepted) < k:
|
||
prev_path = accepted[-1]
|
||
for i in range(len(prev_path) - 1):
|
||
spur = prev_path[i]
|
||
root = prev_path[: i + 1]
|
||
# Ban the next edge every accepted path with this same root took, so the
|
||
# spur is forced onto a genuinely different route out of the spur node...
|
||
banned_edges: set[tuple[str, str]] = set()
|
||
for p in accepted:
|
||
if p[: i + 1] == root and len(p) > i + 1:
|
||
banned_edges.add((p[i], p[i + 1]))
|
||
banned_edges.add((p[i + 1], p[i]))
|
||
# ...and ban the root's interior nodes so the detour stays loopless.
|
||
banned_nodes = set(root[:-1])
|
||
spur_result = shortest(spur, banned_nodes, banned_edges)
|
||
if spur_result is None:
|
||
continue
|
||
full = root[:-1] + spur_result[1]
|
||
if len(full) - 2 > _MAX_SCENARIO_HOPS:
|
||
continue
|
||
key = tuple(full)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
counter += 1
|
||
heapq.heappush(candidates, (path_cost(full), counter, full))
|
||
if not candidates:
|
||
break
|
||
_, _, best_path = heapq.heappop(candidates)
|
||
accepted.append(best_path)
|
||
routes = [tuple(path[1:-1]) for path in accepted]
|
||
self._routes_memo[memo_key] = routes
|
||
return routes
|
||
|
||
|
||
def build_topology(
|
||
*,
|
||
self_id: str,
|
||
contacts: list[Contact],
|
||
trace_paths: list[TracedPath],
|
||
packet_paths: list[PacketPath],
|
||
neighbour_links: tuple[NeighbourLink, ...] | list[NeighbourLink] = (),
|
||
) -> MeshTopology:
|
||
"""Assemble the evidence graph from everything we have received — or asked for.
|
||
|
||
Four sources fold in, each one walked through :meth:`MeshTopology.add_walk`:
|
||
|
||
* **traces** — each successful trace's hop sequence, bracketed by us at both ends
|
||
(the boomerang leaves us and returns to us; the final hash-less hop *is* us).
|
||
Every hop's SNR is a reading on the link it arrived over.
|
||
* **contact routes** — the firmware's learned ``out_path`` per contact: a chain from
|
||
us through its repeaters to the contact. No SNR, but it is the distillation of
|
||
real received floods, so it counts as one reading per link (stamped with the
|
||
contact's last-heard time). A learned *direct* route is a direct link to us.
|
||
* **packets** — RX-logged frames: the relay chain, prefixed by the originator when
|
||
known and always ending at us (we heard the last relay). The measured SNR belongs
|
||
to that final link only.
|
||
* **neighbour reports** — links a remote repeater asserted about itself when we
|
||
fetched its neighbour table: one two-node walk per entry, SNR as measured at the
|
||
repeater. The only evidence here not derived from our own reception — it can
|
||
introduce nodes no other source has ever seen.
|
||
|
||
Args:
|
||
self_id: Our device's key/hash (any width; canonicalized to 12 hex).
|
||
contacts: The device's known contacts (canonicalization + route evidence).
|
||
trace_paths: Stored successful trace walks (see ``Repository.trace_paths``).
|
||
packet_paths: Stored RX-logged packet paths (see ``Repository.packet_paths``).
|
||
neighbour_links: Current repeater-reported links (see
|
||
``Repository.neighbour_links``); empty when none have been fetched.
|
||
|
||
Returns:
|
||
The populated :class:`MeshTopology`.
|
||
"""
|
||
topo = MeshTopology(self_id, contacts)
|
||
us = topo.self_id
|
||
|
||
for traced in trace_paths:
|
||
nodes: list[str | None] = [us]
|
||
snrs: list[float | None] = []
|
||
for hop, snr in traced.hops:
|
||
# The final hash-less hop is the reply landing back at us.
|
||
nodes.append(topo.canonical(hop) if hop is not None else us)
|
||
snrs.append(snr)
|
||
topo.add_walk(nodes, snrs=snrs, when=traced.when, source="trace")
|
||
|
||
for contact in contacts:
|
||
if contact.route_hops is None:
|
||
continue
|
||
contact_id = topo.canonical(contact.public_key or contact.key_prefix)
|
||
route = [topo.canonical(h) for h in contact.route_hops]
|
||
topo.add_walk([us, *route, contact_id], when=contact.last_seen, source="route")
|
||
|
||
for packet in packet_paths:
|
||
nodes = []
|
||
if packet.origin:
|
||
nodes.append(topo.canonical(packet.origin))
|
||
nodes.extend(topo.canonical(h) for h in packet.hops)
|
||
nodes.append(us)
|
||
if len(nodes) < 2:
|
||
continue
|
||
# Only the last link (last relay → us) carries the measured reception SNR.
|
||
snrs = [None] * (len(nodes) - 2) + [packet.snr]
|
||
topo.add_walk(nodes, snrs=snrs, when=packet.when, source="packet")
|
||
|
||
for reported in neighbour_links:
|
||
topo.add_walk(
|
||
[topo.canonical(reported.repeater), topo.canonical(reported.neighbour)],
|
||
snrs=[reported.snr],
|
||
when=reported.when,
|
||
source="neighbour",
|
||
)
|
||
|
||
# Every source has folded in; collapse any node that reached us at two hash widths
|
||
# (a bare trace hop beside its wide contact id) so each appears once everywhere.
|
||
topo.coalesce_prefixes()
|
||
return topo
|