Files
Jean-Pierre MartineauandClaude Opus 5.5 5fc224476c Docstrings and comments of meshterm/services are in ASD-STE100
The same rewrite as for the core package. Three comments now agree with
the code: the activity bucket of the header is one minute
(ACTIVITY_BUCKET_S), the RX log holds packets, and a reference to a
module note that does not exist is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

141 lines
5.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Collect the nodes of the mesh that have a location into map markers.
One list for three surfaces: the full-screen map of the ``map`` tool, the preview minimap
of the node page, and the map where the editors pick a location. All three plot the same
mesh, so they all get their markers from :func:`gather_markers`. The function is in
``services/`` for two reasons. It assembles data from the contacts of the device and the
observation history, which is the layer below all the code that draws. Also, two screens
and one tool use it, and none of them can import from the others.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from ..core.geo import usable_fix
from ..core.models import NODE_TYPE_REPEATER, MapMarker
if TYPE_CHECKING: # pragma: no cover - typing only
from ..context import AppContext
async def gather_markers(ctx: AppContext, *, wait: bool = True) -> list[MapMarker]:
"""Collect all the nodes with a location to plot: the contacts of the device, and our node.
The **contact list** of the companion is the authoritative source for the name of a
node, its type (repeater or leaf), and its advertised position. The observations of
the passive monitor have a location only for the rare node that broadcasts one in an
advert. Thus the contacts set the markers. When MeshTerm heard a contact directly, the
marker of that contact also gets signal detail from the observations.
Args:
ctx: The shared application context.
wait: Whether to wait for the device to send the contacts and the position of our
node, when the session has not cached them yet. ``False`` makes the markers
from the data that MeshTerm already has (the cache and our own history), and
never makes a round trip. Use it for a surface whose nodes are only context
(the location picker). Without it, a companion that refuses the contacts read
can keep that surface closed for twenty seconds or more.
"""
observed = {n.node: n for n in ctx.repo.heard_nodes() if n.node}
markers: list[MapMarker] = []
seen: set[str] = set()
contacts = await _contacts(ctx) if wait else (ctx.devstate.peek_contacts() or [])
for contact in contacts:
if not contact.has_location or not usable_fix(contact.lat, contact.lon):
continue
key = contact.key_prefix or (contact.public_key or "")[:12]
if key:
seen.add(key)
markers.append(
MapMarker(
label=contact.name or key or "?",
lat=float(contact.lat),
lon=float(contact.lon),
is_repeater=contact.is_repeater,
detail=_signal_detail(observed.get(key)),
key=contact.public_key or contact.key_prefix or None,
)
)
# A node that MeshTerm heard when it advertised a location, but that is not in our
# contacts.
for node in observed.values():
if not node.has_location or node.node in seen:
continue
if not usable_fix(node.lat, node.lon):
continue
markers.append(
MapMarker(
label=node.name or node.node or "?",
lat=float(node.lat),
lon=float(node.lon),
is_repeater=node.is_repeater,
detail=_signal_detail(node),
key=node.public_key or node.node or None,
)
)
if wait:
self_marker = await _self_marker(ctx)
else:
info = ctx.devstate.peek_self_info()
self_marker = _marker_for_self(info) if info is not None else None
if self_marker is not None:
markers.append(self_marker)
return markers
async def _contacts(ctx: AppContext) -> list:
"""Get the contacts of the device, best-effort (none if MeshTerm cannot reach the device)."""
try:
return await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the map still works from observations alone
return []
async def _self_marker(ctx: AppContext) -> MapMarker | None:
"""Make a marker for our node from the device, if its location is known."""
try:
info = await ctx.devstate.self_info()
except Exception: # noqa: BLE001 - the map is useful without our own position
return None
return _marker_for_self(info)
def _marker_for_self(info: dict) -> MapMarker | None:
"""The marker of our node from a self-info payload, or ``None`` when it has no fix."""
lat, lon = _as_float(info.get("adv_lat")), _as_float(info.get("adv_lon"))
if lat is None or lon is None or not usable_fix(lat, lon):
return None # a device with no fix reports 0/0 (or bad values out of range)
return MapMarker(
label=str(info.get("name") or "this node"),
lat=lat,
lon=lon,
is_repeater=info.get("adv_type") == NODE_TYPE_REPEATER,
is_self=True,
key=str(info.get("public_key") or "") or None,
)
def _signal_detail(node: object) -> str:
"""A short 'N pkts · +x.x dB' reception note for a heard node, or '' if it is not heard."""
if node is None:
return ""
detail = f"{node.count} pkts" # type: ignore[attr-defined]
if node.median_snr is not None: # type: ignore[attr-defined]
detail += f" · {node.median_snr:+.1f} dB" # type: ignore[attr-defined]
return detail
def _as_float(value: object) -> float | None:
"""Best-effort float conversion (``None`` for a missing or bad value)."""
if value is None:
return None
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None