Compare commits

...

7 Commits

Author SHA1 Message Date
l5y
e9b1c102f5 address review comments 2026-03-28 17:41:29 +01:00
l5y
29be258b57 data: resolve circular dependency of deamon.py 2026-03-28 17:11:38 +01:00
Ben Allfree
b1c416d029 first cut (#651) 2026-03-28 17:09:12 +01:00
dependabot[bot]
8305ca588c build(deps): bump rustls-webpki from 0.103.8 to 0.103.10 in /matrix (#649)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.8...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-21 12:55:17 +01:00
dependabot[bot]
0cf56b6fba build(deps): bump quinn-proto from 0.11.13 to 0.11.14 in /matrix (#646)
Bumps [quinn-proto](https://github.com/quinn-rs/quinn) from 0.11.13 to 0.11.14.
- [Release notes](https://github.com/quinn-rs/quinn/releases)
- [Commits](https://github.com/quinn-rs/quinn/compare/quinn-proto-0.11.13...quinn-proto-0.11.14)

---
updated-dependencies:
- dependency-name: quinn-proto
  dependency-version: 0.11.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-11 14:56:43 +01:00
l5y
ecce7f3504 chore: bump version to 0.5.11 (#645)
* chore: bump version to 0.5.11

* data: run black
2026-03-01 21:59:04 +01:00
l5y
17fa183c4f web: limit horizontal size of dropdown (#644)
* web: limit horizontal size of dropdown

* address review comments
2026-03-01 21:49:06 +01:00
29 changed files with 1883 additions and 447 deletions

View File

@@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.5.10</string>
<string>0.5.11</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.5.10</string>
<string>0.5.11</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>

View File

@@ -1,7 +1,7 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.5.10
version: 0.5.11
environment:
sdk: ">=3.4.0 <4.0.0"

View File

@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.5.10"
VERSION = "0.5.11"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION

View File

@@ -0,0 +1,107 @@
## Mesh ingestor contracts (stable interfaces)
This repos ingestion pipeline is split into:
- **Python collector** (`data/mesh_ingestor/*`) which normalizes packets/events and POSTs JSON to the web app.
- **Sinatra web app** (`web/`) which accepts those payloads on `POST /api/*` ingest routes and persists them into SQLite tables defined under `data/*.sql`.
This document records the **contracts that future providers must preserve**. The intent is to enable adding new providers (MeshCore, Reticulum, …) without changing the Ruby/DB/UI read-side.
### Canonical node identity
- **Canonical node id**: `nodes.node_id` is a `TEXT` primary key and is treated as canonical across the system.
- **Format**: `!%08x` (lowercase hex, 8 chars), for example `!abcdef01`.
- **Normalization**:
- Python currently normalizes via `data/mesh_ingestor/serialization.py:_canonical_node_id`.
- Ruby normalizes via `web/lib/potato_mesh/application/data_processing.rb:canonical_node_parts`.
- **Dual addressing**: Ruby routes and queries accept either a canonical `!xxxxxxxx` string or a numeric node id; they normalize to `node_id`.
Note: non-Meshtastic providers will need a strategy to map their native node identifiers into this `!%08x` space. That mapping is intentionally not standardized in code yet.
### Ingest HTTP routes and payload shapes
Future providers should emit payloads that match these shapes (keys + types), which are validated by existing tests (notably `tests/test_mesh.py`).
#### `POST /api/nodes`
Payload is a mapping keyed by canonical node id:
- `{ "!abcdef01": { ... node fields ... } }`
Node entry fields are “Meshtastic-ish” (camelCase) and may include:
- `num` (int node number)
- `lastHeard` (int unix seconds)
- `snr` (float)
- `hopsAway` (int)
- `isFavorite` (bool)
- `user` (mapping; e.g. `shortName`, `longName`, `macaddr`, `hwModel`, `role`, `publicKey`, `isUnmessagable`)
- `deviceMetrics` (mapping; e.g. `batteryLevel`, `voltage`, `channelUtilization`, `airUtilTx`, `uptimeSeconds`)
- `position` (mapping; `latitude`, `longitude`, `altitude`, `time`, `locationSource`, `precisionBits`, optional nested `raw`)
- Optional radio metadata: `lora_freq`, `modem_preset`
#### `POST /api/messages`
Single message payload:
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
- Identity: `from_id` (string/int), `to_id` (string/int), `channel` (int), `portnum` (string|nil)
- Payload: `text` (string|nil), `encrypted` (string|nil), `reply_id` (int|nil), `emoji` (string|nil)
- RF: `snr` (float|nil), `rssi` (int|nil), `hop_limit` (int|nil)
- Meta: `channel_name` (string; only when not encrypted and known), `ingestor` (canonical host id), `lora_freq`, `modem_preset`
#### `POST /api/positions`
Single position payload:
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
- Node: `node_id` (canonical string), `node_num` (int|nil), `num` (int|nil), `from_id` (canonical string), `to_id` (string|nil)
- Position: `latitude`, `longitude`, `altitude` (floats|nil)
- Position time: `position_time` (int|nil)
- Quality: `location_source` (string|nil), `precision_bits` (int|nil), `sats_in_view` (int|nil), `pdop` (float|nil)
- Motion: `ground_speed` (float|nil), `ground_track` (float|nil)
- RF/meta: `snr`, `rssi`, `hop_limit`, `bitfield`, `payload_b64` (string|nil), `raw` (mapping|nil), `ingestor`, `lora_freq`, `modem_preset`
#### `POST /api/telemetry`
Single telemetry payload:
- Required: `id` (int), `rx_time` (int), `rx_iso` (string)
- Node: `node_id` (canonical string|nil), `node_num` (int|nil), `from_id`, `to_id`
- Time: `telemetry_time` (int|nil)
- Packet: `channel` (int), `portnum` (string|nil), `bitfield` (int|nil), `hop_limit` (int|nil)
- RF: `snr` (float|nil), `rssi` (int|nil)
- Raw: `payload_b64` (string; may be empty string when unknown)
- Metrics: many optional snake_case keys (`battery_level`, `voltage`, `temperature`, etc.)
- Meta: `ingestor`, `lora_freq`, `modem_preset`
#### `POST /api/neighbors`
Neighbors snapshot payload:
- Node: `node_id` (canonical string), `node_num` (int|nil)
- `neighbors`: list of entries with `neighbor_id` (canonical string), `neighbor_num` (int|nil), `snr` (float|nil), `rx_time` (int), `rx_iso` (string)
- Snapshot time: `rx_time`, `rx_iso`
- Optional: `node_broadcast_interval_secs` (int|nil), `last_sent_by_id` (canonical string|nil)
- Meta: `ingestor`, `lora_freq`, `modem_preset`
#### `POST /api/traces`
Single trace payload:
- Identity: `id` (int|nil), `request_id` (int|nil)
- Endpoints: `src` (int|nil), `dest` (int|nil)
- Path: `hops` (list[int])
- Time: `rx_time` (int), `rx_iso` (string)
- Metrics: `rssi` (int|nil), `snr` (float|nil), `elapsed_ms` (int|nil)
- Meta: `ingestor`, `lora_freq`, `modem_preset`
#### `POST /api/ingestors`
Heartbeat payload:
- `node_id` (canonical string)
- `start_time` (int), `last_seen_time` (int)
- `version` (string)
- Optional: `lora_freq`, `modem_preset`

View File

@@ -16,6 +16,7 @@
from __future__ import annotations
import dataclasses
import inspect
import signal
import threading
@@ -24,6 +25,7 @@ import time
from pubsub import pub
from . import config, handlers, ingestors, interfaces
from .provider import Provider
_RECEIVE_TOPICS = (
"meshtastic.receive",
@@ -197,11 +199,6 @@ def _process_ingestor_heartbeat(iface, *, ingestor_announcement_sent: bool) -> b
if heartbeat_sent and not ingestor_announcement_sent:
return True
return ingestor_announcement_sent
iface_cls = getattr(iface_obj, "__class__", None)
if iface_cls is None:
return False
module_name = getattr(iface_cls, "__module__", "") or ""
return "ble_interface" in module_name
def _connected_state(candidate) -> bool | None:
@@ -243,10 +240,286 @@ def _connected_state(candidate) -> bool | None:
return None
def main(existing_interface=None) -> None:
# ---------------------------------------------------------------------------
# Loop state container
# ---------------------------------------------------------------------------
@dataclasses.dataclass
class _DaemonState:
"""All mutable state for the :func:`main` daemon loop."""
provider: Provider
stop: threading.Event
configured_port: str | None
inactivity_reconnect_secs: float
energy_saving_enabled: bool
energy_online_secs: float
energy_sleep_secs: float
retry_delay: float
last_seen_packet_monotonic: float | None
active_candidate: str | None
iface: object = None
resolved_target: str | None = None
initial_snapshot_sent: bool = False
energy_session_deadline: float | None = None
iface_connected_at: float | None = None
last_inactivity_reconnect: float | None = None
ingestor_announcement_sent: bool = False
announced_target: bool = False
# ---------------------------------------------------------------------------
# Per-iteration helpers (each returns True when the caller should `continue`)
# ---------------------------------------------------------------------------
def _advance_retry_delay(current: float) -> float:
"""Return the next exponential-backoff retry delay."""
if config._RECONNECT_MAX_DELAY_SECS <= 0:
return current
next_delay = current * 2 if current else config._RECONNECT_INITIAL_DELAY_SECS
return min(next_delay, config._RECONNECT_MAX_DELAY_SECS)
def _energy_sleep(state: _DaemonState, reason: str) -> None:
"""Sleep for the configured energy-saving interval."""
if not state.energy_saving_enabled or state.energy_sleep_secs <= 0:
return
if config.DEBUG:
config._debug_log(
f"energy saving: {reason}; sleeping for {state.energy_sleep_secs:g}s"
)
state.stop.wait(state.energy_sleep_secs)
def _try_connect(state: _DaemonState) -> bool:
"""Attempt to establish the mesh interface.
Returns:
``True`` when connected and the loop should proceed; ``False`` when
the connection failed and the caller should ``continue``.
"""
try:
state.iface, state.resolved_target, state.active_candidate = (
state.provider.connect(active_candidate=state.active_candidate)
)
handlers.register_host_node_id(state.provider.extract_host_node_id(state.iface))
ingestors.set_ingestor_node_id(handlers.host_node_id())
state.retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
state.initial_snapshot_sent = False
if not state.announced_target and state.resolved_target:
config._debug_log(
"Using mesh interface",
context="daemon.interface",
severity="info",
target=state.resolved_target,
)
state.announced_target = True
if state.energy_saving_enabled and state.energy_online_secs > 0:
state.energy_session_deadline = time.monotonic() + state.energy_online_secs
else:
state.energy_session_deadline = None
state.iface_connected_at = time.monotonic()
# Seed the inactivity tracking from the connection time so a
# reconnect is given a full inactivity window even when the
# handler still reports the previous packet timestamp.
state.last_seen_packet_monotonic = state.iface_connected_at
state.last_inactivity_reconnect = None
return True
except interfaces.NoAvailableMeshInterface as exc:
config._debug_log(
"No mesh interface available",
context="daemon.interface",
severity="error",
error_message=str(exc),
)
_close_interface(state.iface)
raise SystemExit(1) from exc
except Exception as exc:
config._debug_log(
"Failed to create mesh interface",
context="daemon.interface",
severity="warn",
candidate=state.active_candidate or "auto",
error_class=exc.__class__.__name__,
error_message=str(exc),
)
if state.configured_port is None:
state.active_candidate = None
state.announced_target = False
state.stop.wait(state.retry_delay)
state.retry_delay = _advance_retry_delay(state.retry_delay)
return False
def _check_energy_saving(state: _DaemonState) -> bool:
"""Disconnect and sleep when energy-saving conditions are met.
Returns:
``True`` when the interface was closed and the caller should
``continue``; ``False`` otherwise.
"""
if not state.energy_saving_enabled or state.iface is None:
return False
session_expired = (
state.energy_session_deadline is not None
and time.monotonic() >= state.energy_session_deadline
)
ble_dropped = (
_is_ble_interface(state.iface)
and getattr(state.iface, "client", object()) is None
)
if not session_expired and not ble_dropped:
return False
reason = "disconnected after session" if session_expired else "BLE client disconnected"
log_msg = "Energy saving disconnect" if session_expired else "Energy saving BLE disconnect"
config._debug_log(log_msg, context="daemon.energy", severity="info")
_close_interface(state.iface)
state.iface = None
state.announced_target = False
state.initial_snapshot_sent = False
state.energy_session_deadline = None
_energy_sleep(state, reason)
return True
def _try_send_snapshot(state: _DaemonState) -> bool:
"""Send the initial node snapshot via the provider.
Returns:
``True`` when the snapshot succeeded (or no nodes exist yet); ``False``
when a hard error occurred and the caller should ``continue``.
"""
try:
node_items = state.provider.node_snapshot_items(state.iface)
processed_any = False
for node_id, node in node_items:
processed_any = True
try:
handlers.upsert_node(node_id, node)
except Exception as exc:
config._debug_log(
"Failed to update node snapshot",
context="daemon.snapshot",
severity="warn",
node_id=node_id,
error_class=exc.__class__.__name__,
error_message=str(exc),
)
if config.DEBUG:
config._debug_log(
"Snapshot node payload",
context="daemon.snapshot",
node=node,
)
if processed_any:
state.initial_snapshot_sent = True
return True
except Exception as exc:
config._debug_log(
"Snapshot refresh failed",
context="daemon.snapshot",
severity="warn",
error_class=exc.__class__.__name__,
error_message=str(exc),
)
_close_interface(state.iface)
state.iface = None
state.stop.wait(state.retry_delay)
state.retry_delay = _advance_retry_delay(state.retry_delay)
return False
def _check_inactivity_reconnect(state: _DaemonState) -> bool:
"""Reconnect when the interface has been silent for too long.
Returns:
``True`` when a reconnect was triggered and the caller should
``continue``; ``False`` otherwise.
"""
if state.iface is None or state.inactivity_reconnect_secs <= 0:
return False
now = time.monotonic()
iface_activity = handlers.last_packet_monotonic()
if (
iface_activity is not None
and state.iface_connected_at is not None
and iface_activity < state.iface_connected_at
):
iface_activity = state.iface_connected_at
if iface_activity is not None and (
state.last_seen_packet_monotonic is None
or iface_activity > state.last_seen_packet_monotonic
):
state.last_seen_packet_monotonic = iface_activity
state.last_inactivity_reconnect = None
latest_activity = iface_activity
if latest_activity is None and state.iface_connected_at is not None:
latest_activity = state.iface_connected_at
if latest_activity is None:
latest_activity = now
inactivity_elapsed = now - latest_activity
believed_disconnected = _connected_state(getattr(state.iface, "isConnected", None)) is False
if not believed_disconnected and inactivity_elapsed < state.inactivity_reconnect_secs:
return False
if (
state.last_inactivity_reconnect is not None
and now - state.last_inactivity_reconnect < state.inactivity_reconnect_secs
):
return False
reason = (
"disconnected"
if believed_disconnected
else f"no data for {inactivity_elapsed:.0f}s"
)
config._debug_log(
"Mesh interface inactivity detected",
context="daemon.interface",
severity="warn",
reason=reason,
)
state.last_inactivity_reconnect = now
_close_interface(state.iface)
state.iface = None
state.announced_target = False
state.initial_snapshot_sent = False
state.energy_session_deadline = None
state.iface_connected_at = None
return True
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(*, provider: Provider | None = None) -> None:
"""Run the mesh ingestion daemon until interrupted."""
subscribed = _subscribe_receive_topics()
if provider is None:
from .providers.meshtastic import MeshtasticProvider
provider = MeshtasticProvider()
subscribed = provider.subscribe()
if subscribed:
config._debug_log(
"Subscribed to receive topics",
@@ -255,313 +528,83 @@ def main(existing_interface=None) -> None:
topics=subscribed,
)
iface = existing_interface
resolved_target = None
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
stop = threading.Event()
initial_snapshot_sent = False
energy_session_deadline = None
iface_connected_at: float | None = None
last_seen_packet_monotonic = handlers.last_packet_monotonic()
last_inactivity_reconnect: float | None = None
inactivity_reconnect_secs = max(
0.0, getattr(config, "_INACTIVITY_RECONNECT_SECS", 0.0)
state = _DaemonState(
provider=provider,
stop=threading.Event(),
configured_port=config.CONNECTION,
inactivity_reconnect_secs=max(
0.0, getattr(config, "_INACTIVITY_RECONNECT_SECS", 0.0)
),
energy_saving_enabled=config.ENERGY_SAVING,
energy_online_secs=max(0.0, config._ENERGY_ONLINE_DURATION_SECS),
energy_sleep_secs=max(0.0, config._ENERGY_SLEEP_SECS),
retry_delay=max(0.0, config._RECONNECT_INITIAL_DELAY_SECS),
last_seen_packet_monotonic=handlers.last_packet_monotonic(),
active_candidate=config.CONNECTION,
)
ingestor_announcement_sent = False
energy_saving_enabled = config.ENERGY_SAVING
energy_online_secs = max(0.0, config._ENERGY_ONLINE_DURATION_SECS)
energy_sleep_secs = max(0.0, config._ENERGY_SLEEP_SECS)
def _energy_sleep(reason: str) -> None:
if not energy_saving_enabled or energy_sleep_secs <= 0:
return
if config.DEBUG:
config._debug_log(
f"energy saving: {reason}; sleeping for {energy_sleep_secs:g}s"
)
stop.wait(energy_sleep_secs)
def handle_sigterm(*_args) -> None:
stop.set()
state.stop.set()
def handle_sigint(signum, frame) -> None:
if stop.is_set():
if state.stop.is_set():
signal.default_int_handler(signum, frame)
return
stop.set()
state.stop.set()
if threading.current_thread() == threading.main_thread():
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
target = config.INSTANCE or "(no INSTANCE_DOMAIN configured)"
configured_port = config.CONNECTION
active_candidate = configured_port
announced_target = False
config._debug_log(
"Mesh daemon starting",
context="daemon.main",
severity="info",
target=target,
port=configured_port or "auto",
target=config.INSTANCE or "(no INSTANCE_DOMAIN configured)",
port=config.CONNECTION or "auto",
channel=config.CHANNEL_INDEX,
)
try:
while not stop.is_set():
if iface is None:
try:
if active_candidate:
iface, resolved_target = interfaces._create_serial_interface(
active_candidate
)
else:
iface, resolved_target = interfaces._create_default_interface()
active_candidate = resolved_target
interfaces._ensure_radio_metadata(iface)
interfaces._ensure_channel_metadata(iface)
handlers.register_host_node_id(
interfaces._extract_host_node_id(iface)
)
ingestors.set_ingestor_node_id(handlers.host_node_id())
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
initial_snapshot_sent = False
if not announced_target and resolved_target:
config._debug_log(
"Using mesh interface",
context="daemon.interface",
severity="info",
target=resolved_target,
)
announced_target = True
if energy_saving_enabled and energy_online_secs > 0:
energy_session_deadline = time.monotonic() + energy_online_secs
else:
energy_session_deadline = None
iface_connected_at = time.monotonic()
# Seed the inactivity tracking from the connection time so a
# reconnect is given a full inactivity window even when the
# handler still reports the previous packet timestamp.
last_seen_packet_monotonic = iface_connected_at
last_inactivity_reconnect = None
except interfaces.NoAvailableMeshInterface as exc:
config._debug_log(
"No mesh interface available",
context="daemon.interface",
severity="error",
error_message=str(exc),
)
_close_interface(iface)
raise SystemExit(1) from exc
except Exception as exc:
candidate_desc = active_candidate or "auto"
config._debug_log(
"Failed to create mesh interface",
context="daemon.interface",
severity="warn",
candidate=candidate_desc,
error_class=exc.__class__.__name__,
error_message=str(exc),
)
if configured_port is None:
active_candidate = None
announced_target = False
stop.wait(retry_delay)
if config._RECONNECT_MAX_DELAY_SECS > 0:
retry_delay = min(
(
retry_delay * 2
if retry_delay
else config._RECONNECT_INITIAL_DELAY_SECS
),
config._RECONNECT_MAX_DELAY_SECS,
)
continue
if energy_saving_enabled and iface is not None:
if (
energy_session_deadline is not None
and time.monotonic() >= energy_session_deadline
):
config._debug_log(
"Energy saving disconnect",
context="daemon.energy",
severity="info",
)
_close_interface(iface)
iface = None
announced_target = False
initial_snapshot_sent = False
energy_session_deadline = None
_energy_sleep("disconnected after session")
continue
if (
_is_ble_interface(iface)
and getattr(iface, "client", object()) is None
):
config._debug_log(
"Energy saving BLE disconnect",
context="daemon.energy",
severity="info",
)
_close_interface(iface)
iface = None
announced_target = False
initial_snapshot_sent = False
energy_session_deadline = None
_energy_sleep("BLE client disconnected")
continue
if not initial_snapshot_sent:
try:
nodes = getattr(iface, "nodes", {}) or {}
node_items = _node_items_snapshot(nodes)
if node_items is None:
config._debug_log(
"Skipping node snapshot due to concurrent modification",
context="daemon.snapshot",
)
else:
processed_snapshot_item = False
for node_id, node in node_items:
processed_snapshot_item = True
try:
handlers.upsert_node(node_id, node)
except Exception as exc:
config._debug_log(
"Failed to update node snapshot",
context="daemon.snapshot",
severity="warn",
node_id=node_id,
error_class=exc.__class__.__name__,
error_message=str(exc),
)
if config.DEBUG:
config._debug_log(
"Snapshot node payload",
context="daemon.snapshot",
node=node,
)
if processed_snapshot_item:
initial_snapshot_sent = True
except Exception as exc:
config._debug_log(
"Snapshot refresh failed",
context="daemon.snapshot",
severity="warn",
error_class=exc.__class__.__name__,
error_message=str(exc),
)
_close_interface(iface)
iface = None
stop.wait(retry_delay)
if config._RECONNECT_MAX_DELAY_SECS > 0:
retry_delay = min(
(
retry_delay * 2
if retry_delay
else config._RECONNECT_INITIAL_DELAY_SECS
),
config._RECONNECT_MAX_DELAY_SECS,
)
continue
if iface is not None and inactivity_reconnect_secs > 0:
now_monotonic = time.monotonic()
iface_activity = handlers.last_packet_monotonic()
if (
iface_activity is not None
and iface_connected_at is not None
and iface_activity < iface_connected_at
):
iface_activity = iface_connected_at
if iface_activity is not None and (
last_seen_packet_monotonic is None
or iface_activity > last_seen_packet_monotonic
):
last_seen_packet_monotonic = iface_activity
last_inactivity_reconnect = None
latest_activity = iface_activity
if latest_activity is None and iface_connected_at is not None:
latest_activity = iface_connected_at
if latest_activity is None:
latest_activity = now_monotonic
inactivity_elapsed = now_monotonic - latest_activity
connected_attr = getattr(iface, "isConnected", None)
believed_disconnected = False
connected_state = _connected_state(connected_attr)
if connected_state is None:
if callable(connected_attr):
try:
believed_disconnected = not bool(connected_attr())
except Exception:
believed_disconnected = False
elif connected_attr is not None:
try:
believed_disconnected = not bool(connected_attr)
except Exception: # pragma: no cover - defensive guard
believed_disconnected = False
else:
believed_disconnected = not connected_state
should_reconnect = believed_disconnected or (
inactivity_elapsed >= inactivity_reconnect_secs
)
if should_reconnect:
if (
last_inactivity_reconnect is None
or now_monotonic - last_inactivity_reconnect
>= inactivity_reconnect_secs
):
reason = (
"disconnected"
if believed_disconnected
else f"no data for {inactivity_elapsed:.0f}s"
)
config._debug_log(
"Mesh interface inactivity detected",
context="daemon.interface",
severity="warn",
reason=reason,
)
last_inactivity_reconnect = now_monotonic
_close_interface(iface)
iface = None
announced_target = False
initial_snapshot_sent = False
energy_session_deadline = None
iface_connected_at = None
continue
ingestor_announcement_sent = _process_ingestor_heartbeat(
iface, ingestor_announcement_sent=ingestor_announcement_sent
while not state.stop.is_set():
if state.iface is None and not _try_connect(state):
continue
if _check_energy_saving(state):
continue
if not state.initial_snapshot_sent and not _try_send_snapshot(state):
continue
if _check_inactivity_reconnect(state):
continue
state.ingestor_announcement_sent = _process_ingestor_heartbeat(
state.iface, ingestor_announcement_sent=state.ingestor_announcement_sent
)
retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
stop.wait(config.SNAPSHOT_SECS)
state.retry_delay = max(0.0, config._RECONNECT_INITIAL_DELAY_SECS)
state.stop.wait(config.SNAPSHOT_SECS)
except KeyboardInterrupt: # pragma: no cover - interactive only
config._debug_log(
"Received KeyboardInterrupt; shutting down",
context="daemon.main",
severity="info",
)
stop.set()
state.stop.set()
finally:
_close_interface(iface)
_close_interface(state.iface)
__all__ = [
"_RECEIVE_TOPICS",
"_event_wait_allows_default_timeout",
"_node_items_snapshot",
"_subscribe_receive_topics",
"_is_ble_interface",
"_process_ingestor_heartbeat",
"_DaemonState",
"_advance_retry_delay",
"_check_energy_saving",
"_check_inactivity_reconnect",
"_connected_state",
"_energy_sleep",
"_event_wait_allows_default_timeout",
"_is_ble_interface",
"_node_items_snapshot",
"_process_ingestor_heartbeat",
"_subscribe_receive_topics",
"_try_connect",
"_try_send_snapshot",
"main",
]

View File

@@ -0,0 +1,182 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Protocol-agnostic event payload types for ingestion.
The ingestor ultimately POSTs JSON to the web app's ingest routes. These types
capture the *shape* of those payloads so multiple providers can emit the same
events, regardless of how they source or decode packets.
These are intentionally defined as ``TypedDict`` so existing code can continue
to build plain dictionaries without a runtime dependency on dataclasses.
"""
from __future__ import annotations
from typing import NotRequired, TypedDict
class _MessageEventRequired(TypedDict):
id: int
rx_time: int
rx_iso: str
class MessageEvent(_MessageEventRequired, total=False):
from_id: object
to_id: object
channel: int
portnum: str | None
text: str | None
encrypted: str | None
snr: float | None
rssi: int | None
hop_limit: int | None
reply_id: int | None
emoji: str | None
channel_name: str
ingestor: str | None
lora_freq: int
modem_preset: str
class _PositionEventRequired(TypedDict):
id: int
rx_time: int
rx_iso: str
class PositionEvent(_PositionEventRequired, total=False):
node_id: str
node_num: int | None
num: int | None
from_id: str | None
to_id: object
latitude: float | None
longitude: float | None
altitude: float | None
position_time: int | None
location_source: str | None
precision_bits: int | None
sats_in_view: int | None
pdop: float | None
ground_speed: float | None
ground_track: float | None
snr: float | None
rssi: int | None
hop_limit: int | None
bitfield: int | None
payload_b64: str | None
raw: dict
ingestor: str | None
lora_freq: int
modem_preset: str
class _TelemetryEventRequired(TypedDict):
id: int
rx_time: int
rx_iso: str
class TelemetryEvent(_TelemetryEventRequired, total=False):
node_id: str | None
node_num: int | None
from_id: object
to_id: object
telemetry_time: int | None
channel: int
portnum: str | None
hop_limit: int | None
snr: float | None
rssi: int | None
bitfield: int | None
payload_b64: str
ingestor: str | None
lora_freq: int
modem_preset: str
# Metric keys are intentionally open-ended; the Ruby side is permissive and
# evolves over time.
class _NeighborEntryRequired(TypedDict):
rx_time: int
rx_iso: str
class NeighborEntry(_NeighborEntryRequired, total=False):
neighbor_id: str
neighbor_num: int | None
snr: float | None
class _NeighborsSnapshotRequired(TypedDict):
node_id: str
rx_time: int
rx_iso: str
class NeighborsSnapshot(_NeighborsSnapshotRequired, total=False):
node_num: int | None
neighbors: list[NeighborEntry]
node_broadcast_interval_secs: int | None
last_sent_by_id: str | None
ingestor: str | None
lora_freq: int
modem_preset: str
class _TraceEventRequired(TypedDict):
hops: list[int]
rx_time: int
rx_iso: str
class TraceEvent(_TraceEventRequired, total=False):
id: int | None
request_id: int | None
src: int | None
dest: int | None
rssi: int | None
snr: float | None
elapsed_ms: int | None
ingestor: str | None
lora_freq: int
modem_preset: str
class IngestorHeartbeat(TypedDict):
node_id: str
start_time: int
last_seen_time: int
version: str
lora_freq: NotRequired[int]
modem_preset: NotRequired[str]
NodeUpsert = dict[str, dict]
__all__ = [
"IngestorHeartbeat",
"MessageEvent",
"NeighborEntry",
"NeighborsSnapshot",
"NodeUpsert",
"PositionEvent",
"TelemetryEvent",
"TraceEvent",
]

View File

@@ -0,0 +1,117 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Node identity helpers shared across ingestor providers.
The web application keys nodes by a canonical textual identifier of the form
``!%08x`` (lowercase hex). Both the Python collector and Ruby server accept
several input forms (ints, ``0x`` hex strings, ``!`` hex strings, decimal
strings). This module centralizes that normalization.
"""
from __future__ import annotations
from typing import Final
CANONICAL_PREFIX: Final[str] = "!"
def canonical_node_id(value: object) -> str | None:
"""Convert ``value`` into canonical ``!xxxxxxxx`` form.
Parameters:
value: Node reference which may be an int, float, or string.
Returns:
Canonical node id string or ``None`` when parsing fails.
"""
if value is None:
return None
if isinstance(value, (int, float)):
try:
num = int(value)
except (TypeError, ValueError):
return None
if num < 0:
return None
return f"{CANONICAL_PREFIX}{num & 0xFFFFFFFF:08x}"
if not isinstance(value, str):
return None
trimmed = value.strip()
if not trimmed:
return None
if trimmed.startswith("^"):
# Meshtastic special destinations like "^all" are not node ids; callers
# that already accept them should keep passing them through unchanged.
return trimmed
if trimmed.startswith(CANONICAL_PREFIX):
body = trimmed[1:]
elif trimmed.lower().startswith("0x"):
body = trimmed[2:]
elif trimmed.isdigit():
try:
return f"{CANONICAL_PREFIX}{int(trimmed, 10) & 0xFFFFFFFF:08x}"
except ValueError:
return None
else:
body = trimmed
if not body:
return None
try:
return f"{CANONICAL_PREFIX}{int(body, 16) & 0xFFFFFFFF:08x}"
except ValueError:
return None
def node_num_from_id(node_id: object) -> int | None:
"""Extract the numeric node identifier from a canonical (or near-canonical) id."""
if node_id is None:
return None
if isinstance(node_id, (int, float)):
try:
num = int(node_id)
except (TypeError, ValueError):
return None
return num if num >= 0 else None
if not isinstance(node_id, str):
return None
trimmed = node_id.strip()
if not trimmed:
return None
if trimmed.startswith(CANONICAL_PREFIX):
trimmed = trimmed[1:]
if trimmed.lower().startswith("0x"):
trimmed = trimmed[2:]
try:
return int(trimmed, 16)
except ValueError:
try:
return int(trimmed, 10)
except ValueError:
return None
__all__ = [
"CANONICAL_PREFIX",
"canonical_node_id",
"node_num_from_id",
]

View File

@@ -0,0 +1,65 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Provider interface for ingestion sources.
Today the repo ships a Meshtastic provider only. This module defines the seam so
future providers (MeshCore, Reticulum, ...) can be added without changing the
web app ingest contract.
"""
from __future__ import annotations
import enum
from collections.abc import Iterable
from typing import Protocol
class ProviderCapability(enum.Flag):
"""Feature flags describing what a provider can supply."""
NONE = 0
NODE_SNAPSHOT = enum.auto()
HEARTBEATS = enum.auto()
class Provider(Protocol):
"""Abstract source of mesh observations."""
name: str
def subscribe(self) -> list[str]:
"""Subscribe to any async receive callbacks and return topic names."""
def connect(
self, *, active_candidate: str | None
) -> tuple[object, str | None, str | None]:
"""Create an interface connection.
Returns:
(iface, resolved_target, next_active_candidate)
"""
def extract_host_node_id(self, iface: object) -> str | None:
"""Best-effort extraction of the connected host node id."""
def node_snapshot_items(self, iface: object) -> Iterable[tuple[str, object]]:
"""Return iterable of (node_id, node_obj) for initial snapshot."""
__all__ = [
"Provider",
"ProviderCapability",
]

View File

@@ -0,0 +1,26 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Provider implementations.
This package contains protocol-specific provider implementations (Meshtastic
today, others in the future).
"""
from __future__ import annotations
from .meshtastic import MeshtasticProvider
__all__ = ["MeshtasticProvider"]

View File

@@ -0,0 +1,83 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Meshtastic provider implementation."""
from __future__ import annotations
import time
from collections.abc import Iterable
from .. import config, daemon as _daemon, interfaces
class MeshtasticProvider:
"""Meshtastic ingestion provider (current default)."""
name = "meshtastic"
def __init__(self):
self._subscribed: list[str] = []
def subscribe(self) -> list[str]:
"""Subscribe Meshtastic pubsub receive topics."""
if self._subscribed:
return list(self._subscribed)
topics = _daemon._subscribe_receive_topics()
self._subscribed = topics
return list(topics)
def connect(
self, *, active_candidate: str | None
) -> tuple[object, str | None, str | None]:
"""Create a Meshtastic interface using the existing interface helpers."""
iface = None
resolved_target = None
next_candidate = active_candidate
if active_candidate:
iface, resolved_target = interfaces._create_serial_interface(active_candidate)
else:
iface, resolved_target = interfaces._create_default_interface()
next_candidate = resolved_target
interfaces._ensure_radio_metadata(iface)
interfaces._ensure_channel_metadata(iface)
return iface, resolved_target, next_candidate
def extract_host_node_id(self, iface: object) -> str | None:
return interfaces._extract_host_node_id(iface)
def node_snapshot_items(self, iface: object) -> list[tuple[str, object]]:
nodes = getattr(iface, "nodes", {}) or {}
for _ in range(3):
try:
return list(nodes.items())
except RuntimeError as err:
if "dictionary changed size during iteration" not in str(err):
raise
time.sleep(0)
config._debug_log(
"Skipping node snapshot due to concurrent modification",
context="meshtastic.snapshot",
)
return []
__all__ = ["MeshtasticProvider"]

View File

@@ -33,6 +33,9 @@ from google.protobuf.json_format import MessageToDict
from google.protobuf.message import DecodeError
from google.protobuf.message import Message as ProtoMessage
from .node_identity import canonical_node_id as _canonical_node_id
from .node_identity import node_num_from_id as _node_num_from_id
_CLI_ROLE_MODULE_NAMES: tuple[str, ...] = (
"meshtastic.cli.common",
"meshtastic.cli.roles",
@@ -429,91 +432,6 @@ def _pkt_to_dict(packet) -> dict:
return {"_unparsed": str(packet)}
def _canonical_node_id(value) -> str | None:
"""Convert node identifiers into the canonical ``!xxxxxxxx`` format.
Parameters:
value: Input identifier which may be an int, float or string.
Returns:
The canonical identifier or ``None`` if conversion fails.
"""
if value is None:
return None
if isinstance(value, (int, float)):
try:
num = int(value)
except (TypeError, ValueError):
return None
if num < 0:
return None
return f"!{num & 0xFFFFFFFF:08x}"
if not isinstance(value, str):
return None
trimmed = value.strip()
if not trimmed:
return None
if trimmed.startswith("^"):
return trimmed
if trimmed.startswith("!"):
body = trimmed[1:]
elif trimmed.lower().startswith("0x"):
body = trimmed[2:]
elif trimmed.isdigit():
try:
return f"!{int(trimmed, 10) & 0xFFFFFFFF:08x}"
except ValueError:
return None
else:
body = trimmed
if not body:
return None
try:
return f"!{int(body, 16) & 0xFFFFFFFF:08x}"
except ValueError:
return None
def _node_num_from_id(node_id) -> int | None:
"""Extract the numeric node ID from a canonical identifier.
Parameters:
node_id: Identifier value accepted by :func:`_canonical_node_id`.
Returns:
The numeric node ID or ``None`` when parsing fails.
"""
if node_id is None:
return None
if isinstance(node_id, (int, float)):
try:
num = int(node_id)
except (TypeError, ValueError):
return None
return num if num >= 0 else None
if not isinstance(node_id, str):
return None
trimmed = node_id.strip()
if not trimmed:
return None
if trimmed.startswith("!"):
trimmed = trimmed[1:]
if trimmed.lower().startswith("0x"):
trimmed = trimmed[2:]
try:
return int(trimmed, 16)
except ValueError:
try:
return int(trimmed, 10)
except ValueError:
return None
def _merge_mappings(base, extra):
"""Merge two mapping-like objects recursively.

10
matrix/Cargo.lock generated
View File

@@ -969,7 +969,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "potatomesh-matrix-bridge"
version = "0.5.10"
version = "0.5.11"
dependencies = [
"anyhow",
"axum",
@@ -1037,9 +1037,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
@@ -1255,9 +1255,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.8"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",

View File

@@ -14,7 +14,7 @@
[package]
name = "potatomesh-matrix-bridge"
version = "0.5.10"
version = "0.5.11"
edition = "2021"
[dependencies]

View File

@@ -19,8 +19,10 @@ import io
import json
import sys
from meshtastic.protobuf import mesh_pb2
from meshtastic.protobuf import telemetry_pb2
import pytest
mesh_pb2 = pytest.importorskip("meshtastic.protobuf.mesh_pb2")
telemetry_pb2 = pytest.importorskip("meshtastic.protobuf.telemetry_pb2")
from data.mesh_ingestor import decode_payload

167
tests/test_events_unit.py Normal file
View File

@@ -0,0 +1,167 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for :mod:`data.mesh_ingestor.events`."""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor.events import ( # noqa: E402 - path setup
IngestorHeartbeat,
MessageEvent,
NeighborEntry,
NeighborsSnapshot,
PositionEvent,
TelemetryEvent,
TraceEvent,
)
def test_message_event_requires_id_rx_time_rx_iso():
event: MessageEvent = {"id": 1, "rx_time": 1700000000, "rx_iso": "2023-11-14T00:00:00Z"}
assert event["id"] == 1
assert event["rx_time"] == 1700000000
assert event["rx_iso"] == "2023-11-14T00:00:00Z"
def test_message_event_accepts_optional_fields():
event: MessageEvent = {
"id": 2,
"rx_time": 1700000001,
"rx_iso": "2023-11-14T00:00:01Z",
"text": "hello",
"from_id": "!aabbccdd",
"snr": 4.5,
"rssi": -90,
}
assert event["text"] == "hello"
assert event["snr"] == 4.5
def test_position_event_required_fields():
event: PositionEvent = {"id": 10, "rx_time": 1700000002, "rx_iso": "2023-11-14T00:00:02Z"}
assert event["id"] == 10
def test_position_event_optional_fields():
event: PositionEvent = {
"id": 11,
"rx_time": 1700000003,
"rx_iso": "2023-11-14T00:00:03Z",
"latitude": 37.7749,
"longitude": -122.4194,
"altitude": 10.0,
"node_id": "!aabbccdd",
}
assert event["latitude"] == 37.7749
def test_telemetry_event_required_fields():
event: TelemetryEvent = {"id": 20, "rx_time": 1700000004, "rx_iso": "2023-11-14T00:00:04Z"}
assert event["id"] == 20
def test_telemetry_event_optional_fields():
event: TelemetryEvent = {
"id": 21,
"rx_time": 1700000005,
"rx_iso": "2023-11-14T00:00:05Z",
"channel": 0,
"payload_b64": "AAEC",
"snr": 3.0,
}
assert event["payload_b64"] == "AAEC"
def test_neighbor_entry_required_fields():
entry: NeighborEntry = {"rx_time": 1700000006, "rx_iso": "2023-11-14T00:00:06Z"}
assert entry["rx_time"] == 1700000006
def test_neighbor_entry_optional_fields():
entry: NeighborEntry = {
"rx_time": 1700000007,
"rx_iso": "2023-11-14T00:00:07Z",
"neighbor_id": "!11223344",
"snr": 6.0,
}
assert entry["neighbor_id"] == "!11223344"
def test_neighbors_snapshot_required_fields():
snap: NeighborsSnapshot = {
"node_id": "!aabbccdd",
"rx_time": 1700000008,
"rx_iso": "2023-11-14T00:00:08Z",
}
assert snap["node_id"] == "!aabbccdd"
def test_neighbors_snapshot_optional_fields():
snap: NeighborsSnapshot = {
"node_id": "!aabbccdd",
"rx_time": 1700000009,
"rx_iso": "2023-11-14T00:00:09Z",
"neighbors": [],
"node_broadcast_interval_secs": 900,
}
assert snap["node_broadcast_interval_secs"] == 900
def test_trace_event_required_fields():
event: TraceEvent = {
"hops": [1, 2, 3],
"rx_time": 1700000010,
"rx_iso": "2023-11-14T00:00:10Z",
}
assert event["hops"] == [1, 2, 3]
def test_trace_event_optional_fields():
event: TraceEvent = {
"hops": [4, 5],
"rx_time": 1700000011,
"rx_iso": "2023-11-14T00:00:11Z",
"elapsed_ms": 42,
"snr": 2.5,
}
assert event["elapsed_ms"] == 42
def test_ingestor_heartbeat_all_fields():
hb: IngestorHeartbeat = {
"node_id": "!aabbccdd",
"start_time": 1700000000,
"last_seen_time": 1700000012,
"version": "0.5.11",
"lora_freq": 906875,
"modem_preset": "LONG_FAST",
}
assert hb["version"] == "0.5.11"
assert hb["lora_freq"] == 906875
def test_ingestor_heartbeat_without_optional_fields():
hb: IngestorHeartbeat = {
"node_id": "!aabbccdd",
"start_time": 1700000000,
"last_seen_time": 1700000013,
"version": "0.5.11",
}
assert "lora_freq" not in hb

View File

@@ -0,0 +1,76 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for :mod:`data.mesh_ingestor.node_identity`."""
from __future__ import annotations
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor.node_identity import ( # noqa: E402 - path setup
canonical_node_id,
node_num_from_id,
)
def test_canonical_node_id_accepts_numeric():
assert canonical_node_id(1) == "!00000001"
assert canonical_node_id(0xABCDEF01) == "!abcdef01"
assert canonical_node_id(1.0) == "!00000001"
def test_canonical_node_id_accepts_string_forms():
assert canonical_node_id("!ABCDEF01") == "!abcdef01"
assert canonical_node_id("0xABCDEF01") == "!abcdef01"
assert canonical_node_id("abcdef01") == "!abcdef01"
assert canonical_node_id("123") == "!0000007b"
def test_canonical_node_id_passthrough_caret_destinations():
assert canonical_node_id("^all") == "^all"
def test_node_num_from_id_parses_canonical_and_hex():
assert node_num_from_id("!abcdef01") == 0xABCDEF01
assert node_num_from_id("abcdef01") == 0xABCDEF01
assert node_num_from_id("0xabcdef01") == 0xABCDEF01
assert node_num_from_id(123) == 123
def test_canonical_node_id_rejects_none_and_empty():
assert canonical_node_id(None) is None
assert canonical_node_id("") is None
assert canonical_node_id(" ") is None
def test_canonical_node_id_rejects_negative():
assert canonical_node_id(-1) is None
assert canonical_node_id(-0xABCDEF01) is None
def test_canonical_node_id_truncates_overflow():
# Values wider than 32 bits are masked, not rejected.
assert canonical_node_id(0x1_ABCDEF01) == "!abcdef01"
def test_node_num_from_id_rejects_none_and_empty():
assert node_num_from_id(None) is None
assert node_num_from_id("") is None
assert node_num_from_id("not-hex") is None

149
tests/test_provider_unit.py Normal file
View File

@@ -0,0 +1,149 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for :mod:`data.mesh_ingestor.provider` integration seams."""
from __future__ import annotations
import sys
import types
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor import daemon # noqa: E402 - path setup
from data.mesh_ingestor.provider import Provider # noqa: E402 - path setup
from data.mesh_ingestor.providers.meshtastic import ( # noqa: E402 - path setup
MeshtasticProvider,
)
def test_meshtastic_provider_satisfies_protocol():
"""MeshtasticProvider must structurally satisfy the Provider Protocol."""
required = {"name", "subscribe", "connect", "extract_host_node_id", "node_snapshot_items"}
missing = required - set(dir(MeshtasticProvider))
assert not missing, f"MeshtasticProvider is missing Protocol members: {missing}"
def test_daemon_main_uses_provider_connect(monkeypatch):
calls = {"connect": 0}
class FakeProvider(MeshtasticProvider):
def subscribe(self):
return []
def connect(self, *, active_candidate): # type: ignore[override]
calls["connect"] += 1
# Return a minimal iface and stop immediately via Event.
class Iface:
nodes = {}
def close(self):
return None
return Iface(), "serial0", active_candidate
def extract_host_node_id(self, iface): # type: ignore[override]
return "!host"
def node_snapshot_items(self, iface): # type: ignore[override]
return []
# Make the loop exit quickly.
class AutoStopEvent:
def __init__(self):
self._set = False
def set(self):
self._set = True
def is_set(self):
return self._set
def wait(self, _timeout=None):
self._set = True
return True
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", False)
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", 0)
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
monkeypatch.setattr(
daemon,
"threading",
types.SimpleNamespace(
Event=AutoStopEvent,
current_thread=daemon.threading.current_thread,
main_thread=daemon.threading.main_thread,
),
)
monkeypatch.setattr(daemon.handlers, "register_host_node_id", lambda *_a, **_k: None)
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_a, **_k: None)
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", lambda *_a, **_k: None)
monkeypatch.setattr(daemon.ingestors, "queue_ingestor_heartbeat", lambda *_a, **_k: True)
daemon.main(provider=FakeProvider())
assert calls["connect"] >= 1
def test_node_snapshot_items_retries_on_concurrent_mutation(monkeypatch):
"""node_snapshot_items must retry on dict-mutation RuntimeError, not raise."""
from data.mesh_ingestor.providers.meshtastic import MeshtasticProvider
attempt = {"n": 0}
class MutatingNodes:
def items(self):
attempt["n"] += 1
if attempt["n"] < 3:
raise RuntimeError("dictionary changed size during iteration")
return [("!aabbccdd", {"num": 1})]
class FakeIface:
nodes = MutatingNodes()
monkeypatch.setattr("time.sleep", lambda _: None)
result = MeshtasticProvider().node_snapshot_items(FakeIface())
assert result == [("!aabbccdd", {"num": 1})]
assert attempt["n"] == 3
def test_node_snapshot_items_returns_empty_after_retry_exhaustion(monkeypatch):
"""node_snapshot_items returns [] (non-fatal) when all retries fail."""
from data.mesh_ingestor.providers.meshtastic import MeshtasticProvider
import data.mesh_ingestor.providers.meshtastic as _mod
class AlwaysMutating:
def items(self):
raise RuntimeError("dictionary changed size during iteration")
class FakeIface:
nodes = AlwaysMutating()
monkeypatch.setattr("time.sleep", lambda _: None)
monkeypatch.setattr(_mod.config, "_debug_log", lambda *_a, **_k: None)
result = MeshtasticProvider().node_snapshot_items(FakeIface())
assert result == []

View File

@@ -55,8 +55,38 @@ def _javascript_package_version() -> str:
raise AssertionError("package.json does not expose a string version")
def _flutter_package_version() -> str:
pubspec_path = REPO_ROOT / "app" / "pubspec.yaml"
for line in pubspec_path.read_text(encoding="utf-8").splitlines():
if line.startswith("version:"):
version = line.split(":", 1)[1].strip()
if version:
return version
break
raise AssertionError("pubspec.yaml does not expose a version")
def _rust_package_version() -> str:
cargo_path = REPO_ROOT / "matrix" / "Cargo.toml"
inside_package = False
for line in cargo_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped == "[package]":
inside_package = True
continue
if inside_package and stripped.startswith("[") and stripped.endswith("]"):
break
if inside_package:
literal = re.match(
r'version\s*=\s*["\'](?P<version>[^"\']+)["\']', stripped
)
if literal:
return literal.group("version")
raise AssertionError("Cargo.toml does not expose a package version")
def test_version_identifiers_match_across_languages() -> None:
"""Guard against version drift between Python, Ruby, and JavaScript."""
"""Guard against version drift between Python, Ruby, JavaScript, Flutter, and Rust."""
python_version = getattr(data, "__version__", None)
assert (
@@ -65,5 +95,13 @@ def test_version_identifiers_match_across_languages() -> None:
ruby_version = _ruby_fallback_version()
javascript_version = _javascript_package_version()
flutter_version = _flutter_package_version()
rust_version = _rust_package_version()
assert python_version == ruby_version == javascript_version
assert (
python_version
== ruby_version
== javascript_version
== flutter_version
== rust_version
)

View File

@@ -187,7 +187,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"0.5.10"
"0.5.11"
end
# Default refresh interval for frontend polling routines.

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.10",
"version": "0.5.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.10",
"version": "0.5.11",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.10",
"version": "0.5.11",
"type": "module",
"private": true,
"scripts": {

View File

@@ -0,0 +1,64 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
filterDisplayableFederationInstances,
isSuppressedFederationSiteName,
resolveFederationInstanceLabel,
resolveFederationInstanceSortValue,
resolveFederationSiteNameForDisplay,
shouldDisplayFederationInstance,
truncateFederationSiteName
} from '../federation-instance-display.js';
test('isSuppressedFederationSiteName detects URL-like advertising names', () => {
assert.equal(isSuppressedFederationSiteName('http://spam.example offer'), true);
assert.equal(isSuppressedFederationSiteName('Visit www.spam.example today'), true);
assert.equal(isSuppressedFederationSiteName('Mesh Collective'), false);
assert.equal(isSuppressedFederationSiteName(''), false);
assert.equal(isSuppressedFederationSiteName(null), false);
});
test('truncateFederationSiteName shortens names longer than 32 characters', () => {
assert.equal(truncateFederationSiteName('Short Mesh'), 'Short Mesh');
assert.equal(
truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz1234567890'),
'abcdefghijklmnopqrstuvwxyz123...'
);
assert.equal(truncateFederationSiteName('abcdefghijklmnopqrstuvwxyz123456').length, 32);
assert.equal(truncateFederationSiteName(null), '');
});
test('display helpers filter suppressed names and preserve original domains', () => {
const entries = [
{ name: 'Normal Mesh', domain: 'normal.mesh' },
{ name: 'https://spam.example promo', domain: 'spam.mesh' },
{ domain: 'unnamed.mesh' }
];
assert.equal(shouldDisplayFederationInstance(entries[0]), true);
assert.equal(shouldDisplayFederationInstance(entries[1]), false);
assert.deepEqual(filterDisplayableFederationInstances(entries), [
{ name: 'Normal Mesh', domain: 'normal.mesh' },
{ domain: 'unnamed.mesh' }
]);
assert.equal(resolveFederationSiteNameForDisplay(entries[0]), 'Normal Mesh');
assert.equal(resolveFederationInstanceLabel(entries[2]), 'unnamed.mesh');
assert.equal(resolveFederationInstanceSortValue(entries[0]), 'Normal Mesh');
});

View File

@@ -21,6 +21,74 @@ import { createDomEnvironment } from './dom-environment.js';
import { initializeFederationPage } from '../federation-page.js';
import { roleColors } from '../role-helpers.js';
function createBasicFederationPageHarness() {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
return { ...env, statusEl, tbodyEl };
}
function createBasicLeafletStub(options = {}) {
const { markerPopups = null, fitBounds = false } = options;
return {
map() {
return {
setView() {},
on() {},
fitBounds: fitBounds ? () => {} : undefined,
getPane() {
return null;
}
};
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return {
addLayer() {},
addTo() {
return this;
}
};
},
circleMarker() {
return {
bindPopup(html) {
markerPopups?.push(html);
return this;
}
};
}
};
}
test('federation map centers on configured coordinates and follows theme filters', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true });
const { document, window, createElement, registerElement, cleanup } = env;
@@ -603,57 +671,141 @@ test('federation legend toggle respects media query changes', async () => {
});
test('federation page tolerates fetch failures', async () => {
const { cleanup } = createBasicFederationPageHarness();
const fetchImpl = async () => {
throw new Error('boom');
};
const leafletStub = createBasicLeafletStub();
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
cleanup();
});
test('federation page suppresses spammy site names and truncates long names in visible UI', async () => {
const { cleanup, statusEl, tbodyEl } = createBasicFederationPageHarness();
const markerPopups = [];
const leafletStub = createBasicLeafletStub({ markerPopups, fitBounds: true });
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'visible.mesh',
name: 'abcdefghijklmnopqrstuvwxyz1234567890',
latitude: 1,
longitude: 1,
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
},
{
domain: 'spam.mesh',
name: 'www.spam.example buy now',
latitude: 2,
longitude: 2,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
}
]
});
try {
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
assert.equal(statusEl.textContent, '1 instances');
assert.equal(tbodyEl.childNodes.length, 1);
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.doesNotMatch(tbodyEl.childNodes[0].innerHTML, /spam\.mesh/);
assert.equal(markerPopups.length, 1);
assert.match(markerPopups[0], /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.doesNotMatch(markerPopups[0], /www\.spam\.example/);
} finally {
cleanup();
}
});
test('federation page sorts by full site names before truncating visible labels', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const headerNameTh = createElement('th');
const headerName = createElement('span');
headerName.classList.add('sort-header');
headerName.dataset.sortKey = 'name';
headerName.dataset.sortLabel = 'Name';
headerNameTh.appendChild(headerName);
const ths = [headerNameTh];
const headers = [headerName];
const headerHandlers = new Map();
headers.forEach(header => {
header.addEventListener = (event, handler) => {
const existing = headerHandlers.get(header) || {};
existing[event] = handler;
headerHandlers.set(header, existing);
};
header.closest = () => ths.find(th => th.childNodes.includes(header));
header.querySelector = () => null;
});
tableEl.querySelectorAll = selector => {
if (selector === 'thead .sort-header[data-sort-key]') return headers;
if (selector === 'thead th') return ths;
return [];
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify({}));
configEl.setAttribute('data-app-config', JSON.stringify({ mapCenter: { lat: 0, lon: 0 }, mapZoom: 3 }));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
const leafletStub = {
map() {
return {
setView() {},
on() {},
getPane() {
return null;
}
};
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return { addLayer() {}, addTo() { return this; } };
},
circleMarker() {
return { bindPopup() { return this; } };
}
};
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'zeta.mesh',
name: `${sharedPrefix}zeta suffix`,
latitude: 1,
longitude: 1,
lastUpdateTime: Math.floor(Date.now() / 1000) - 30
},
{
domain: 'alpha.mesh',
name: `${sharedPrefix}alpha suffix`,
latitude: 2,
longitude: 2,
lastUpdateTime: Math.floor(Date.now() / 1000) - 60
}
]
});
const fetchImpl = async () => {
throw new Error('boom');
};
try {
await initializeFederationPage({
config: {},
fetchImpl,
leaflet: createBasicLeafletStub({ fitBounds: true })
});
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
cleanup();
const nameHandlers = headerHandlers.get(headerName);
nameHandlers.click();
assert.match(tbodyEl.childNodes[0].innerHTML, /alpha\.mesh/);
assert.match(tbodyEl.childNodes[1].innerHTML, /zeta\.mesh/);
assert.match(tbodyEl.childNodes[0].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
assert.match(tbodyEl.childNodes[1].innerHTML, /abcdefghijklmnopqrstuvwxyz123\.\.\./);
} finally {
cleanup();
}
});

View File

@@ -154,6 +154,75 @@ test('initializeInstanceSelector populates options alphabetically and selects th
}
});
test('initializeInstanceSelector hides suppressed names and truncates long labels', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const navLink = env.document.createElement('a');
navLink.classList.add('js-federation-nav');
navLink.textContent = 'Federation';
env.document.body.appendChild(navLink);
const fetchImpl = async () => ({
ok: true,
async json() {
return [
{ name: 'Visit https://spam.example now', domain: 'spam.mesh' },
{ name: 'abcdefghijklmnopqrstuvwxyz1234567890', domain: 'long.mesh' },
{ name: 'Alpha Mesh', domain: 'alpha.mesh' }
];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(select.options.length, 3);
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
assert.equal(select.options[2].textContent, 'Alpha Mesh');
assert.equal(navLink.textContent, 'Federation (2)');
assert.equal(select.options.some(option => option.value === 'spam.mesh'), false);
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector sorts by full site names before truncating labels', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);
const sharedPrefix = 'abcdefghijklmnopqrstuvwxyz123';
const fetchImpl = async () => ({
ok: true,
async json() {
return [
{ name: `${sharedPrefix}zeta suffix`, domain: 'zeta.mesh' },
{ name: `${sharedPrefix}alpha suffix`, domain: 'alpha.mesh' }
];
}
});
try {
await initializeInstanceSelector({
selectElement: select,
fetchImpl,
windowObject: env.window,
documentObject: env.document
});
assert.equal(select.options[1].value, 'alpha.mesh');
assert.equal(select.options[2].value, 'zeta.mesh');
assert.equal(select.options[1].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
assert.equal(select.options[2].textContent, 'abcdefghijklmnopqrstuvwxyz123...');
} finally {
env.cleanup();
}
});
test('initializeInstanceSelector navigates to the chosen instance domain', async () => {
const env = createDomEnvironment();
const select = setupSelectElement(env.document);

View File

@@ -0,0 +1,172 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const MAX_VISIBLE_SITE_NAME_LENGTH = 32;
const TRUNCATION_SUFFIX = '...';
const TRUNCATED_SITE_NAME_LENGTH = MAX_VISIBLE_SITE_NAME_LENGTH - TRUNCATION_SUFFIX.length;
const SUPPRESSED_SITE_NAME_PATTERN = /(?:^|[^a-z0-9])(?:https?:\/\/|www\.)\S+/i;
/**
* Read a federated instance site name as a trimmed string.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Trimmed site name or an empty string when absent.
*/
function readSiteName(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
return typeof entry.name === 'string' ? entry.name.trim() : '';
}
/**
* Read a federated instance domain as a trimmed string.
*
* @param {{ domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Trimmed domain or an empty string when absent.
*/
function readDomain(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
return typeof entry.domain === 'string' ? entry.domain.trim() : '';
}
/**
* Determine whether a remote site name should be suppressed from frontend displays.
*
* @param {string} name Remote site name.
* @returns {boolean} true when the name contains a URL-like advertising token.
*/
export function isSuppressedFederationSiteName(name) {
if (typeof name !== 'string') {
return false;
}
const trimmed = name.trim();
if (!trimmed) {
return false;
}
return SUPPRESSED_SITE_NAME_PATTERN.test(trimmed);
}
/**
* Truncate an instance site name for frontend display without mutating source data.
*
* Names longer than 32 characters are shortened to stay within that 32-character
* budget including the trailing ellipsis.
*
* @param {string} name Remote site name.
* @returns {string} Display-ready site name.
*/
export function truncateFederationSiteName(name) {
if (typeof name !== 'string') {
return '';
}
const trimmed = name.trim();
if (trimmed.length <= MAX_VISIBLE_SITE_NAME_LENGTH) {
return trimmed;
}
return `${trimmed.slice(0, TRUNCATED_SITE_NAME_LENGTH)}${TRUNCATION_SUFFIX}`;
}
/**
* Determine whether an instance should remain visible in frontend federation views.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {boolean} true when the entry should be shown to users.
*/
export function shouldDisplayFederationInstance(entry) {
return !isSuppressedFederationSiteName(readSiteName(entry));
}
/**
* Resolve a frontend display name for a federation instance.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Display-ready site name or an empty string when absent.
*/
export function resolveFederationSiteNameForDisplay(entry) {
const siteName = readSiteName(entry);
return siteName ? truncateFederationSiteName(siteName) : '';
}
/**
* Resolve the original trimmed site name for a federation instance.
*
* @param {{ name?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Full trimmed site name or an empty string when absent.
*/
export function resolveFederationSiteName(entry) {
return readSiteName(entry);
}
/**
* Determine the full sort value for an instance selector entry.
*
* Sorting must use the original trimmed site name so truncation does not collapse
* multiple entries into the same comparison key.
*
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Full trimmed site name falling back to the domain.
*/
export function resolveFederationInstanceSortValue(entry) {
const siteName = resolveFederationSiteName(entry);
return siteName || readDomain(entry);
}
/**
* Determine the most suitable display label for an instance list entry.
*
* @param {{ name?: string, domain?: string } | null | undefined} entry Federation instance payload entry.
* @returns {string} Display label falling back to the domain.
*/
export function resolveFederationInstanceLabel(entry) {
const siteName = resolveFederationSiteNameForDisplay(entry);
if (siteName) {
return siteName;
}
return readDomain(entry);
}
/**
* Filter a federation payload down to the instances that should remain visible.
*
* @param {Array<object>} entries Federation payload from the API.
* @returns {Array<object>} Visible instances for frontend rendering.
*/
export function filterDisplayableFederationInstances(entries) {
if (!Array.isArray(entries)) {
return [];
}
return entries.filter(shouldDisplayFederationInstance);
}
export const __test__ = {
MAX_VISIBLE_SITE_NAME_LENGTH,
TRUNCATION_SUFFIX,
TRUNCATED_SITE_NAME_LENGTH,
readDomain,
readSiteName,
SUPPRESSED_SITE_NAME_PATTERN
};

View File

@@ -15,6 +15,11 @@
*/
import { readAppConfig } from './config.js';
import {
filterDisplayableFederationInstances,
resolveFederationSiteName,
resolveFederationSiteNameForDisplay
} from './federation-instance-display.js';
import { resolveLegendVisibility } from './map-legend-visibility.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
@@ -274,7 +279,12 @@ export async function initializeFederationPage(options = {}) {
? true
: legendCollapsedValue.trim() !== 'false';
const tableSorters = {
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
name: {
getValue: inst => resolveFederationSiteName(inst),
compare: compareString,
hasValue: hasStringValue,
defaultDirection: 'asc'
},
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
@@ -363,7 +373,8 @@ export async function initializeFederationPage(options = {}) {
for (const instance of sorted) {
const tr = document.createElement('tr');
const url = buildInstanceUrl(instance.domain);
const nameHtml = instance.name ? escapeHtml(instance.name) : '<em>—</em>';
const displayName = resolveFederationSiteNameForDisplay(instance);
const nameHtml = displayName ? escapeHtml(displayName) : '<em>—</em>';
const domainHtml = url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
: escapeHtml(instance.domain || '');
@@ -529,7 +540,7 @@ export async function initializeFederationPage(options = {}) {
credentials: 'omit'
});
if (response.ok) {
instances = await response.json();
instances = filterDisplayableFederationInstances(await response.json());
}
} catch (err) {
console.warn('Failed to fetch federation instances', err);
@@ -636,7 +647,8 @@ export async function initializeFederationPage(options = {}) {
bounds.push([lat, lon]);
const name = instance.name || instance.domain || 'Unknown';
const displayName = resolveFederationSiteNameForDisplay(instance);
const name = displayName || instance.domain || 'Unknown';
const url = buildInstanceUrl(instance.domain);
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const popupLines = [

View File

@@ -14,6 +14,12 @@
* limitations under the License.
*/
import {
filterDisplayableFederationInstances,
resolveFederationInstanceLabel,
resolveFederationInstanceSortValue
} from './federation-instance-display.js';
/**
* Determine the most suitable label for an instance list entry.
*
@@ -21,17 +27,7 @@
* @returns {string} Preferred display label falling back to the domain.
*/
function resolveInstanceLabel(entry) {
if (!entry || typeof entry !== 'object') {
return '';
}
const name = typeof entry.name === 'string' ? entry.name.trim() : '';
if (name.length > 0) {
return name;
}
const domain = typeof entry.domain === 'string' ? entry.domain.trim() : '';
return domain;
return resolveFederationInstanceLabel(entry);
}
/**
@@ -206,23 +202,21 @@ export async function initializeInstanceSelector(options) {
return;
}
if (!Array.isArray(payload)) {
return;
}
updateFederationNavCount({ documentObject: doc, count: payload.length });
const visibleEntries = filterDisplayableFederationInstances(payload);
updateFederationNavCount({ documentObject: doc, count: visibleEntries.length });
const sanitizedDomain = typeof instanceDomain === 'string' ? instanceDomain.trim().toLowerCase() : null;
const sortedEntries = payload
const sortedEntries = visibleEntries
.filter(entry => entry && typeof entry.domain === 'string' && entry.domain.trim() !== '')
.map(entry => ({
domain: entry.domain.trim(),
label: resolveInstanceLabel(entry),
sortLabel: resolveFederationInstanceSortValue(entry),
}))
.sort((a, b) => {
const labelA = a.label || a.domain;
const labelB = b.label || b.domain;
const labelA = a.sortLabel || a.domain;
const labelB = b.sortLabel || b.domain;
return labelA.localeCompare(labelB, undefined, { sensitivity: 'base' });
});

View File

@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
node_id: "!abc12345",
start_time: now - 120,
last_seen_time: now - 60,
version: "0.5.10",
version: "0.5.11",
lora_freq: 915,
modem_preset: "LongFast",
}.merge(overrides)
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!fresh000", now - 100, now - 10, "0.5.10"],
["!fresh000", now - 100, now - 10, "0.5.11"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
["!rich000", now - 200, now - 100, "0.5.10", 915, "MediumFast"],
["!rich000", now - 200, now - 100, "0.5.11", 915, "MediumFast"],
)
end
@@ -173,7 +173,7 @@ RSpec.describe "Ingestor endpoints" do
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!new-ingestor", now - 60, now - 30, "0.5.10"],
["!new-ingestor", now - 60, now - 30, "0.5.11"],
)
end