Merge pull request #379 from agessaman/feat/mqtt-neighbors

feat(mqtt): periodic neighbours publication
This commit is contained in:
Lloyd
2026-07-29 21:55:46 +01:00
committed by GitHub
76 changed files with 4824 additions and 155 deletions
+27
View File
@@ -545,6 +545,32 @@ mqtt_brokers:
email: ""
brokers: []
# Periodic neighbours publication (the "neighbors" topic).
#
# Each cycle runs a zero-hop node-discovery broadcast to refresh the neighbour
# table, then asks each zero-hop repeater for its region scopes with an
# anonymous regions request, then publishes the assembled table. Scope queries
# are issued ONE AT A TIME - firing them as a burst makes the responses collide.
#
# Off unless a broker opts in below with `neighbors: true`; `enabled` here is a
# master kill switch for the whole feature.
# neighbors:
# enabled: true # master on/off (default true)
# interval_hours: 24 # 12-336, how often a cycle runs
# discovery_timeout_seconds: 60 # node-discovery collection window
# scope_response_timeout_seconds: 0 # 0 = size the window from radio settings
# max_neighbors: 32 # cap on neighbours queried per cycle
# max_neighbor_age_seconds: 86400 # ignore zero-hop rows older than this
# max_sweep_seconds: 900 # give up on a cycle after this long
# duty_cycle_abort_seconds: 30 # abandon the sweep when the airtime
# # backlog exceeds this
#
# This must be a block, not a boolean - `neighbors: true` here is ignored (with
# a startup warning) because the on/off switch that matters is the per-broker
# `neighbors: true` flag documented in the broker schema below.
#
# `discover.scopes` over the mesh CLI runs one cycle immediately.
# Below is the broker object schema:
# enabled: true|false # Enable this specific mqtt broker
# name: "" # Internal name for this broker
@@ -560,6 +586,7 @@ mqtt_brokers:
# letsmesh, waev - MC2MQTT family flavors (same topic structure, network-specific identity)
# mqtt - legacy openhop-repeater local-broker convention (custom topic, singular 'packet')
# retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery)
# neighbors: true|false # Publish the periodic neighbours table to this broker's "neighbors" topic (default false). Only enable for brokers that expect the topic - some reject unknown topics and close the connection.
# tls:
# enabled: true|false # Enable TLS. If the endpoint's certificate is self-signed, the Root CA should be added to the OS's certificate store.
# insecure: true|false # Validate TLS certificates
+61
View File
@@ -239,6 +239,12 @@ class _BrokerConnection:
self.enabled = broker.get("enabled", False)
self.retain_status = broker.get("retain_status", False)
# Opt-in per broker, default off. The neighbors topic is not part of every
# MC2MQTT deployment's contract, and a broker that rejects an unexpected
# topic closes the connection (rc=16) - see the format auto-correction
# below for the same hazard. This is the openhop equivalent of the
# firmware's packets-only MeshRank exclusion.
self.neighbors_enabled = bool(broker.get("neighbors", False))
self._tls_verified = False
@@ -1016,6 +1022,61 @@ class MeshCoreToMqttPusher:
return results
def has_neighbors_brokers(self) -> bool:
"""True when at least one enabled broker opted into the neighbors topic."""
return any(conn.enabled and conn.neighbors_enabled for conn in self.connections)
def has_connected_neighbors_brokers(self) -> bool:
"""True when an opted-in broker is connected and could receive a publish."""
return any(
conn.enabled and conn.neighbors_enabled and conn.is_connected()
for conn in self.connections
)
def publish_neighbors(self, payload: dict):
"""Publish the neighbours table to every broker that opted in.
QoS 1 and non-retained, matching the firmware's ``publishNeighbors()``:
the table is a periodic snapshot, so a stale retained copy would outlive
its usefulness, but it is infrequent enough to be worth delivering once.
"""
message = json.dumps(payload)
neighbor_count = len(payload.get("neighbors", []))
logger.debug(
f"Publishing topic='neighbors', neighbors={neighbor_count}, "
f"bytes={len(message.encode('utf-8'))}"
)
results = []
with self._lock:
for conn in self.connections:
if not (conn.enabled and conn.neighbors_enabled):
continue
if not conn.is_connected():
logger.warning(
f"Cannot publish neighbors to {conn.broker['name']} - not connected"
)
continue
result = conn.publish("neighbors", message, retain=False, qos=1)
# This is by far the largest payload the node emits and it is not
# size-capped, so an oversized or queue-full rejection is a real
# outcome. Check paho's rc rather than reporting a publish that
# never left the client as a success.
rc = getattr(result, "rc", None)
if result is None or (rc is not None and rc != mqtt.MQTT_ERR_SUCCESS):
logger.warning(
f"Neighbors publish rejected by {conn.broker['name']} "
f"(rc={rc}, bytes={len(message.encode('utf-8'))})"
)
continue
results.append((conn.broker["name"], result))
_trace(f"Published to {conn.broker['name']} -- neighbors")
if not results:
logger.warning("Neighbors table was not published to any broker")
return results
def publish_mqtt(self, payload: dict, subtopic: str, retain: bool = False, qos: int = 0):
"""Publish message to brokers using the legacy custom-MQTT format only.
+207
View File
@@ -735,11 +735,190 @@ class SQLiteHandler:
)
logger.info(f"Migration '{migration_name}' applied successfully")
# Migration 14: Small key/value store for daemon state that must
# outlive a restart. Added for the neighbours publisher, whose
# schedule otherwise resets on every boot and re-runs a discovery
# sweep; kept generic so the next such need does not add another
# table. Not for anything hot -- one row per writer, rewritten at
# whatever cadence that writer already has.
migration_name = "add_daemon_state"
existing = conn.execute(
"SELECT migration_name FROM migrations WHERE migration_name = ?",
(migration_name,),
).fetchone()
if not existing:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS daemon_state (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at REAL NOT NULL
)
"""
)
conn.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, time.time()),
)
logger.info(f"Migration '{migration_name}' applied successfully")
# Migration 15: Last-known region scopes per neighbour, from the
# anon-regions query the neighbours publisher issues. The MQTT
# payload was the only consumer, so the answers were discarded as
# soon as they were published and the web UI had nothing to show.
# One row per queried neighbour, rewritten at most once per query.
migration_name = "add_neighbor_scopes"
existing = conn.execute(
"SELECT migration_name FROM migrations WHERE migration_name = ?",
(migration_name,),
).fetchone()
if not existing:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS neighbor_scopes (
pubkey TEXT PRIMARY KEY,
scopes TEXT NOT NULL DEFAULT '',
responded_at REAL,
status TEXT NOT NULL,
queried_at REAL NOT NULL
)
"""
)
conn.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, time.time()),
)
logger.info(f"Migration '{migration_name}' applied successfully")
conn.commit()
except Exception as e:
logger.error(f"Failed to run migrations: {e}")
# Neighbour scope methods
def get_neighbor_scopes(self) -> dict:
"""Return every stored scope record, keyed by lowercase pubkey hex.
Never raises: the caller renders a column from this, and a missing or
corrupt table must degrade to "nothing known" rather than fail the
request that also carries the neighbour table.
"""
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT pubkey, scopes, responded_at, status, queried_at FROM neighbor_scopes"
).fetchall()
return {
row["pubkey"]: {
"scopes": row["scopes"] or "",
"responded_at": row["responded_at"],
"status": row["status"],
"queried_at": row["queried_at"],
}
for row in rows
}
except Exception as e:
logger.debug(f"Could not read neighbour scopes: {e}")
return {}
def record_neighbor_scope(
self,
pubkey: str,
status: str,
scopes: Optional[str] = None,
queried_at: Optional[float] = None,
) -> bool:
"""Record the outcome of one scope query.
``scopes`` is the answer the neighbour gave, and is only supplied when it
actually answered. A failed query updates ``status``/``queried_at`` but
leaves the last known ``scopes`` and ``responded_at`` in place: the
responder rate-limits anon replies (4 per 3 minutes, shared across
identities), so a single timeout is weak evidence that a neighbour's
scopes changed and is not worth discarding a good answer over.
An empty ``scopes`` string is a real answer -- it means the neighbour
serves unscoped traffic only -- so it is stored, not treated as absent.
"""
pubkey = str(pubkey or "").strip().lower()
if not pubkey:
return False
when = time.time() if queried_at is None else float(queried_at)
try:
with self._connect() as conn:
if scopes is None:
conn.execute(
"""
INSERT INTO neighbor_scopes (pubkey, scopes, responded_at,
status, queried_at)
VALUES (?, '', NULL, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET
status = excluded.status,
queried_at = excluded.queried_at
""",
(pubkey, str(status), when),
)
else:
conn.execute(
"""
INSERT INTO neighbor_scopes (pubkey, scopes, responded_at,
status, queried_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET
scopes = excluded.scopes,
responded_at = excluded.responded_at,
status = excluded.status,
queried_at = excluded.queried_at
""",
(pubkey, str(scopes), when, str(status), when),
)
conn.commit()
return True
except Exception as e:
logger.warning(f"Could not persist neighbour scopes for {pubkey[:8]}: {e}")
return False
# Daemon state methods
def get_daemon_state(self, key: str) -> Optional[dict]:
"""Read a persisted daemon-state blob, or None when absent/unreadable.
Never raises: every caller treats missing state as "no history", so a
corrupt row must degrade to that rather than break startup.
"""
try:
with self._connect() as conn:
row = conn.execute(
"SELECT value_json FROM daemon_state WHERE key = ?", (key,)
).fetchone()
if not row:
return None
value = json.loads(row[0])
return value if isinstance(value, dict) else None
except Exception as e:
logger.debug(f"Could not read daemon state '{key}': {e}")
return None
def set_daemon_state(self, key: str, value: dict) -> bool:
"""Upsert a daemon-state blob. Returns whether it was written."""
try:
with self._connect() as conn:
conn.execute(
"""
INSERT INTO daemon_state (key, value_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value_json = excluded.value_json,
updated_at = excluded.updated_at
""",
(key, json.dumps(value), time.time()),
)
conn.commit()
return True
except Exception as e:
logger.warning(f"Could not persist daemon state '{key}': {e}")
return False
# API Token methods
def create_api_token(self, name: str, token_hash: str) -> int:
"""Create a new API token entry"""
@@ -2564,6 +2743,11 @@ class SQLiteHandler:
try:
with self._connect() as conn:
result = conn.execute(purge_queries[table_name])
if table_name == "adverts":
# Purging the neighbour table has to take the scopes with it,
# or the UI shows scope counts for repeaters it no longer lists
# and presents them as current.
conn.execute("DELETE FROM neighbor_scopes")
conn.commit()
logger.info(f"Purged {result.rowcount} rows from {table_name}")
return result.rowcount
@@ -2600,6 +2784,14 @@ class SQLiteHandler:
result = conn.execute("DELETE FROM adverts WHERE timestamp < ?", (cutoff,))
adverts_deleted = result.rowcount
# A scope row describes a neighbour, so it has nothing left to be
# displayed against once that neighbour's advert is pruned. Without
# this it would survive every retention pass and grow without bound.
conn.execute(
"DELETE FROM neighbor_scopes WHERE pubkey NOT IN "
"(SELECT lower(pubkey) FROM adverts)"
)
result = conn.execute("DELETE FROM noise_floor WHERE timestamp < ?", (cutoff,))
noise_deleted = result.rowcount
@@ -3085,7 +3277,17 @@ class SQLiteHandler:
def delete_advert(self, advert_id: int) -> bool:
try:
with self._connect() as conn:
# Adverts are one row per pubkey (migration `adverts_unique_pubkey`),
# so deleting one drops the neighbour entirely; its scope row would
# otherwise outlive it with nothing left to display it against.
row = conn.execute(
"SELECT pubkey FROM adverts WHERE id = ?", (advert_id,)
).fetchone()
cursor = conn.execute("DELETE FROM adverts WHERE id = ?", (advert_id,))
if row and row[0]:
conn.execute(
"DELETE FROM neighbor_scopes WHERE pubkey = ?", (str(row[0]).lower(),)
)
self._neighbors_cache = {"timestamp": 0.0, "value": None}
return cursor.rowcount > 0
except Exception as e:
@@ -3098,11 +3300,16 @@ class SQLiteHandler:
with self._connect() as conn:
if pubkey_prefix is None:
cursor = conn.execute("DELETE FROM adverts")
conn.execute("DELETE FROM neighbor_scopes")
else:
cursor = conn.execute(
"DELETE FROM adverts WHERE lower(pubkey) LIKE ?",
(f"{pubkey_prefix.lower()}%",),
)
conn.execute(
"DELETE FROM neighbor_scopes WHERE pubkey LIKE ?",
(f"{pubkey_prefix.lower()}%",),
)
self._neighbors_cache = {"timestamp": 0.0, "value": None}
return int(cursor.rowcount)
except Exception as e:
@@ -539,6 +539,24 @@ class StorageCollector:
def get_neighbors(self) -> dict:
return self.sqlite_handler.get_neighbors()
def get_neighbor_scopes(self) -> dict:
return self.sqlite_handler.get_neighbor_scopes()
def record_neighbor_scope(
self,
pubkey: str,
status: str,
scopes: Optional[str] = None,
queried_at: Optional[float] = None,
) -> bool:
return self.sqlite_handler.record_neighbor_scope(pubkey, status, scopes, queried_at)
def get_daemon_state(self, key: str) -> Optional[dict]:
return self.sqlite_handler.get_daemon_state(key)
def set_daemon_state(self, key: str, value: dict) -> bool:
return self.sqlite_handler.set_daemon_state(key, value)
def get_node_name_by_pubkey(self, pubkey: str) -> Optional[str]:
"""
Lookup node name from adverts table by public key.
+2
View File
@@ -3,6 +3,7 @@
from .advert import AdvertHelper
from .discovery import DiscoveryHelper
from .login import LoginHelper
from .neighbor_scopes import NeighborScopeHelper
from .path import PathHelper
from .protocol_request import ProtocolRequestHelper
from .text import TextHelper
@@ -13,6 +14,7 @@ __all__ = [
"DiscoveryHelper",
"AdvertHelper",
"LoginHelper",
"NeighborScopeHelper",
"TextHelper",
"PathHelper",
"ProtocolRequestHelper",
+85
View File
@@ -36,6 +36,91 @@ NODE_TYPE_NAMES = {
}
def build_discovery_advert_record(result: dict) -> Optional[dict]:
"""Turn a discovery response into a zero-hop advert row, or None if unusable.
A node-discover response is only ever answered by a node that heard our
zero-hop broadcast directly, so the row is recorded with ``zero_hop=True``
and ``route_type=2`` the same thing firmware ``putNeighbour()`` records
when a discovery response arrives.
"""
pubkey = str(result.get("pub_key") or "").strip().lower()
if pubkey.startswith("0x"):
pubkey = pubkey[2:]
if not pubkey:
return None
node_type = int(result.get("node_type", 0) or 0)
rssi = result.get("rssi")
# response_snr is our RX of their reply; plain snr is the fallback shape.
snr = result.get("response_snr", result.get("snr"))
return {
"timestamp": time.time(),
"pubkey": pubkey,
"node_name": result.get("node_name"),
"is_repeater": node_type == 2,
"route_type": 2,
"contact_type": NODE_TYPE_NAMES.get(node_type, "Unknown"),
"latitude": None,
"longitude": None,
"rssi": int(rssi) if rssi is not None else None,
"snr": float(snr) if snr is not None else None,
"is_new_neighbor": True,
"zero_hop": True,
}
def persist_discovery_result(storage, result: dict) -> bool:
"""Store one discovery response, returning whether it was written.
Accepts either storage object the callers actually hold: ``StorageCollector``
exposes ``record_advert`` (store plus the MQTT/Glass advert publish), while
``SQLiteHandler`` exposes only ``store_advert``. Preferring the first and
falling back to the second is what makes this work from the mesh CLI, which
is constructed with the SQLiteHandler checking for ``record_advert`` alone
made ``discover.neighbors`` silently persist nothing.
"""
if storage is None:
return False
record = build_discovery_advert_record(result)
if record is None:
return False
writer = getattr(storage, "record_advert", None) or getattr(storage, "store_advert", None)
if not callable(writer):
logger.debug("Storage backend cannot persist discovery results")
return False
# Discovery responses do not carry advert metadata such as name or location.
# Preserve those fields from an existing advert instead of replacing them
# with None when SQLite updates the neighbor row.
reader = getattr(storage, "get_neighbors", None)
if callable(reader):
try:
neighbors = reader()
if isinstance(neighbors, dict):
existing = neighbors.get(record["pubkey"])
if isinstance(existing, dict):
for field in ("node_name", "latitude", "longitude"):
if record.get(field) is None:
record[field] = existing.get(field)
except Exception as e:
logger.debug(
"Failed to read existing discovery metadata for %s: %s",
record["pubkey"][:8],
e,
)
try:
writer(record)
return True
except Exception as e:
logger.debug("Failed to persist discovery result for %s: %s", record["pubkey"][:8], e)
return False
class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater."""
+64 -32
View File
@@ -1,3 +1,4 @@
import concurrent.futures
import logging
from pathlib import Path
from typing import Any, Callable, Dict, Optional
@@ -128,41 +129,11 @@ class MeshCLI:
if not self.storage_handler:
return enriched
record_advert = getattr(self.storage_handler, "record_advert", None)
if not callable(record_advert):
return enriched
from repeater.handler_helpers.discovery import persist_discovery_result
try:
import time
node_type = int(enriched.get("node_type", 0) or 0)
contact_type = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}.get(node_type, "Unknown")
rssi = enriched.get("rssi")
snr = enriched.get("response_snr", enriched.get("snr"))
advert_record = {
"timestamp": time.time(),
"pubkey": pub_key,
"node_name": enriched.get("node_name"),
"is_repeater": node_type == 2,
"route_type": 2,
"contact_type": contact_type,
"latitude": None,
"longitude": None,
"rssi": int(rssi) if rssi is not None else None,
"snr": float(snr) if snr is not None else None,
"is_new_neighbor": True,
"zero_hop": True,
}
record_advert(advert_record)
if persist_discovery_result(self.storage_handler, enriched):
enriched["known_neighbor"] = True
enriched["auto_added"] = True
except Exception as exc:
logger.debug("Auto-add discovery result failed for %s: %s", pub_key, exc)
return enriched
@@ -247,6 +218,8 @@ class MeshCLI:
return self._cmd_neighbors()
elif command.startswith("neighbor.remove "):
return self._cmd_neighbor_remove(command)
elif command.startswith("discover.scopes"):
return self._cmd_discover_scopes(command)
elif command.startswith("discover.neighbors"):
return self._cmd_discover_neighbors(command)
@@ -326,6 +299,7 @@ class MeshCLI:
" neighbors List neighbors",
" neighbor.remove <key> Remove neighbor by pubkey",
" discover.neighbors Send zero-hop neighbor discovery",
" discover.scopes Discover neighbor scopes, publish to MQTT",
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
" setperm <pubkey> <perm> Set ACL permissions",
" log start|stop|erase Logging control",
@@ -380,6 +354,12 @@ class MeshCLI:
),
"neighbors": "List known neighbor nodes from the routing table.",
"discover.neighbors": "Send a neighbor discovery request.",
"discover.scopes": (
"discover.scopes\n"
" Refresh the zero-hop neighbor table, query each neighbor for its\n"
" region scopes, then publish the table to the MQTT neighbors topic.\n"
" Requires a broker configured with neighbors: true."
),
"setperm": "setperm <pubkey_hex> <permission_int> \u2014 Set ACL permissions for a node.",
"log": "log start|stop|erase \u2014 Control logging.",
}
@@ -1295,6 +1275,58 @@ class MeshCLI:
logger.error(f"discover.neighbors failed: {e}", exc_info=True)
return f"Error: {e}"
def _cmd_discover_scopes(self, command: str) -> str:
"""Refresh the neighbour table, query each neighbour's scopes, publish once.
Manual trigger for the periodic MQTT neighbors cycle (firmware
``discover.scopes``). The cycle takes minutes -- a node-discovery window
plus one serialized scope query per neighbour -- so this schedules it and
returns immediately rather than holding the CLI reply open.
"""
sub = command[15:]
if sub.strip():
return "Err - discover.scopes has no options"
daemon_instance = getattr(self.config_manager, "daemon", None)
publisher = getattr(daemon_instance, "neighbors_publisher", None)
if not publisher:
return "Error: Neighbors publisher not available"
if not publisher.enabled():
return "Err - neighbors publishing is disabled (no broker opted in)"
import asyncio
loop = self._event_loop
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is None or not loop.is_running():
return "Error: Event loop not available"
try:
# trigger_cycle owns the "already running" check and tracks the task
# so shutdown can cancel it. Doing it here instead would race: this
# runs on the CLI thread, the cycle starts on the event loop.
started = concurrent.futures.Future()
def _start():
try:
started.set_result(publisher.trigger_cycle())
except Exception as exc: # pragma: no cover - defensive
started.set_exception(exc)
loop.call_soon_threadsafe(_start)
if started.result(timeout=5):
return "OK - neighbor scope discovery started"
return "Err - neighbors cycle already active"
except Exception as e:
logger.error(f"discover.scopes failed: {e}", exc_info=True)
return f"Error: {e}"
# ==================== Temporary Radio Commands ====================
def _cmd_tempradio(self, command: str) -> str:
+458
View File
@@ -0,0 +1,458 @@
"""Neighbour scope-query helper for openHop Repeater.
Client side of the anonymous *regions* request. The server side already lives in
:class:`~openhop_core.node.handlers.anon_request.AnonRequestHandler` (wired up by
:mod:`repeater.handler_helpers.login`); this module asks the question instead of
answering it, so the MQTT ``neighbors`` topic can report which region scopes each
zero-hop neighbour serves.
Wire format mirrors firmware ``MyMesh::sendAnonRegionsReq``: a PAYLOAD_TYPE_ANON_REQ
whose plaintext is ``tag(4) + ANON_REQ_TYPE_REGIONS + 0x00``, where the trailing
``0x00`` asks for a zero-hop reply path. The reply is a PAYLOAD_TYPE_RESPONSE
datagram (``dest_hash(1) + src_hash(1) + cipher``) whose plaintext is
``tag(4) + clock(4) + comma_separated_scope_names``.
Two firmware behaviours are load-bearing and are reproduced here:
* The request must be **route-direct** ``AnonRequestHandler`` (and the firmware
regions handler it mirrors) ignores flooded discovery sub-types outright.
* Queries are issued **strictly one at a time**. Firing them as a burst makes every
responder answer into the same window and the replies collide; the firmware fixed
this by walking the snapshot one entry at a time and only arming the response
deadline once the request has actually transmitted (MeshCore ``aba571ed``).
``router.inject_packet`` already resolves at that exact point it awaits
``dispatcher.send_packet`` under the engine TX lock so the firmware's
QUEUED/PENDING state machine collapses into a sequential ``await`` here.
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
from openhop_core.protocol import CryptoUtils, Identity
from openhop_core.protocol.constants import (
ANON_REQ_TYPE_REGIONS,
MAX_PACKET_PAYLOAD,
PAYLOAD_TYPE_RESPONSE,
)
logger = logging.getLogger("NeighborScopes")
# Per-neighbour outcome, published verbatim in the MQTT neighbors payload.
STATUS_RESPONDED = "responded"
STATUS_TIMEOUT = "timeout"
STATUS_SEND_FAILED = "send_failed"
# Responder-side reply delay to budget for. Firmware uses SERVER_RESPONSE_DELAY
# (500 ms); openhop_core's AnonRequestHandler uses 300 ms. Budget the larger so a
# firmware neighbour is not written off early.
SERVER_RESPONSE_DELAY_MS = 500.0
# Slack for scheduler jitter, matching the firmware's own 360 ms constant.
RESPONSE_TIMEOUT_SLACK_MS = 360.0
# Bounds for the auto-sized response timeout. The floor keeps a fast radio config
# from producing an unreachably tight window; the ceiling keeps a slow one (SF12,
# narrow bandwidth) from stalling a whole sweep on one dead neighbour.
MIN_RESPONSE_TIMEOUT_SEC = 5.0
MAX_RESPONSE_TIMEOUT_SEC = 120.0
# Used when no AirtimeManager is available to size the window from radio params.
FALLBACK_RESPONSE_TIMEOUT_SEC = 30.0
# Whole-sweep budget, and how long a duty-cycle backlog may be before the sweep
# gives up rather than queueing behind forwarding traffic for minutes.
DEFAULT_MAX_SWEEP_SECONDS = 900.0
DEFAULT_DUTY_CYCLE_ABORT_SECONDS = 30.0
def neighbors_config_block(config: Optional[dict]) -> dict:
"""Return ``mqtt_brokers.neighbors`` as a mapping, or ``{}`` when it is not one.
``neighbors`` names a settings block under ``mqtt_brokers`` but a plain
boolean on each broker entry, and ``config.yaml.example`` documents both, so
``mqtt_brokers.neighbors: true`` is an easy hand-edit to make. A truthy
non-mapping used to reach ``.get()`` directly, which raised AttributeError
inside :meth:`NeighborScopeHelper.refresh_config` and because that helper is
built during daemon init, it took the whole daemon down on startup. Ignore the
value instead; saving from the API rewrites it as a proper block.
"""
if not isinstance(config, dict):
return {}
brokers_cfg = config.get("mqtt_brokers", {})
if not isinstance(brokers_cfg, dict):
return {}
block = brokers_cfg.get("neighbors", {})
if not isinstance(block, dict):
logger.debug(
"Ignoring mqtt_brokers.neighbors: expected a settings block, got %s",
type(block).__name__,
)
return {}
return block
@dataclass(frozen=True)
class NeighborSnapshot:
"""One neighbour, frozen at sweep start.
Firmware originally indexed into the live ``neighbours[]`` table and had to
stop doing that (``aba571ed``) because an advert arriving mid-pass could
reshuffle it. Same reasoning here: the sqlite neighbour table keeps changing
while a multi-minute sweep runs, so what gets published is decided up front.
"""
pubkey: str
last_seen: float = 0.0
snr: float = 0.0
@property
def pubkey_bytes(self) -> bytes:
return bytes.fromhex(self.pubkey)
@dataclass
class ScopeResult:
status: str
scopes: str = ""
# Whether the request actually reached the air. Feeds the payload's
# ``queried_neighbors``, which firmware increments in ``logTx`` when a QUEUED
# entry becomes PENDING. It is not derivable from ``status``: a target the
# sweep never reached is reported as ``timeout`` exactly like one that was
# asked and stayed silent.
transmitted: bool = False
@dataclass
class _PendingQuery:
pubkey: str
tag: int
src_hash: int
future: "asyncio.Future[str]" = field(repr=False, default=None)
class _ScopeTarget:
"""Minimal contact stand-in for ``PacketBuilder.create_anon_request``.
``out_path_len = 0`` (rather than -1) selects direct routing with an empty
path, i.e. the zero-hop request the responder's route-direct gate requires.
"""
def __init__(self, pubkey_hex: str):
self.public_key = pubkey_hex
self.out_path_len = 0
self.out_path = b""
class NeighborScopeHelper:
"""Issues anon-regions queries and matches their responses."""
def __init__(
self,
local_identity,
packet_injector: Optional[Callable] = None,
airtime_manager=None,
config: Optional[dict] = None,
):
self.local_identity = local_identity
self.packet_injector = packet_injector
self.airtime_manager = airtime_manager
# Held by reference so a live config update is visible; re-read at the
# start of every sweep rather than cached at construction.
self.config = config if config is not None else {}
self._pending: Optional[_PendingQuery] = None
self._sweep_lock = asyncio.Lock()
self._response_timeout_override = 0.0
self._max_sweep_seconds = DEFAULT_MAX_SWEEP_SECONDS
self._duty_cycle_abort_seconds = DEFAULT_DUTY_CYCLE_ABORT_SECONDS
self._direct_tx_delay_factor = 0.5
self.refresh_config(self.config)
# ------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------
def refresh_config(self, config: Optional[dict] = None) -> None:
"""Re-read the tunables. Called at construction and before each sweep."""
if config is None:
config = self.config
else:
self.config = config
neighbors_cfg = neighbors_config_block(config)
self._response_timeout_override = _as_float(
neighbors_cfg.get("scope_response_timeout_seconds", 0), 0.0
)
self._max_sweep_seconds = max(
1.0, _as_float(neighbors_cfg.get("max_sweep_seconds"), DEFAULT_MAX_SWEEP_SECONDS)
)
self._duty_cycle_abort_seconds = max(
0.0,
_as_float(
neighbors_cfg.get("duty_cycle_abort_seconds"), DEFAULT_DUTY_CYCLE_ABORT_SECONDS
),
)
delays_cfg = config.get("delays", {}) if isinstance(config, dict) else {}
self._direct_tx_delay_factor = _as_float(
delays_cfg.get("direct_tx_delay_factor") if isinstance(delays_cfg, dict) else None,
0.5,
)
def response_timeout(self) -> float:
"""How long to wait for a reply once the request is on air.
Mirrors firmware ``neighborDiscoverQueryTimeoutMs()``: the responder's
fixed reply delay, plus however long it may defer the transmission, plus
airtime for one packet ahead of the response and the response itself.
The firmware term there is ``getCADFailMaxDuration()``; openhop's
equivalent deferral is the engine's random direct-TX window,
``[0, 5 * airtime * direct_tx_delay_factor]``.
"""
if self._response_timeout_override > 0:
return self._response_timeout_override
airtime_ms = self._estimate_response_airtime_ms()
if airtime_ms <= 0:
return FALLBACK_RESPONSE_TIMEOUT_SEC
deferral_ms = 5.0 * airtime_ms * max(0.0, self._direct_tx_delay_factor)
total_ms = (
SERVER_RESPONSE_DELAY_MS + deferral_ms + (2.0 * airtime_ms) + RESPONSE_TIMEOUT_SLACK_MS
)
return min(MAX_RESPONSE_TIMEOUT_SEC, max(MIN_RESPONSE_TIMEOUT_SEC, total_ms / 1000.0))
def _estimate_response_airtime_ms(self) -> float:
if not self.airtime_manager:
return 0.0
try:
# Worst-case response size, matching the firmware's
# getEstAirtimeFor(MAX_PACKET_PAYLOAD + 2).
return float(self.airtime_manager.calculate_airtime(MAX_PACKET_PAYLOAD + 2))
except Exception as e:
logger.debug(f"Could not estimate response airtime: {e}")
return 0.0
# ------------------------------------------------------------------
# Sweep
# ------------------------------------------------------------------
@property
def active(self) -> bool:
return self._sweep_lock.locked()
async def sweep(self, targets: List[NeighborSnapshot]) -> Dict[str, ScopeResult]:
"""Query every target's scopes, one request in flight at a time.
Returns a result per target keyed by lowercase pubkey hex. Targets that
are never reached (sweep budget or duty-cycle backlog) come back as
``timeout``, matching the firmware's treatment of unsent entries.
"""
results: Dict[str, ScopeResult] = {}
if not targets:
return results
if self._sweep_lock.locked():
raise RuntimeError("neighbor scope sweep already active")
async with self._sweep_lock:
# Pick up live config edits (the mesh CLI can change
# delays.direct_tx_delay_factor, which sizes the response window).
self.refresh_config(self.config)
deadline = time.monotonic() + self._max_sweep_seconds
timeout = self.response_timeout()
logger.info(
"Scope sweep starting: %d neighbour(s), %.1fs response window, %.0fs sweep budget",
len(targets),
timeout,
self._max_sweep_seconds,
)
abandoned = False
for target in targets:
if abandoned or time.monotonic() >= deadline:
if not abandoned:
logger.warning(
"Scope sweep budget exhausted; %s and later marked timeout",
target.pubkey[:8],
)
abandoned = True
results[target.pubkey] = ScopeResult(STATUS_TIMEOUT)
continue
result = await self._query_one(target, timeout)
results[target.pubkey] = result
if result.status == STATUS_SEND_FAILED and self._duty_cycle_backlogged():
# The airtime budget is gone; the rest of the sweep would sit
# behind forwarding traffic. Publish what resolved instead.
logger.warning("Scope sweep abandoned: duty-cycle budget exhausted")
abandoned = True
responded = sum(1 for r in results.values() if r.status == STATUS_RESPONDED)
logger.info("Scope sweep complete: %d/%d responded", responded, len(results))
return results
async def _query_one(self, target: NeighborSnapshot, timeout: float) -> ScopeResult:
if not self.packet_injector:
logger.warning("No packet injector available - cannot query neighbour scopes")
return ScopeResult(STATUS_SEND_FAILED)
try:
packet, tag = self._build_request(target)
except Exception as e:
logger.warning(f"Could not build scope request for {target.pubkey[:8]}: {e}")
return ScopeResult(STATUS_SEND_FAILED)
if not self._duty_cycle_allows(packet):
logger.warning(
"Skipping scope query for %s: duty-cycle budget exhausted", target.pubkey[:8]
)
return ScopeResult(STATUS_SEND_FAILED)
loop = asyncio.get_running_loop()
pending = _PendingQuery(
pubkey=target.pubkey,
tag=tag,
src_hash=target.pubkey_bytes[0],
future=loop.create_future(),
)
self._pending = pending
# One finally for the whole method: the injector await spends most of its
# wall time in the engine TX path, so a shutdown cancel lands there. A
# CancelledError escaping with _pending still set would leave a dead query
# matching (and hiding from the companion bridges) every later RESPONSE.
try:
try:
# Resolves only once the packet is actually on air (or has failed):
# the engine awaits dispatcher.send_packet under its TX lock and
# defers local TX until the duty cycle allows. This is the firmware's
# logTx / logTxFail boundary, which is where the response deadline is
# armed -- hence the wait_for below, and not a moment earlier.
sent = await self.packet_injector(packet, wait_for_ack=False)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(f"Scope request send failed for {target.pubkey[:8]}: {e}")
return ScopeResult(STATUS_SEND_FAILED)
if not sent:
logger.debug(f"Scope request not transmitted for {target.pubkey[:8]}")
return ScopeResult(STATUS_SEND_FAILED)
# Past this point the request is on air, so every outcome counts as
# queried regardless of whether the neighbour answers.
try:
scopes = await asyncio.wait_for(pending.future, timeout)
except asyncio.TimeoutError:
logger.debug(f"Scope query timed out for {target.pubkey[:8]}")
return ScopeResult(STATUS_TIMEOUT, transmitted=True)
logger.debug(f"Scope response from {target.pubkey[:8]}: '{scopes}'")
return ScopeResult(STATUS_RESPONDED, scopes, transmitted=True)
finally:
self._pending = None
def _build_request(self, target: NeighborSnapshot):
from openhop_core.protocol.packet_builder import PacketBuilder
# tag(4) is prepended by create_anon_request as the request timestamp;
# ANON_REQ_TYPE_REGIONS selects the scopes reply and 0x00 asks for a
# zero-hop reply path (firmware inner[4], inner[5]).
return PacketBuilder.create_anon_request(
_ScopeTarget(target.pubkey),
self.local_identity,
req_data=bytes([ANON_REQ_TYPE_REGIONS, 0x00]),
)
# ------------------------------------------------------------------
# Duty cycle
# ------------------------------------------------------------------
def _duty_cycle_allows(self, packet) -> bool:
"""Pre-flight the airtime budget.
Stands in for the firmware's ``getFreeCount() >= 5`` packet-pool guard:
different scarce resource, same job of refusing to enqueue a request the
transmit path cannot honour. A short backlog is fine the engine defers
local TX on its own so only a long one is treated as a failure.
"""
if not self.airtime_manager:
return True
try:
airtime_ms = self.airtime_manager.calculate_airtime(packet.get_raw_length())
can_tx, wait_time = self.airtime_manager.can_transmit(airtime_ms)
except Exception as e:
logger.debug(f"Duty-cycle pre-flight failed, allowing send: {e}")
return True
return can_tx or wait_time <= self._duty_cycle_abort_seconds
def _duty_cycle_backlogged(self) -> bool:
if not self.airtime_manager:
return False
try:
airtime_ms = self.airtime_manager.calculate_airtime(MAX_PACKET_PAYLOAD)
can_tx, wait_time = self.airtime_manager.can_transmit(airtime_ms)
except Exception:
return False
return not can_tx and wait_time > self._duty_cycle_abort_seconds
# ------------------------------------------------------------------
# Response matching
# ------------------------------------------------------------------
async def process_response_packet(self, packet) -> bool:
"""Consume a PAYLOAD_TYPE_RESPONSE that answers the in-flight query.
Returns True only when the packet decrypts under the pending neighbour's
shared secret *and* echoes its tag, so an unrelated response still falls
through to the companion bridges. Like firmware
``handleNeighborDiscoverResponse``, a reply that arrives after its entry
has timed out is not accepted.
"""
pending = self._pending
if pending is None or pending.future is None or pending.future.done():
return False
try:
if packet.get_payload_type() != PAYLOAD_TYPE_RESPONSE:
return False
payload = bytes(getattr(packet, "payload", b"") or b"")
if len(payload) < 3:
return False
our_hash = self.local_identity.get_public_key()[0]
if payload[0] != our_hash or payload[1] != pending.src_hash:
return False
peer = Identity(bytes.fromhex(pending.pubkey))
shared_secret = peer.calc_shared_secret(self.local_identity.get_private_key())
plaintext = CryptoUtils.mac_then_decrypt(shared_secret[:16], shared_secret, payload[2:])
if not plaintext or len(plaintext) < 8:
return False
# plaintext: tag(4) + responder clock(4) + comma-separated scope names
if int.from_bytes(plaintext[:4], "little") != pending.tag:
return False
# The responder builds this field as a C string and the block cipher
# zero-pads the tail, so stop at the first NUL exactly as the firmware
# reader does -- rstrip alone would let "DEN\x00junk" through.
raw_scopes = bytes(plaintext[8:]).split(b"\x00", 1)[0]
scopes = raw_scopes.decode("utf-8", errors="replace").strip()
if not pending.future.done():
pending.future.set_result(scopes)
return True
except Exception as e:
logger.debug(f"Error matching scope response: {e}")
return False
def _as_float(value, default: float) -> float:
try:
if value is None:
return default
return float(value)
except (TypeError, ValueError):
return default
+40
View File
@@ -31,6 +31,7 @@ from repeater.handler_helpers import (
AdvertHelper,
DiscoveryHelper,
LoginHelper,
NeighborScopeHelper,
PathHelper,
ProtocolRequestHelper,
TextHelper,
@@ -38,6 +39,7 @@ from repeater.handler_helpers import (
)
from repeater.identity_manager import IdentityConfigurationError, IdentityManager, IdentitySpec
from repeater.logging_utils import normalize_log_level
from repeater.neighbors_publisher import NeighborsPublisher
from repeater.packet_router import PacketRouter
from repeater.region_map_builder import build_region_map
from repeater.sensors import SensorManager
@@ -99,6 +101,8 @@ class RepeaterDaemon:
self.trace_helper = None
self.advert_helper = None
self.discovery_helper = None
self.neighbor_scope_helper = None
self.neighbors_publisher = None
self.login_helper = None
self.text_helper = None
self.path_helper = None
@@ -654,6 +658,35 @@ class RepeaterDaemon:
# When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)
self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions
# Neighbour scope discovery + periodic MQTT neighbors publication.
# Created unconditionally and self-gating: the publisher idles unless
# the master switch is on and some broker opted in with neighbors:true.
self.neighbor_scope_helper = NeighborScopeHelper(
local_identity=self.local_identity,
packet_injector=self.router.inject_packet,
airtime_manager=(
getattr(self.repeater_handler, "airtime_mgr", None)
if self.repeater_handler
else None
),
config=self.config,
)
self.neighbors_publisher = NeighborsPublisher(
config=self.config,
local_identity=self.local_identity,
discovery_helper=self.discovery_helper,
scope_helper=self.neighbor_scope_helper,
mqtt_handler_provider=lambda: getattr(
getattr(self.repeater_handler, "storage", None), "mqtt_handler", None
),
storage_provider=lambda: getattr(self.repeater_handler, "storage", None),
self_scopes_fn=(
self.login_helper._format_region_names if self.login_helper else None
),
)
self.neighbors_publisher.start()
logger.info("Neighbors publisher initialized")
# Optional pyMC_Glass integration loop (inform/control plane)
self.glass_handler = GlassHandler(
config=self.config,
@@ -1632,6 +1665,13 @@ class RepeaterDaemon:
"http server", asyncio.to_thread(self.http_server.stop), timeout=3
)
# Stop the neighbours publication loop.
if self.neighbors_publisher:
try:
await self.neighbors_publisher.stop()
except Exception as e:
logger.warning(f"Error stopping neighbors publisher: {e}")
# Stop Glass inform loop
if self.glass_handler:
await self._shutdown_step("glass handler", self.glass_handler.stop())
+953
View File
@@ -0,0 +1,953 @@
"""Periodic neighbours publication for the MQTT ``neighbors`` topic.
Python port of the firmware's two-stage neighbours cycle
(MeshCore ``simple_repeater/MyMesh.cpp``, ``WITH_MQTT_NEIGHBORS``):
* **Stage 1** a zero-hop node-discovery broadcast refreshes the neighbour table.
Firmware calls ``sendNodeDiscoverReq()`` and waits out its collection window;
here that is a :class:`~repeater.handler_helpers.discovery.DiscoveryHelper`
session, whose results are persisted by the same enricher the ``discover.neighbors``
CLI command uses.
* **Stage 2** one anon-regions scope query per neighbour, issued serially by
:class:`~repeater.handler_helpers.neighbor_scopes.NeighborScopeHelper`.
* **Publish** the assembled table goes to every enabled broker that opted in
with ``neighbors: true``.
The published table is the whole zero-hop neighbour snapshot, including entries
that did not answer the scope query (``timeout`` / ``send_failed``), matching the
firmware payload. Unlike the firmware there is no 10 KB cap: the ESP32 needed a
fixed PSRAM buffer, a Linux host does not.
"""
import asyncio
import logging
import time
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
from repeater.handler_helpers.discovery import persist_discovery_result
from repeater.handler_helpers.neighbor_scopes import (
STATUS_RESPONDED,
STATUS_SEND_FAILED,
STATUS_TIMEOUT,
NeighborSnapshot,
ScopeResult,
neighbors_config_block,
)
logger = logging.getLogger("NeighborsPublisher")
# Firmware parity: mqtt.neighbors.interval accepts 12-336 hours, default 24, and
# rejects out-of-range values rather than clamping them.
MIN_INTERVAL_HOURS = 12
MAX_INTERVAL_HOURS = 336
DEFAULT_INTERVAL_HOURS = 24
# Firmware waits out the 60 s node-discover collection window before querying scopes.
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 60.0
# Firmware bounds the table by MAX_NEIGHBOURS; this is the openhop equivalent so a
# large advert history cannot turn into an unbounded sweep.
DEFAULT_MAX_NEIGHBORS = 32
# Zero-hop rows older than this are treated as gone and left out of the pass.
DEFAULT_MAX_NEIGHBOR_AGE_SECONDS = 86400.0
# How often the loop wakes to re-evaluate its schedule.
_TICK_SECONDS = 30.0
# Delay before retrying a cycle that failed or published nothing, instead of
# waiting out the full interval.
RETRY_DELAY_SECONDS = 900.0
# How an unset default region is spelled in the payload. Matches the wildcard
# LoginHelper._format_region_names already emits for unscoped flood.
DEFAULT_SCOPE_WILDCARD = "*"
# daemon_state key holding the persisted schedule.
STATE_KEY = "neighbors_publisher"
# A restored schedule never fires inside this window after boot, even when it is
# already overdue. Keeps a restart quiet and lets the radio and brokers settle
# before a cycle claims the airtime. Firmware has no equivalent -- it treats
# every boot as immediately due.
STARTUP_GRACE_SECONDS = 300.0
# Phases reported by status(), mirroring the firmware's NeighborsPhase.
PHASE_DISABLED = "disabled"
PHASE_SCHEDULED = "scheduled"
PHASE_ACTIVE = "active"
PHASE_DUE = "due"
def build_neighbors_payload(
*,
origin: str,
origin_id: str,
self_scopes: str,
entries: List[dict],
timestamp: Optional[str] = None,
total_neighbors: Optional[int] = None,
queried_neighbors: Optional[int] = None,
self_default_scope: str = DEFAULT_SCOPE_WILDCARD,
) -> dict:
"""Assemble the ``neighbors`` topic payload.
``entries`` are ordered most- to least-useful (most recently heard first, then
stronger SNR, then pubkey) exactly as the firmware orders them. The firmware
needs that order so it can drop the tail when its fixed buffer fills; we keep
it because it is the documented shape of the topic and it puts the useful rows
first for consumers.
``self`` carries this node's own advertised scopes plus ``default_scope``, the
region it stamps on outgoing floods (``*`` when it floods unscoped). The
firmware tracks a ``default_scope`` internally but does not publish it; it is
included here because a consumer reading the table cannot otherwise tell which
of several scopes this node actually transmits under.
Two progress counters mirror firmware ``buildNeighborsMessage``:
* ``total_neighbors`` neighbours in this cycle's table, which is also how
many rows ``neighbors`` carries.
* ``queried_neighbors`` how many scope requests reached the air.
The firmware's third field, ``truncated``, is deliberately not emitted: it
reports that a fixed PSRAM JSON buffer filled and the tail was dropped, and
openhop has no such buffer, so it could only ever be false. That also keeps
``total_neighbors`` equal to the published row count here, where firmware
allows it to run ahead. Both are emitted only when the caller supplies the
counts, matching the firmware's ``total_neighbors >= 0`` guard.
"""
ordered = sorted(
entries,
key=lambda e: (e.get("heard_secs_ago", 0), -float(e.get("snr", 0.0)), e.get("pubkey", "")),
)
payload = {
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
"origin": origin,
"origin_id": origin_id,
}
# Key order matches the firmware writer so the two payloads diff cleanly.
if total_neighbors is not None:
payload["total_neighbors"] = int(total_neighbors)
payload["queried_neighbors"] = int(
queried_neighbors if queried_neighbors is not None else total_neighbors
)
payload["self"] = {
"scopes": self_scopes or "",
"default_scope": self_default_scope or DEFAULT_SCOPE_WILDCARD,
}
payload["neighbors"] = ordered
return payload
class NeighborsPublisher:
"""Owns the periodic neighbours cycle and its manual trigger."""
def __init__(
self,
config: dict,
*,
local_identity=None,
discovery_helper=None,
scope_helper=None,
mqtt_handler_provider: Optional[Callable[[], Any]] = None,
storage_provider: Optional[Callable[[], Any]] = None,
self_scopes_fn: Optional[Callable[[], str]] = None,
):
self.config = config
self.local_identity = local_identity
self.discovery_helper = discovery_helper
self.scope_helper = scope_helper
self._mqtt_handler_provider = mqtt_handler_provider
self._storage_provider = storage_provider
self._self_scopes_fn = self_scopes_fn
self._task: Optional[asyncio.Task] = None
self._manual_task: Optional[asyncio.Task] = None
self._running = False
self._active = False
# Single-neighbour queries running outside a cycle. A cycle must not start
# on top of one: it would reach the sweep and find the helper's lock held,
# which raises and costs the whole cycle (including its discovery
# broadcast). Tracked as a count, and as tasks so shutdown can cancel them.
self._queries_in_flight = 0
self._query_tasks: set = set()
# Discovery responses collected during the current cycle, keyed by pubkey.
self._discovery_seen: Dict[str, dict] = {}
self._next_publish_at: Optional[float] = None
self._last_result: Optional[str] = None
# When the last cycle finished, whatever its outcome -- this is what
# status() reports alongside last_result.
self._last_publish_at: Optional[float] = None
# When a cycle last actually reached a broker. The schedule is measured
# from this, not from the above: a cycle that failed to publish reschedules
# on the short retry delay, and restoring from a failed attempt would
# silently turn that retry into a full interval.
self._last_success_at: Optional[float] = None
# Whether the feature has been enabled at any point in this process. The
# disabled branch of _tick clears the schedule so re-enabling publishes
# promptly, which must not fire before we have ever been enabled -- that
# would throw away a schedule just restored from disk.
self._was_enabled = False
# ------------------------------------------------------------------
# Config accessors (re-read every cycle so live edits take effect)
# ------------------------------------------------------------------
@property
def _neighbors_config(self) -> dict:
return neighbors_config_block(self.config)
@property
def master_enabled(self) -> bool:
"""Feature kill switch. Defaults on; the per-broker flags are the control."""
return bool(self._neighbors_config.get("enabled", True))
@property
def interval_seconds(self) -> float:
hours = self._neighbors_config.get("interval_hours", DEFAULT_INTERVAL_HOURS)
return normalize_interval_hours(hours) * 3600.0
@property
def discovery_timeout(self) -> float:
try:
value = float(
self._neighbors_config.get(
"discovery_timeout_seconds", DEFAULT_DISCOVERY_TIMEOUT_SECONDS
)
)
except (TypeError, ValueError):
return DEFAULT_DISCOVERY_TIMEOUT_SECONDS
return max(1.0, value)
@property
def max_neighbors(self) -> int:
try:
value = int(self._neighbors_config.get("max_neighbors", DEFAULT_MAX_NEIGHBORS))
except (TypeError, ValueError):
return DEFAULT_MAX_NEIGHBORS
return max(1, value)
@property
def max_neighbor_age(self) -> float:
try:
value = float(
self._neighbors_config.get(
"max_neighbor_age_seconds", DEFAULT_MAX_NEIGHBOR_AGE_SECONDS
)
)
except (TypeError, ValueError):
return DEFAULT_MAX_NEIGHBOR_AGE_SECONDS
return max(1.0, value)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
if self._task is not None and not self._task.done():
return
# Say so once, loudly, rather than from the config accessors: those run
# several times per tick and would bury the log.
raw_block = (self.config.get("mqtt_brokers") or {}) if isinstance(self.config, dict) else {}
if isinstance(raw_block, dict) and not isinstance(raw_block.get("neighbors", {}), dict):
logger.warning(
"mqtt_brokers.neighbors is %s, not a settings block - using defaults. "
"The per-broker 'neighbors: true' flag is what opts a broker in.",
type(raw_block.get("neighbors")).__name__,
)
self._restore_schedule()
self._running = True
self._task = asyncio.create_task(self._run_loop(), name="neighbors-publisher")
logger.info("Neighbors publisher started (interval %.1fh)", self.interval_seconds / 3600.0)
# ------------------------------------------------------------------
# Schedule persistence
# ------------------------------------------------------------------
def _restore_schedule(self) -> None:
"""Resume the schedule from the last publish instead of restarting it.
Without this a restart leaves ``_next_publish_at`` unset, which reads as
"due" and spends a discovery broadcast plus a serialized scope query per
neighbour on every boot. ``_next_publish_at`` is monotonic and so cannot
be stored directly; the persisted value is the wall-clock publish time,
converted back to a monotonic deadline here.
"""
storage = self._storage()
reader = getattr(storage, "get_daemon_state", None) if storage else None
if not callable(reader):
# Older storage backend, or none wired up: behave as before.
self._next_publish_at = time.monotonic() + STARTUP_GRACE_SECONDS
return
state = reader(STATE_KEY) or {}
last = _as_epoch(state.get("last_success_at"))
# Restore the display fields too, so a restart does not report the node as
# having never run.
self._last_result = state.get("last_result") or None
self._last_publish_at = _as_epoch(state.get("last_publish_at")) or None
interval = self.interval_seconds
now = time.time()
if last <= 0 or last > now:
# No successful publish on record, or a timestamp from the future --
# the clock moved backwards, or the row is junk. Either way it cannot
# place the next cycle, so fall back to the grace delay.
if last > now:
logger.warning(
"Persisted neighbours publish time is %.0fs in the future; ignoring it",
last - now,
)
self._next_publish_at = time.monotonic() + STARTUP_GRACE_SECONDS
return
self._last_success_at = last
# Never sooner than the grace window, never later than a full interval
# from now -- the latter bounds the damage from an interval that shrank
# since the last publish.
delay = min(max((last + interval) - now, STARTUP_GRACE_SECONDS), interval)
self._next_publish_at = time.monotonic() + delay
logger.info(
"Neighbours schedule resumed: last published %.1fh ago, next in %.1fh",
(now - last) / 3600.0,
delay / 3600.0,
)
def _persist_schedule(self) -> None:
storage = self._storage()
writer = getattr(storage, "set_daemon_state", None) if storage else None
if not callable(writer):
return
writer(
STATE_KEY,
{
"last_success_at": self._last_success_at,
"last_publish_at": self._last_publish_at,
"last_result": self._last_result,
},
)
def trigger_cycle(self) -> bool:
"""Start a manual cycle, tracked so shutdown can cancel it.
A cycle runs for minutes (a discovery window plus one serialized scope
query per neighbour), so an untracked task would keep transmitting while
the daemon tears down. Returns False when a cycle is already running.
"""
if self._active or (self._manual_task is not None and not self._manual_task.done()):
return False
if self._queries_in_flight:
return False
self._manual_task = asyncio.create_task(
self.run_cycle(trigger="manual"), name="neighbors-manual-cycle"
)
self._manual_task.add_done_callback(self._on_manual_task_done)
return True
def _on_manual_task_done(self, task: asyncio.Task) -> None:
if self._manual_task is task:
self._manual_task = None
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Manual neighbors cycle failed: {e}", exc_info=True)
async def stop(self) -> None:
self._running = False
tasks = [
t
for t in (self._task, self._manual_task, *self._query_tasks)
if t and not t.done() and t is not asyncio.current_task()
]
self._task = None
self._manual_task = None
self._query_tasks.clear()
for task in tasks:
task.cancel()
for task in tasks:
try:
await task
except asyncio.CancelledError:
pass
except Exception as e:
logger.debug(f"Neighbors publisher shutdown error ignored: {e}")
logger.info("Neighbors publisher stopped")
def status(self) -> dict:
if not self.enabled():
phase = PHASE_DISABLED
elif self._active:
phase = PHASE_ACTIVE
elif self._next_publish_at is None or time.monotonic() >= self._next_publish_at:
phase = PHASE_DUE
else:
phase = PHASE_SCHEDULED
secs_until_next = None
if phase == PHASE_SCHEDULED and self._next_publish_at is not None:
secs_until_next = max(0, int(self._next_publish_at - time.monotonic()))
return {
"phase": phase,
"secs_until_next": secs_until_next,
"last_result": self._last_result,
"last_publish_at": self._last_publish_at,
"interval_hours": self.interval_seconds / 3600.0,
}
def enabled(self) -> bool:
"""True when the master switch is on and some broker opted in."""
if not self.master_enabled:
return False
handler = self._mqtt_handler()
return bool(handler and handler.has_neighbors_brokers())
def _mqtt_handler(self):
if not self._mqtt_handler_provider:
return None
try:
return self._mqtt_handler_provider()
except Exception as e:
logger.debug(f"MQTT handler unavailable: {e}")
return None
def _storage(self):
if not self._storage_provider:
return None
try:
return self._storage_provider()
except Exception as e:
logger.debug(f"Storage unavailable: {e}")
return None
def _local_pubkey_hex(self) -> str:
if not self.local_identity:
return ""
try:
return self.local_identity.get_public_key().hex().lower()
except Exception:
return ""
# ------------------------------------------------------------------
# Loop
# ------------------------------------------------------------------
async def _run_loop(self) -> None:
try:
while self._running:
try:
await self._tick()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Neighbors publisher cycle failed: {e}", exc_info=True)
self._last_result = f"error: {e}"
self._reschedule(retry=True)
await asyncio.sleep(_TICK_SECONDS)
except asyncio.CancelledError:
logger.debug("Neighbors publisher loop cancelled")
raise
async def _tick(self) -> None:
if not self.enabled():
# Drop the schedule so re-enabling runs a pass promptly, matching the
# firmware's next_neighbors_publish = 0 reset. Only once the feature
# has actually been on in this process, though: at boot the MQTT
# handler may not have its connections up yet, and clearing here would
# discard the schedule just restored from disk and re-run the sweep
# anyway -- the exact thing persistence exists to prevent.
if self._was_enabled:
self._next_publish_at = None
return
self._was_enabled = True
handler = self._mqtt_handler()
if not handler or not handler.has_connected_neighbors_brokers():
logger.debug("Neighbors publish deferred: no connected opted-in broker")
return
now = time.monotonic()
if self._next_publish_at is not None and now < self._next_publish_at:
return
if self._queries_in_flight:
# Deferred, not rescheduled: the next tick is 30 s away and a single
# query is far shorter than that, so the cycle simply runs then.
logger.debug("Neighbors cycle deferred: a manual scope query is in flight")
return
await self.run_cycle(trigger="periodic")
def _reschedule(self, *, retry: bool = False) -> None:
"""Arm the next cycle.
A cycle that produced no publish retries on a short delay instead of
burning the whole interval: the usual cause is a broker that was briefly
unreachable or rejected the payload, and waiting a day to find out
otherwise is not useful.
"""
delay = min(RETRY_DELAY_SECONDS, self.interval_seconds) if retry else self.interval_seconds
self._next_publish_at = time.monotonic() + delay
# ------------------------------------------------------------------
# Cycle
# ------------------------------------------------------------------
async def run_cycle(self, trigger: str = "manual") -> dict:
"""Run one full discovery + scopes + publish cycle."""
if self._active:
return {"success": False, "error": "neighbors cycle already active"}
self._active = True
started = time.monotonic()
self._discovery_seen = {}
published = False
try:
await self._refresh_neighbor_table()
targets = self._snapshot_neighbors()
scope_results: Dict[str, ScopeResult] = {}
if targets and self.scope_helper:
try:
scope_results = await self.scope_helper.sweep(targets)
except RuntimeError as e:
# A manual query took the helper between the checks in _tick /
# trigger_cycle and here -- the discovery window above leaves a
# wide gap for that. Give up on this pass rather than publish a
# table with every scope missing; the finally below reschedules
# on the short retry delay.
logger.warning("Neighbors cycle abandoned: %s", e)
self._last_result = f"deferred: {e}"
return {"success": False, "error": str(e)}
self._persist_scope_results(scope_results)
elif targets:
logger.warning("No scope helper available; publishing without scopes")
payload = self._build_payload(targets, scope_results)
published = self._publish(payload)
responded = sum(1 for r in scope_results.values() if r.status == STATUS_RESPONDED)
self._last_result = (
f"ok ({len(targets)} neighbours, {responded} with scopes)"
if published
else "publish failed (broker unreachable or rejected the payload)"
)
self._last_publish_at = time.time()
if published:
self._last_success_at = self._last_publish_at
self._persist_schedule()
logger.info(
"Neighbors %s cycle finished in %.1fs: %d neighbour(s), %d with scopes, "
"published=%s",
trigger,
time.monotonic() - started,
len(targets),
responded,
published,
)
return {
"success": True,
"neighbors": len(targets),
"responded": responded,
"published": published,
}
finally:
self._active = False
self._reschedule(retry=not published)
@staticmethod
def _was_asked(result: ScopeResult) -> bool:
"""Whether this outcome represents a query the node actually attempted.
``timeout`` is ambiguous on its own: the sweep reports it both for a
neighbour that was asked and stayed silent and for one it never reached
(budget exhausted), so ``transmitted`` is what separates those. A
``send_failed`` was attempted and refused by the transmit path -- the
duty-cycle pre-flight -- which is worth recording even though nothing went
on air, otherwise a repeater that keeps refusing reads as "never queried"
forever.
"""
return result.transmitted or result.status == STATUS_SEND_FAILED
def _persist_scope_results(
self, results: Dict[str, ScopeResult], now: Optional[float] = None
) -> None:
"""Store what a sweep learned, so it outlives the MQTT publish.
The payload used to be the only consumer, which left the web UI with
nothing to show between cycles. One row per neighbour the node actually
asked (see :meth:`_was_asked`); targets the sweep never reached are left
untouched rather than credited with a query that never happened.
"""
storage = self._storage()
writer = getattr(storage, "record_neighbor_scope", None) if storage else None
if not callable(writer):
return
stamp = time.time() if now is None else now
for pubkey, result in (results or {}).items():
if not self._was_asked(result):
continue
try:
# scopes is passed only for an answer, so a failed query keeps the
# last known value instead of blanking it.
writer(
pubkey,
result.status,
result.scopes if result.status == STATUS_RESPONDED else None,
stamp,
)
except Exception as e:
logger.debug(f"Could not persist scopes for {pubkey[:8]}: {e}")
async def query_one(self, pubkey: str) -> dict:
"""Query a single neighbour's scopes now, outside the periodic cycle.
Deliberately independent of ``enabled()``: the answer is stored for the
web UI, so this is useful on a repeater that publishes to no broker at
all. Nothing is published as a result -- the periodic cycle owns that.
Raises ``ValueError`` for a key that cannot be queried and ``RuntimeError``
when a cycle or another query already holds the scope helper; the caller
turns both into a message. The cycle check is on ``_active`` rather than on
the helper's lock because a cycle spends its first minute in the discovery
window without holding that lock, and colliding later -- once the sweep has
taken it -- would cost the whole cycle.
"""
key = str(pubkey or "").strip().lower()
if len(key) != 64:
# ECDH against the responder needs the full 32-byte key; a prefix
# (which is all an advert-only sighting may carry) cannot be used.
raise ValueError("A full 64-character public key is required")
try:
bytes.fromhex(key)
except ValueError:
raise ValueError("Public key is not valid hex") from None
if key == self._local_pubkey_hex():
raise ValueError("Cannot query this repeater's own scopes")
if not self.scope_helper:
raise RuntimeError("Scope helper not available")
if self._active:
# A cycle owns the helper for its whole run, including the discovery
# window before the sweep takes the lock. Refusing here is the mirror
# of the sweep-side guard below and keeps the two from colliding.
raise RuntimeError("A neighbours cycle is running - try again once it finishes")
snapshot = self._snapshot_for(key)
self._queries_in_flight += 1
task = asyncio.current_task()
if task is not None:
# Tracked for the same reason trigger_cycle tracks its task: this holds
# the radio for up to a response window, and shutdown has to be able to
# cut it short rather than transmit through the teardown.
self._query_tasks.add(task)
try:
try:
results = await self.scope_helper.sweep([snapshot])
except RuntimeError:
# The helper's own wording names its internals; say what the
# operator can act on instead.
raise RuntimeError(
"A neighbour scope sweep is already running - try again once it finishes"
) from None
now = time.time()
self._persist_scope_results(results, now=now)
result = results.get(key) or ScopeResult(STATUS_TIMEOUT)
return self._scope_record(key, result, now)
finally:
self._queries_in_flight = max(0, self._queries_in_flight - 1)
if task is not None:
self._query_tasks.discard(task)
def _scope_record(self, pubkey: str, result: ScopeResult, now: float) -> dict:
"""The stored view of one query's outcome, as the API returns it.
Read back through the stored row rather than reported straight from
``result``: a failed query deliberately keeps the neighbour's last known
scopes, so returning this query's empty string would tell the client to
forget an answer the database still holds.
"""
responded = result.status == STATUS_RESPONDED
record = {
"pubkey": pubkey,
"status": result.status,
"scopes": result.scopes if responded else "",
"transmitted": result.transmitted,
"queried_at": now if self._was_asked(result) else None,
"responded_at": now if responded else None,
}
if responded:
return record
storage = self._storage()
reader = getattr(storage, "get_neighbor_scopes", None) if storage else None
if not callable(reader):
return record
try:
stored = (reader() or {}).get(pubkey) or {}
except Exception as e:
logger.debug(f"Could not re-read stored scopes for {pubkey[:8]}: {e}")
return record
if stored.get("responded_at") is not None:
record["scopes"] = stored.get("scopes") or ""
record["responded_at"] = stored.get("responded_at")
return record
def _snapshot_for(self, pubkey: str) -> NeighborSnapshot:
"""Build a one-target snapshot, borrowing last_seen/snr when we have them.
Neither field affects the query -- they are carried so a single query goes
through exactly the same path as a sweep entry.
"""
storage = self._storage()
if storage:
try:
# The adverts table does not normalise key case, so match on the
# lowercased form rather than indexing directly.
for candidate, info in (storage.get_neighbors() or {}).items():
if str(candidate or "").lower() != pubkey:
continue
return NeighborSnapshot(
pubkey=pubkey,
last_seen=float(info.get("last_seen") or 0.0),
snr=float(info.get("snr") or 0.0),
)
except Exception as e:
logger.debug(f"Could not read neighbour row for {pubkey[:8]}: {e}")
return NeighborSnapshot(pubkey=pubkey)
async def _refresh_neighbor_table(self) -> None:
"""Stage 1: zero-hop node discovery, awaited to completion.
``prefix_only=False`` is required, not cosmetic: the scope query needs the
neighbour's full 32-byte public key to derive a shared secret.
"""
if not self.discovery_helper:
logger.debug("No discovery helper; using the stored neighbour table as-is")
return
try:
self.discovery_helper.cleanup_sessions()
session = self.discovery_helper.create_session(
timeout=self.discovery_timeout,
filter_mask=(1 << 2), # repeaters
since=0,
prefix_only=False,
result_enricher=self._enrich_discovery_result,
)
await self.discovery_helper.execute_session(session["session_id"])
except Exception as e:
logger.warning(f"Neighbour table refresh failed, using stored table: {e}")
def _enrich_discovery_result(self, result: dict) -> dict:
"""Record each discovery response for this cycle, and persist it.
This is what makes stage 1 matter: without it the snapshot would report
whatever ``last_seen``/``snr`` the advert history happened to hold, rather
than what answered just now. Mirrors firmware ``putNeighbour()`` on a
discovery response.
The in-memory copy is not redundant with the write. ``get_neighbors()``
serves a 60 s cache that ``store_advert()`` does not invalidate, so a
neighbour that answered seconds ago can be missing from, or stale in, the
table read that follows. The snapshot merges this dict over that read.
"""
pubkey = str(result.get("pub_key") or "").strip().lower()
if not pubkey or len(pubkey) != 64:
return result
local_pubkey = self._local_pubkey_hex()
if local_pubkey and pubkey == local_pubkey:
return result
node_type_raw = int(result.get("node_type", 0) or 0)
snr_raw = result.get("response_snr", result.get("snr"))
if node_type_raw == 2:
self._discovery_seen[pubkey] = {
"last_seen": time.time(),
"snr": float(snr_raw) if snr_raw is not None else 0.0,
}
persist_discovery_result(self._storage(), result)
return result
def _snapshot_neighbors(self) -> List[NeighborSnapshot]:
"""Freeze the zero-hop repeater table for this pass.
Taken once, up front: the sweep can run for minutes and the table keeps
changing underneath it (firmware hit the same problem and stopped indexing
into its live ``neighbours[]`` in ``aba571ed``).
Sources are the stored zero-hop repeater table (which also carries
neighbours heard via advert but silent during discovery, as the firmware's
table does) merged with this cycle's discovery responses, which win on
``last_seen``/``snr`` because they are first-hand and the table read is
served from a cache the advert write does not invalidate.
"""
storage = self._storage()
neighbors = {}
if storage:
try:
neighbors = storage.get_neighbors() or {}
except Exception as e:
logger.warning(f"Could not read neighbour table: {e}")
local_pubkey = self._local_pubkey_hex()
now = time.time()
max_age = self.max_neighbor_age
merged: Dict[str, dict] = {}
for pubkey, info in neighbors.items():
if not info.get("is_repeater") or not info.get("zero_hop"):
continue
key = str(pubkey or "").lower()
if len(key) != 64:
# Scope queries need the full key for ECDH; a prefix cannot be used.
continue
last_seen = float(info.get("last_seen") or 0.0)
if last_seen <= 0 or (now - last_seen) > max_age:
continue
merged[key] = {"last_seen": last_seen, "snr": float(info.get("snr") or 0.0)}
for key, seen in (self._discovery_seen or {}).items():
merged[key] = dict(seen)
snapshots = [
NeighborSnapshot(pubkey=key, last_seen=info["last_seen"], snr=info["snr"])
for key, info in merged.items()
if not (local_pubkey and key == local_pubkey)
]
# Freshest first, then strongest, then pubkey for a deterministic order --
# the same ordering the firmware applies before it starts querying.
snapshots.sort(key=lambda s: (-s.last_seen, -s.snr, s.pubkey))
if len(snapshots) > self.max_neighbors:
# Only logged, not published: the payload reports the capped table, so
# the dropped rows would otherwise be invisible here.
logger.info(
"Neighbour table has %d entries; querying the freshest %d",
len(snapshots),
self.max_neighbors,
)
snapshots = snapshots[: self.max_neighbors]
return snapshots
def _build_payload(
self,
targets: List[NeighborSnapshot],
scope_results: Dict[str, ScopeResult],
) -> dict:
now = time.time()
entries = []
queried = 0
for target in targets:
result = scope_results.get(target.pubkey) or ScopeResult(STATUS_TIMEOUT)
if result.transmitted:
queried += 1
heard_secs_ago = int(max(0.0, now - target.last_seen)) if target.last_seen else 0
entries.append(
{
"pubkey": target.pubkey,
"snr": round(target.snr, 2),
"heard_secs_ago": heard_secs_ago,
"scopes": result.scopes or "",
"status": result.status,
}
)
handler = self._mqtt_handler()
origin = getattr(handler, "node_name", "") if handler else ""
origin_id = getattr(handler, "public_key", "") if handler else ""
if not origin:
origin = self.config.get("repeater", {}).get("node_name", "openHop-Repeater")
if not origin_id and self.local_identity:
try:
origin_id = self.local_identity.get_public_key().hex().upper()
except Exception:
origin_id = ""
return build_neighbors_payload(
origin=origin,
origin_id=origin_id,
self_scopes=self._self_scopes(),
self_default_scope=self._self_default_scope(),
entries=entries,
total_neighbors=len(entries),
queried_neighbors=queried,
)
def _self_default_scope(self) -> str:
"""The region this node stamps on outgoing floods, or ``*`` when unset.
Read from live config on every cycle because ``region default <name>`` over
the mesh CLI writes straight into ``config["mesh"]`` (the same dict this
holds) and expects to take effect without a restart.
Normalised like the ``scopes`` field beside it: the transport-key table
stores region names with a leading ``#``, which is not part of the name a
consumer matches on, so it is stripped here as
``LoginHelper._format_region_names`` strips it there.
"""
mesh_cfg = self.config.get("mesh", {}) if isinstance(self.config, dict) else {}
if not isinstance(mesh_cfg, dict):
return DEFAULT_SCOPE_WILDCARD
raw = mesh_cfg.get("default_region")
name = str(raw).strip() if raw not in (None, "") else ""
if name.startswith("#"):
name = name[1:].strip()
return name or DEFAULT_SCOPE_WILDCARD
def _self_scopes(self) -> str:
if not self._self_scopes_fn:
return ""
try:
return self._self_scopes_fn() or ""
except Exception as e:
logger.debug(f"Could not read local scopes: {e}")
return ""
def _publish(self, payload: dict) -> bool:
handler = self._mqtt_handler()
if not handler:
return False
try:
results = handler.publish_neighbors(payload)
except Exception as e:
logger.error(f"Neighbors publish failed: {e}")
return False
return bool(results)
def _as_epoch(value) -> float:
"""Coerce a persisted timestamp to a float, or 0.0 when it is unusable."""
try:
if value is None:
return 0.0
return float(value)
except (TypeError, ValueError):
return 0.0
def normalize_interval_hours(value) -> float:
"""Validate an interval in hours, falling back to the default when invalid.
Firmware rejects out-of-range values instead of clamping them; the API layer
surfaces that rejection to the user, and this fallback keeps a hand-edited
config.yaml from producing a nonsense schedule.
"""
try:
hours = float(value)
except (TypeError, ValueError):
return float(DEFAULT_INTERVAL_HOURS)
if hours < MIN_INTERVAL_HOURS or hours > MAX_INTERVAL_HOURS:
logger.warning(
"mqtt_brokers.neighbors.interval_hours=%s outside %d-%d; using %d",
value,
MIN_INTERVAL_HOURS,
MAX_INTERVAL_HOURS,
DEFAULT_INTERVAL_HOURS,
)
return float(DEFAULT_INTERVAL_HOURS)
return hours
+22 -1
View File
@@ -713,7 +713,28 @@ class PacketRouter:
# to first hop instead of original requester).
consumed = False
dest_hash = packet.payload[0] if packet.payload and len(packet.payload) >= 1 else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
# A neighbour's answer to our own anon-regions scope query is addressed
# to this repeater's identity, and no companion bridge owns it. Offer it
# to the scope helper first; it consumes the packet only when the
# ciphertext authenticates under the pending neighbour's shared secret
# AND echoes that query's tag, so an unrelated RESPONSE still falls
# through to the companion paths below.
scope_helper = getattr(self.daemon, "neighbor_scope_helper", None)
scope_consumed = False
if scope_helper is not None:
try:
scope_consumed = await scope_helper.process_response_packet(packet)
except Exception as e:
logger.debug(f"Neighbor scope response matching failed: {e}")
if scope_consumed:
packet.mark_do_not_retransmit()
processed_by_injection = True
self._record_for_ui(packet, metadata)
companion_bridges = {}
else:
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
local_hash = getattr(self.daemon, "local_hash", None)
if dest_hash is not None and dest_hash in companion_bridges:
_, consumed = await self._fan_out_to_bridges(
+344
View File
@@ -191,6 +191,14 @@ POLICY_GROUP_KINDS = {
class APIEndpoints:
# How long /api/query_neighbor_scopes holds a request open. The scope helper's
# response window is normally its 5 s floor; a slow radio config (SF12) or a
# duty-cycle deferral can push a single query past this, in which case the
# query is left running so its answer still reaches the stored table and the
# client is told to re-read it. Chosen to stay inside a default reverse-proxy
# read timeout rather than to cover the helper's 120 s ceiling.
SCOPE_QUERY_HTTP_TIMEOUT = 45.0
def __init__(
self,
stats_getter: Optional[Callable] = None,
@@ -2258,13 +2266,25 @@ class APIEndpoints:
"reconnecting": conn.has_pending_reconnect(),
},
"format": conn.format,
"neighbors": getattr(conn, "neighbors_enabled", False),
}
)
# Schedule summary for the neighbors topic, the openhop equivalent of
# the firmware's trailing "nbr: <next>/<last>" in `get mqtt.status`.
neighbors_status = None
publisher = getattr(self.daemon_instance, "neighbors_publisher", None)
if publisher:
try:
neighbors_status = publisher.status()
except Exception as exc:
logger.debug(f"mqtt_status could not read neighbors publisher: {exc}")
return self._success(
{
"handler_active": handler is not None,
"brokers": connected_brokers,
"neighbors": neighbors_status,
}
)
except Exception as e:
@@ -2325,6 +2345,294 @@ class APIEndpoints:
logger.error(f"Error listing broker presets: {e}")
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
def publish_neighbors(self):
"""Run one neighbours discovery + publish cycle now.
POST /api/publish_neighbors
The HTTP equivalent of the ``discover.scopes`` mesh CLI command. A cycle
runs for minutes -- a discovery window plus one serialized scope query per
neighbour -- so this schedules it on the event loop and returns
immediately rather than holding the request open. Poll ``mqtt_status``
for the outcome.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
future = None
try:
self._require_post()
publisher = getattr(self.daemon_instance, "neighbors_publisher", None)
if not publisher:
return self._error("Neighbors publisher not available")
if not publisher.enabled():
return self._error(
"Neighbours publishing is disabled - enable it and opt a broker in first"
)
if self.event_loop is None:
return self._error("Event loop not available")
import asyncio
# trigger_cycle owns the already-running check and tracks the task so
# shutdown can cancel it. Doing either here would race: this runs on a
# cherrypy thread while the cycle lives on the event loop.
async def _start():
return publisher.trigger_cycle()
future = asyncio.run_coroutine_threadsafe(_start(), self.event_loop)
if future.result(timeout=10):
return self._success("Neighbours discovery cycle started")
return self._error("A neighbours cycle is already running")
except FutureTimeoutError:
logger.error("Timed out starting the neighbours cycle", exc_info=True)
if future is not None:
future.cancel()
return self._error("Timed out starting the neighbours cycle")
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Error starting neighbours cycle: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
def neighbor_scopes(self):
"""Last known region scopes per neighbour.
GET /api/neighbor_scopes
Served as its own endpoint rather than folded into the advert queries: the
advert list is paginated per contact type and read on every neighbours-page
load, and this table is small enough (one row per queried neighbour) that
the client can hold the whole thing and join on pubkey.
``scopes`` is the last answer a neighbour gave; an empty string is a real
answer meaning it serves unscoped traffic only. ``status``/``queried_at``
describe the most recent query, which may have failed after a good answer,
so ``responded_at`` is how fresh the scopes themselves are.
``served`` carries this node's own scopes in the same comma-separated form,
so a client can tell which of a neighbour's scopes it already shares without
re-deriving the rule (the wildcard plus every allow-flood region). It is the
very string this node sends when a neighbour asks *it* the same question.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
storage = self._get_storage()
scopes = storage.get_neighbor_scopes() or {}
# Keyword cannot be named `self` here -- it would collide with the
# method's own first argument.
return self._success(
scopes, count=len(scopes), served={"scopes": self._served_scopes()}
)
except Exception as e:
logger.error(f"Error reading neighbour scopes: {e}")
return self._error(str(e))
def _served_scopes(self) -> str:
"""This node's advertised region scopes, or "" when they cannot be read.
Deliberately the same formatter the anon-regions responder uses, rather
than a second reading of the transport-key table: whatever a neighbour
would be told is what a client comparing against it has to see.
"""
login_helper = getattr(self.daemon_instance, "login_helper", None)
formatter = getattr(login_helper, "_format_region_names", None) if login_helper else None
if not callable(formatter):
return ""
try:
return formatter() or ""
except Exception as e:
logger.debug(f"Could not read served region scopes: {e}")
return ""
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def query_neighbor_scopes(self):
"""Ask one neighbour for its region scopes now.
POST /api/query_neighbor_scopes {"pubkey": "<64 hex chars>"}
Unlike ``publish_neighbors`` this holds the request open for the answer:
one query is a single route-direct request whose response window is
normally the 5 s floor (only a slow radio config approaches the 120 s
ceiling), so a spinner is a better fit than a poll. Nothing is published
as a result -- the answer is stored and returned, and the periodic cycle
remains the only thing that writes to the MQTT topic.
The request is route-direct with an empty path, so it only reaches a
zero-hop neighbour, and the responder rate-limits anonymous replies (4 per
3 minutes, shared across its identities) -- both show up here as a
``timeout``.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
pubkey = str(data.get("pubkey") or "").strip()
if not pubkey:
return self._error("Missing pubkey parameter")
publisher = getattr(self.daemon_instance, "neighbors_publisher", None)
if not publisher:
return self._error("Neighbors publisher not available")
if self.event_loop is None:
return self._error("Event loop not available")
import asyncio
future = asyncio.run_coroutine_threadsafe(publisher.query_one(pubkey), self.event_loop)
result = future.result(timeout=self.SCOPE_QUERY_HTTP_TIMEOUT)
return self._success(result)
except FutureTimeoutError:
# Deliberately not cancelled: the query has already spent the airtime,
# and it persists its own outcome, so letting it finish means the answer
# still shows up on a re-read instead of being thrown away here.
logger.warning(
"Neighbour scope query still in flight after %.0fs",
self.SCOPE_QUERY_HTTP_TIMEOUT,
)
return self._error("Still waiting for the neighbour to reply - check back in a moment")
except cherrypy.HTTPError:
raise
except ValueError as e:
return self._error(str(e))
except RuntimeError as e:
# query_one raises this when a cycle or another query already holds the
# scope helper -- one request in flight at a time, by design. Still
# logged, because an unrelated RuntimeError from the event loop lands
# here too and would otherwise leave no trace.
logger.warning(f"Neighbour scope query refused: {e}")
return self._error(str(e))
except Exception as e:
logger.error(f"Error querying neighbour scopes: {e}", exc_info=True)
return self._error(str(e))
@staticmethod
def _validate_neighbors_settings(raw):
"""Validate the ``mqtt_brokers.neighbors`` block.
Returns ``(settings, error)``; ``error`` is None on success. The interval
is rejected rather than clamped when out of range, matching the firmware's
``set mqtt.neighbors.interval`` behavior.
"""
from repeater.neighbors_publisher import (
DEFAULT_INTERVAL_HOURS,
MAX_INTERVAL_HOURS,
MIN_INTERVAL_HOURS,
)
if not isinstance(raw, dict):
return None, "neighbors must be an object"
# Reject unknown keys rather than accepting them silently: a typo would
# otherwise return success while changing nothing the runtime reads.
known_keys = {
"enabled",
"interval_hours",
"discovery_timeout_seconds",
"scope_response_timeout_seconds",
"max_sweep_seconds",
"duty_cycle_abort_seconds",
"max_neighbors",
"max_neighbor_age_seconds",
}
unknown = sorted(set(raw) - known_keys)
if unknown:
return None, f"Unknown neighbors settings: {', '.join(unknown)}"
settings = {}
if "enabled" in raw:
if not isinstance(raw["enabled"], bool):
return None, "neighbors.enabled must be true or false"
settings["enabled"] = raw["enabled"]
if "interval_hours" in raw:
interval = raw["interval_hours"]
if isinstance(interval, bool) or not isinstance(interval, (int, float)):
return None, "neighbors.interval_hours must be a number"
if float(interval) != int(interval):
return None, "neighbors.interval_hours must be a whole number of hours"
interval = int(interval)
if interval < MIN_INTERVAL_HOURS or interval > MAX_INTERVAL_HOURS:
return (
None,
f"neighbors.interval_hours must be between {MIN_INTERVAL_HOURS} "
f"and {MAX_INTERVAL_HOURS} (default {DEFAULT_INTERVAL_HOURS})",
)
settings["interval_hours"] = interval
if "discovery_timeout_seconds" in raw:
try:
timeout = float(raw["discovery_timeout_seconds"])
except (TypeError, ValueError):
return None, "neighbors.discovery_timeout_seconds must be a number"
if timeout < 5 or timeout > 300:
return None, "neighbors.discovery_timeout_seconds must be between 5 and 300"
settings["discovery_timeout_seconds"] = timeout
if "scope_response_timeout_seconds" in raw:
try:
scope_timeout = float(raw["scope_response_timeout_seconds"])
except (TypeError, ValueError):
return None, "neighbors.scope_response_timeout_seconds must be a number"
if scope_timeout < 0 or scope_timeout > 300:
return None, "neighbors.scope_response_timeout_seconds must be between 0 and 300"
settings["scope_response_timeout_seconds"] = scope_timeout
if "max_neighbors" in raw:
try:
max_neighbors = int(raw["max_neighbors"])
except (TypeError, ValueError):
return None, "neighbors.max_neighbors must be a number"
if max_neighbors < 1 or max_neighbors > 255:
return None, "neighbors.max_neighbors must be between 1 and 255"
settings["max_neighbors"] = max_neighbors
if "max_neighbor_age_seconds" in raw:
try:
max_age = float(raw["max_neighbor_age_seconds"])
except (TypeError, ValueError):
return None, "neighbors.max_neighbor_age_seconds must be a number"
if max_age < 60:
return None, "neighbors.max_neighbor_age_seconds must be at least 60"
settings["max_neighbor_age_seconds"] = max_age
if "max_sweep_seconds" in raw:
try:
max_sweep = float(raw["max_sweep_seconds"])
except (TypeError, ValueError):
return None, "neighbors.max_sweep_seconds must be a number"
if max_sweep < 30 or max_sweep > 7200:
return None, "neighbors.max_sweep_seconds must be between 30 and 7200"
settings["max_sweep_seconds"] = max_sweep
if "duty_cycle_abort_seconds" in raw:
try:
abort_after = float(raw["duty_cycle_abort_seconds"])
except (TypeError, ValueError):
return None, "neighbors.duty_cycle_abort_seconds must be a number"
if abort_after < 0 or abort_after > 600:
return None, "neighbors.duty_cycle_abort_seconds must be between 0 and 600"
settings["duty_cycle_abort_seconds"] = abort_after
return settings, None
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
@@ -2365,12 +2673,41 @@ class APIEndpoints:
mqtt_updates["owner"] = str(data["owner"]).strip()
if "email" in data:
mqtt_updates["email"] = str(data["email"]).strip()
if "neighbors" in data:
from repeater.handler_helpers.neighbor_scopes import neighbors_config_block
neighbors_settings, error = self._validate_neighbors_settings(data["neighbors"])
if error:
return self._error(error)
# update_and_save replaces a section's key outright, so merge onto
# the stored block instead of letting a partial POST drop the
# settings it did not mention. neighbors_config_block returns {} for
# a hand-edited scalar (`neighbors: true` is an easy mistake, since
# the per-broker key of the same name is a boolean), which would
# otherwise fail the merge with a TypeError; the save then rewrites
# it as a proper block.
mqtt_updates["neighbors"] = {
**neighbors_config_block(self.config),
**neighbors_settings,
}
# if "disallowed_packet_types" in data:
# mqtt_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
if "brokers" in data:
brokers = data["brokers"]
if not isinstance(brokers, list):
return self._error("brokers must be a list")
# The rebuild below is a strict field whitelist, so any key a
# client omits is reset to its default. For neighbors that would
# silently switch the feature off whenever a UI that predates it
# saves an unrelated MQTT setting, so fall back to the stored
# value per broker name instead of to False.
stored_neighbors_by_name = {
str(existing.get("name")): bool(existing.get("neighbors", False))
for existing in (self.config.get("mqtt_brokers", {}) or {}).get("brokers", [])
if isinstance(existing, dict) and existing.get("name")
}
validated = []
for i, b in enumerate(brokers):
if not isinstance(b, dict):
@@ -2402,6 +2739,13 @@ class APIEndpoints:
"format": str(b["format"]).strip(),
"disallowed_packet_types": list(b.get("disallowed_packet_types", [])),
"retain_status": bool(b.get("retain_status", False)),
# Opt-in per broker; brokers that do not expect the
# neighbors topic can reject it and drop the connection.
"neighbors": bool(
b["neighbors"]
if "neighbors" in b
else stored_neighbors_by_name.get(str(b["name"]).strip(), False)
),
"tls": {
"enabled": bool(
b.get("tls", {}).get("enabled", True if port == 443 else False)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.ml-0[data-v-164d2832]{margin-left:0}.ml-4[data-v-164d2832]{margin-left:1rem}.ml-8[data-v-164d2832]{margin-left:2rem}.ml-12[data-v-164d2832]{margin-left:3rem}.ml-16[data-v-164d2832]{margin-left:4rem}.ml-20[data-v-164d2832]{margin-left:5rem}.ml-24[data-v-164d2832]{margin-left:6rem}.ml-28[data-v-164d2832]{margin-left:7rem}.ml-32[data-v-164d2832]{margin-left:8rem}.dropdown-enter-active[data-v-e0c27f45],.dropdown-leave-active[data-v-e0c27f45]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-e0c27f45],.dropdown-leave-to[data-v-e0c27f45]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.ml-0[data-v-72d5e310]{margin-left:0}.ml-4[data-v-72d5e310]{margin-left:1rem}.ml-8[data-v-72d5e310]{margin-left:2rem}.ml-12[data-v-72d5e310]{margin-left:3rem}.ml-16[data-v-72d5e310]{margin-left:4rem}.ml-20[data-v-72d5e310]{margin-left:5rem}.ml-24[data-v-72d5e310]{margin-left:6rem}.ml-28[data-v-72d5e310]{margin-left:7rem}.ml-32[data-v-72d5e310]{margin-left:8rem}.dropdown-enter-active[data-v-a57c4601],.dropdown-leave-active[data-v-a57c4601]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-a57c4601],.dropdown-leave-to[data-v-a57c4601]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-I6pN5hYo.js";import{t as d}from"./index-CeNJ9V2D.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading`},w={key:0,class:`text-sm`},T={class:`ml-2 text-accent-red`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-ui-title sm:text-ui-title-lg font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-ui-label sm:text-ui-body text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/opacity-light px-3 py-2 text-sm hover:bg-black/opacity-light dark:hover:bg-white/opacity-light`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-accent-green/opacity-light text-accent-green dark:bg-accent-green/opacity-medium dark:text-accent-green`:`bg-accent-red/opacity-light text-accent-red dark:bg-accent-red/opacity-medium dark:text-accent-red`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/opacity-light dark:bg-white/opacity-subtle`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/opacity-light`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-CYhvNqj-.js";import{t as d}from"./index-Cd7zc3iG.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading`},w={key:0,class:`text-sm`},T={class:`ml-2 text-accent-red`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-ui-title sm:text-ui-title-lg font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-ui-label sm:text-ui-body text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/opacity-light px-3 py-2 text-sm hover:bg-black/opacity-light dark:hover:bg-white/opacity-light`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-accent-green/opacity-light text-accent-green dark:bg-accent-green/opacity-medium dark:text-accent-green`:`bg-accent-red/opacity-light text-accent-red dark:bg-accent-red/opacity-medium dark:text-accent-red`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/opacity-light dark:bg-white/opacity-subtle`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/opacity-light`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-I6pN5hYo.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=4,p=5;function m(e,t){return e-t}function h(e){return l[e]??l[d]}function g(e,t){if(e<t)return{bars:0,color:`text-accent-red`,bgColor:`bg-accent-red`,snr:e,quality:`None`};let n=Math.min(p,Math.floor((e-t)/f)+1);return{bars:n,snr:e,...{1:{color:`text-accent-red`,bgColor:`bg-accent-red`,quality:`Poor`},2:{color:`text-accent-orange`,bgColor:`bg-accent-orange`,quality:`Poor`},3:{color:`text-accent-amber`,bgColor:`bg-accent-amber`,quality:`Fair`},4:{color:`text-accent-green-light`,bgColor:`bg-accent-green-light`,quality:`Good`},5:{color:`text-accent-green`,bgColor:`bg-accent-green`,quality:`Excellent`}}[n]}}function _(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>h(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`};let n=m(e,t.value);return g(Math.max(-30,Math.min(20,n)),r.value)},getSignalQualityFromSNR:e=>e===null||!Number.isFinite(e)?{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`}:g(Math.max(-30,Math.min(20,e)),r.value),noiseFloor:t,spreadingFactor:n,minSNR:r}}var v={class:`flex items-end gap-0.5`},y=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,v,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{_ as n,y as t};
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-CYhvNqj-.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=4,p=5;function m(e,t){return e-t}function h(e){return l[e]??l[d]}function g(e,t){if(e<t)return{bars:0,color:`text-accent-red`,bgColor:`bg-accent-red`,snr:e,quality:`None`};let n=Math.min(p,Math.floor((e-t)/f)+1);return{bars:n,snr:e,...{1:{color:`text-accent-red`,bgColor:`bg-accent-red`,quality:`Poor`},2:{color:`text-accent-orange`,bgColor:`bg-accent-orange`,quality:`Poor`},3:{color:`text-accent-amber`,bgColor:`bg-accent-amber`,quality:`Fair`},4:{color:`text-accent-green-light`,bgColor:`bg-accent-green-light`,quality:`Good`},5:{color:`text-accent-green`,bgColor:`bg-accent-green`,quality:`Excellent`}}[n]}}function _(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>h(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`};let n=m(e,t.value);return g(Math.max(-30,Math.min(20,n)),r.value)},getSignalQualityFromSNR:e=>e===null||!Number.isFinite(e)?{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`}:g(Math.max(-30,Math.min(20,e)),r.value),noiseFloor:t,spreadingFactor:n,minSNR:r}}var v={class:`flex items-end gap-0.5`},y=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,v,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{_ as n,y as t};
@@ -1 +1 @@
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-CeNJ9V2D.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-D-jFxDsV.js";import{d as p}from"./index-Cd7zc3iG.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{t as e}from"./dataService-T3gtqUro.js";export{e as useDataService};
@@ -1 +0,0 @@
import{t as e}from"./dataService-DLD4OWK_.js";export{e as useDataService};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{t as e}from"./packets-CHXdenlB.js";export{e as usePacketStore};
@@ -1 +0,0 @@
import{t as e}from"./packets-DgbiHYaE.js";export{e as usePacketStore};
@@ -1 +1 @@
import{t as e}from"./api-DeNK29BA.js";async function t(t,n,r={}){let{connectTimeoutMs:i=15e3,idleTimeoutMs:a=5e3,onPhaseChange:o}=r,s=new AbortController,c=!1,l=null,u=setTimeout(()=>{c||s.abort(Error(`Connection timeout`))},i),d=()=>{l&&clearTimeout(l),l=setTimeout(()=>{s.abort(Error(`Stream stalled`))},a)};o?.(`connecting`);try{return await e.get(t,n,{signal:s.signal,timeout:0,onDownloadProgress:e=>{!c&&(e.loaded??0)>0?(c=!0,clearTimeout(u),o?.(`receiving`),d()):c&&d()}})}finally{clearTimeout(u),l&&clearTimeout(l)}}export{t};
import{t as e}from"./api-CTBjApNX.js";async function t(t,n,r={}){let{connectTimeoutMs:i=15e3,idleTimeoutMs:a=5e3,onPhaseChange:o}=r,s=new AbortController,c=!1,l=null,u=setTimeout(()=>{c||s.abort(Error(`Connection timeout`))},i),d=()=>{l&&clearTimeout(l),l=setTimeout(()=>{s.abort(Error(`Stream stalled`))},a)};o?.(`connecting`);try{return await e.get(t,n,{signal:s.signal,timeout:0,onDownloadProgress:e=>{!c&&(e.loaded??0)>0?(c=!0,clearTimeout(u),o?.(`receiving`),d()):c&&d()}})}finally{clearTimeout(u),l&&clearTimeout(l)}}export{t};
@@ -0,0 +1 @@
import{t as e}from"./system-CYhvNqj-.js";export{e as useSystemStore};
@@ -1 +1 @@
import{M as e,U as t,o as n}from"./runtime-core.esm-bundler-CINEgm0a.js";import{n as r,t as i,v as a}from"./api-DeNK29BA.js";import{t as o}from"./packets-DgbiHYaE.js";var s=`pymc_config_cache`;function c(){try{let e=sessionStorage.getItem(s);return e?JSON.parse(e):null}catch{return null}}function l(e){if(e)try{sessionStorage.setItem(s,JSON.stringify(e))}catch{}}function u(){try{sessionStorage.removeItem(s)}catch{}}var d=a(`system`,()=>{let a=c(),s=t(a?{config:a}:null),d=t(!1),f=t(null),p=t(null),m=t(`forward`),h=t(!0),g=t(0),_=t(10),v=t(!1),y=n(()=>s.value?.config?.node_name??`Unknown`),b=n(()=>s.value?.site_name??``);e(()=>{let e=b.value;document.title=e?`${e} — Repeater`:`Repeater Dashboard`});let x=n(()=>{let e=s.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),S=n(()=>s.value!==null),C=n(()=>s.value?.version??`Unknown`),w=n(()=>s.value?.core_version??`Unknown`),T=n(()=>s.value?.noise_floor_dbm??null),E=n(()=>_.value>0?Math.min(g.value/_.value*100,100):0),D=n(()=>m.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:m.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:h.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),O=n(()=>({mode:m.value})),k=n(()=>h.value?{active:!0,warning:!1}:{active:!1,warning:!0}),A=e=>{v.value=e},j=null;async function M(e){return j===null?(j=(async()=>{try{d.value=!0,f.value=null;let t=new AbortController,n=15e3,i=window.setTimeout(()=>t.abort(),n),a=!1,c=()=>{a||(a=!0,e?.onFirstByte?.()),clearTimeout(i),i=window.setTimeout(()=>t.abort(),n)},u;try{u=await r.get(`/stats`,{signal:t.signal,onDownloadProgress:c,timeout:0})}finally{clearTimeout(i)}let m=u.data,h;if(m.success&&m.data)h=m.data;else if(m&&`version`in m)h=m;else throw Error(m.error||`Failed to fetch stats`);return s.value=h,p.value=new Date,N(h),l(h.config),o().systemStats=h,h}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{d.value=!1}})(),j.finally(()=>{j=null}),j):j}function N(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?m.value=t:t!==void 0&&(m.value=`forward`);let n=e.config.duty_cycle;if(n){h.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?_.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(_.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?g.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(g.value=t.parsedValue||0)}async function P(e){try{let t=await i.post(`/set_mode`,{mode:e});if(t.success)return m.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function F(e){try{let t=await i.post(`/set_duty_cycle`,{enabled:e});if(t.success)return h.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function I(){try{let e=await i.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function L(){return await F(!h.value)}function R(e){s.value?(e.uptime_seconds!==void 0&&(s.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(s.value.noise_floor_dbm=e.noise_floor_dbm)):s.value=e,p.value=new Date,N(e)}async function z(e=5e3,t=!1){t||await M();let n=null;return t||(n=setInterval(async()=>{try{await M()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function B(){s.value=null,f.value=null,p.value=null,d.value=!1,m.value=`forward`,h.value=!0,g.value=0,_.value=10,u()}return{stats:s,isLoading:d,error:f,lastUpdated:p,currentMode:m,dutyCycleEnabled:h,dutyCycleUtilization:g,dutyCycleMax:_,cadCalibrationRunning:v,nodeName:y,siteName:b,pubKey:x,hasStats:S,version:C,coreVersion:w,noiseFloorDbm:T,dutyCyclePercentage:E,statusBadge:D,modeButtonState:O,dutyCycleButtonState:k,fetchStats:M,setMode:P,setDutyCycle:F,sendAdvert:I,toggleDutyCycle:L,startAutoRefresh:z,updateRealtimeStats:R,reset:B,setCadCalibrationRunning:A}});export{d as t};
import{M as e,U as t,o as n}from"./runtime-core.esm-bundler-CINEgm0a.js";import{n as r,t as i,v as a}from"./api-CTBjApNX.js";import{t as o}from"./packets-CHXdenlB.js";var s=`pymc_config_cache`;function c(){try{let e=sessionStorage.getItem(s);return e?JSON.parse(e):null}catch{return null}}function l(e){if(e)try{sessionStorage.setItem(s,JSON.stringify(e))}catch{}}function u(){try{sessionStorage.removeItem(s)}catch{}}var d=a(`system`,()=>{let a=c(),s=t(a?{config:a}:null),d=t(!1),f=t(null),p=t(null),m=t(`forward`),h=t(!0),g=t(0),_=t(10),v=t(!1),y=n(()=>s.value?.config?.node_name??`Unknown`),b=n(()=>s.value?.site_name??``);e(()=>{let e=b.value;document.title=e?`${e} — Repeater`:`Repeater Dashboard`});let x=n(()=>{let e=s.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),S=n(()=>s.value!==null),C=n(()=>s.value?.version??`Unknown`),w=n(()=>s.value?.core_version??`Unknown`),T=n(()=>s.value?.noise_floor_dbm??null),E=n(()=>_.value>0?Math.min(g.value/_.value*100,100):0),D=n(()=>m.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:m.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:h.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),O=n(()=>({mode:m.value})),k=n(()=>h.value?{active:!0,warning:!1}:{active:!1,warning:!0}),A=e=>{v.value=e},j=null;async function M(e){return j===null?(j=(async()=>{try{d.value=!0,f.value=null;let t=new AbortController,n=15e3,i=window.setTimeout(()=>t.abort(),n),a=!1,c=()=>{a||(a=!0,e?.onFirstByte?.()),clearTimeout(i),i=window.setTimeout(()=>t.abort(),n)},u;try{u=await r.get(`/stats`,{signal:t.signal,onDownloadProgress:c,timeout:0})}finally{clearTimeout(i)}let m=u.data,h;if(m.success&&m.data)h=m.data;else if(m&&`version`in m)h=m;else throw Error(m.error||`Failed to fetch stats`);return s.value=h,p.value=new Date,N(h),l(h.config),o().systemStats=h,h}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{d.value=!1}})(),j.finally(()=>{j=null}),j):j}function N(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?m.value=t:t!==void 0&&(m.value=`forward`);let n=e.config.duty_cycle;if(n){h.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?_.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(_.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?g.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(g.value=t.parsedValue||0)}async function P(e){try{let t=await i.post(`/set_mode`,{mode:e});if(t.success)return m.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function F(e){try{let t=await i.post(`/set_duty_cycle`,{enabled:e});if(t.success)return h.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function I(){try{let e=await i.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function L(){return await F(!h.value)}function R(e){s.value?(e.uptime_seconds!==void 0&&(s.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(s.value.noise_floor_dbm=e.noise_floor_dbm)):s.value=e,p.value=new Date,N(e)}async function z(e=5e3,t=!1){t||await M();let n=null;return t||(n=setInterval(async()=>{try{await M()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function B(){s.value=null,f.value=null,p.value=null,d.value=!1,m.value=`forward`,h.value=!0,g.value=0,_.value=10,u()}return{stats:s,isLoading:d,error:f,lastUpdated:p,currentMode:m,dutyCycleEnabled:h,dutyCycleUtilization:g,dutyCycleMax:_,cadCalibrationRunning:v,nodeName:y,siteName:b,pubKey:x,hasStats:S,version:C,coreVersion:w,noiseFloorDbm:T,dutyCyclePercentage:E,statusBadge:D,modeButtonState:O,dutyCycleButtonState:k,fetchStats:M,setMode:P,setDutyCycle:F,sendAdvert:I,toggleDutyCycle:L,startAutoRefresh:z,updateRealtimeStats:R,reset:B,setCadCalibrationRunning:A}});export{d as t};
@@ -1 +0,0 @@
import{t as e}from"./system-I6pN5hYo.js";export{e as useSystemStore};
@@ -1 +1 @@
import{U as e,o as t}from"./runtime-core.esm-bundler-CINEgm0a.js";import{c as n,d as r,i,l as a,v as o}from"./api-DeNK29BA.js";import{t as s}from"./packets-DgbiHYaE.js";import{t as c}from"./system-I6pN5hYo.js";import{t as l}from"./dataService-DLD4OWK_.js";var u=o(`websocket`,()=>{let o=e(null),u=e(`idle`),d=e(0),f=e(Date.now()),p=e(null),m=e(null),h=e(!1),g=e(!1),_=e(!1),v=e({visible:!1,message:``,variant:`info`}),y=null,b=s(),x=c(),S=i(),C=l(),w=t(()=>u.value===`open`);function T(e,t,n=0){y!==null&&(clearTimeout(y),y=null),v.value={visible:!0,message:e,variant:t},n>0&&(y=window.setTimeout(()=>{E()},n))}function E(){y!==null&&(clearTimeout(y),y=null),v.value.visible=!1}function D(){p.value!==null&&(clearTimeout(p.value),p.value=null)}function O(){m.value!==null&&(clearInterval(m.value),m.value=null)}function k(){T(`Reconnecting...`,`info`)}function A(){let e=a();return!h.value&&!g.value&&!!e&&!r()&&S.canMaintainConnections}function j(){let e,t=a(),r=n(),i=new URLSearchParams;return t&&i.set(`token`,t),r&&i.set(`client_id`,r),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function M(){await C.onReconnect()}function N(e=!1){O(),o.value&&e&&(o.value.onopen=null,o.value.onmessage=null,o.value.onerror=null,o.value.onclose=null)}function P(){if(D(),!A()){if(a()&&r()){S.handleAuthFailure(`expired`);return}u.value=`closed`;return}if(d.value>=6){u.value=`closed`,T(`Connection lost`,`error`,5e3);return}u.value=`reconnecting`,k();let e=Math.min(1e3*2**d.value,3e4);d.value+=1,p.value=window.setTimeout(()=>{p.value=null,F(!0)},e)}function F(e=!1){if(!A()||o.value?.readyState===WebSocket.OPEN||o.value?.readyState===WebSocket.CONNECTING)return;D(),N(!0),u.value=e||d.value>0||_.value?`reconnecting`:`connecting`,_.value&&k();let t=new WebSocket(j());o.value=t,t.onopen=()=>{u.value=`open`,f.value=Date.now();let e=d.value>0||_.value;d.value=0,_.value=!1,O(),m.value=window.setInterval(()=>{o.value?.readyState===WebSocket.OPEN&&(o.value.send(JSON.stringify({type:`ping`})),Date.now()-f.value>6e4&&(N(!0),o.value?.close()))},3e4),e?(C.onReconnect(),T(`Back online`,`success`,2500)):E()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?b.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&b.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&x.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?b.updateRealtimeStats(t.data):t.type===`system_stats`?x.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(f.value=Date.now(),t.type===`ping`&&o.value?.readyState===WebSocket.OPEN&&o.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{u.value=d.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=o.value;if(N(),t===o.value&&(o.value=null),h.value||g.value){u.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){S.handleAuthFailure(`expired`);return}C.noteDisconnect(),P()}}function I(e=`lifecycle`){if(g.value=!0,D(),u.value=`closed`,e===`offline`?(_.value=!0,T(`Connection lost`,`error`,4e3)):e===`hidden`?(_.value=!0,E()):e===`logout`&&(_.value=!1,E()),o.value){let e=o.value;o.value=null,N(!0),e.close()}}function L(){h.value=!1,g.value=!1}function R(e={}){h.value=e.preventReconnect??h.value,e.silent||E(),I(e.preventReconnect?`logout`:`lifecycle`),d.value=0}return{isConnected:w,connectionState:u,reconnectAttempts:d,snackbar:v,connect:F,disconnect:R,pause:I,allowReconnect:L,hideSnackbar:E,resyncData:M}});export{u as t};
import{U as e,o as t}from"./runtime-core.esm-bundler-CINEgm0a.js";import{c as n,d as r,i,l as a,v as o}from"./api-CTBjApNX.js";import{t as s}from"./packets-CHXdenlB.js";import{t as c}from"./system-CYhvNqj-.js";import{t as l}from"./dataService-T3gtqUro.js";var u=o(`websocket`,()=>{let o=e(null),u=e(`idle`),d=e(0),f=e(Date.now()),p=e(null),m=e(null),h=e(!1),g=e(!1),_=e(!1),v=e({visible:!1,message:``,variant:`info`}),y=null,b=s(),x=c(),S=i(),C=l(),w=t(()=>u.value===`open`);function T(e,t,n=0){y!==null&&(clearTimeout(y),y=null),v.value={visible:!0,message:e,variant:t},n>0&&(y=window.setTimeout(()=>{E()},n))}function E(){y!==null&&(clearTimeout(y),y=null),v.value.visible=!1}function D(){p.value!==null&&(clearTimeout(p.value),p.value=null)}function O(){m.value!==null&&(clearInterval(m.value),m.value=null)}function k(){T(`Reconnecting...`,`info`)}function A(){let e=a();return!h.value&&!g.value&&!!e&&!r()&&S.canMaintainConnections}function j(){let e,t=a(),r=n(),i=new URLSearchParams;return t&&i.set(`token`,t),r&&i.set(`client_id`,r),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function M(){await C.onReconnect()}function N(e=!1){O(),o.value&&e&&(o.value.onopen=null,o.value.onmessage=null,o.value.onerror=null,o.value.onclose=null)}function P(){if(D(),!A()){if(a()&&r()){S.handleAuthFailure(`expired`);return}u.value=`closed`;return}if(d.value>=6){u.value=`closed`,T(`Connection lost`,`error`,5e3);return}u.value=`reconnecting`,k();let e=Math.min(1e3*2**d.value,3e4);d.value+=1,p.value=window.setTimeout(()=>{p.value=null,F(!0)},e)}function F(e=!1){if(!A()||o.value?.readyState===WebSocket.OPEN||o.value?.readyState===WebSocket.CONNECTING)return;D(),N(!0),u.value=e||d.value>0||_.value?`reconnecting`:`connecting`,_.value&&k();let t=new WebSocket(j());o.value=t,t.onopen=()=>{u.value=`open`,f.value=Date.now();let e=d.value>0||_.value;d.value=0,_.value=!1,O(),m.value=window.setInterval(()=>{o.value?.readyState===WebSocket.OPEN&&(o.value.send(JSON.stringify({type:`ping`})),Date.now()-f.value>6e4&&(N(!0),o.value?.close()))},3e4),e?(C.onReconnect(),T(`Back online`,`success`,2500)):E()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?b.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&b.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&x.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?b.updateRealtimeStats(t.data):t.type===`system_stats`?x.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(f.value=Date.now(),t.type===`ping`&&o.value?.readyState===WebSocket.OPEN&&o.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{u.value=d.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=o.value;if(N(),t===o.value&&(o.value=null),h.value||g.value){u.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){S.handleAuthFailure(`expired`);return}C.noteDisconnect(),P()}}function I(e=`lifecycle`){if(g.value=!0,D(),u.value=`closed`,e===`offline`?(_.value=!0,T(`Connection lost`,`error`,4e3)):e===`hidden`?(_.value=!0,E()):e===`logout`&&(_.value=!1,E()),o.value){let e=o.value;o.value=null,N(!0),e.close()}}function L(){h.value=!1,g.value=!1}function R(e={}){h.value=e.preventReconnect??h.value,e.silent||E(),I(e.preventReconnect?`logout`:`lifecycle`),d.value=0}return{isConnected:w,connectionState:u,reconnectAttempts:d,snackbar:v,connect:F,disconnect:R,pause:I,allowReconnect:L,hideSnackbar:E,resyncData:M}});export{u as t};
@@ -0,0 +1 @@
import{t as e}from"./websocket-CZs2lKC6.js";export{e as useWebSocketStore};
@@ -1 +0,0 @@
import{t as e}from"./websocket-B5jstwcP.js";export{e as useWebSocketStore};
+12 -11
View File
@@ -8,20 +8,21 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-CeNJ9V2D.js"></script>
<script type="module" crossorigin src="/assets/index-Cd7zc3iG.js"></script>
<link rel="modulepreload" crossorigin href="/assets/chunk-DECur_0Z.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-CINEgm0a.js">
<link rel="modulepreload" crossorigin href="/assets/api-DeNK29BA.js">
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-D-_sbJKW.js">
<link rel="modulepreload" crossorigin href="/assets/api-CTBjApNX.js">
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-C08u_PTE.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-dom.esm-bundler-B3VeUO8l.js">
<link rel="modulepreload" crossorigin href="/assets/Spinner-CMJUE3iy.js">
<link rel="modulepreload" crossorigin href="/assets/useTheme-vbCn9P26.js">
<link rel="modulepreload" crossorigin href="/assets/packets-DgbiHYaE.js">
<link rel="modulepreload" crossorigin href="/assets/system-I6pN5hYo.js">
<link rel="modulepreload" crossorigin href="/assets/dataService-DLD4OWK_.js">
<link rel="modulepreload" crossorigin href="/assets/websocket-B5jstwcP.js">
<link rel="modulepreload" crossorigin href="/assets/constants-C3rXUIAq.js">
<link rel="stylesheet" crossorigin href="/assets/index-I9fimu9Z.css">
<link rel="modulepreload" crossorigin href="/assets/Spinner-D-jFxDsV.js">
<link rel="modulepreload" crossorigin href="/assets/useTheme-BtGkqRpn.js">
<link rel="modulepreload" crossorigin href="/assets/packets-CHXdenlB.js">
<link rel="modulepreload" crossorigin href="/assets/system-CYhvNqj-.js">
<link rel="modulepreload" crossorigin href="/assets/formatters-vw234I-c.js">
<link rel="modulepreload" crossorigin href="/assets/dataService-T3gtqUro.js">
<link rel="modulepreload" crossorigin href="/assets/websocket-CZs2lKC6.js">
<link rel="modulepreload" crossorigin href="/assets/constants-Hl8RMxy-.js">
<link rel="stylesheet" crossorigin href="/assets/index-DNuHAVQy.css">
</head>
<body>
<div id="app"></div>
+155
View File
@@ -3417,6 +3417,140 @@ paths:
schema:
type: object
/publish_neighbors:
post:
tags: [System]
summary: Publish the neighbours table now
description: >
Runs one neighbours cycle immediately: a zero-hop discovery broadcast, a
serialized scope query per neighbour, then a publish to every opted-in
broker. Returns as soon as the cycle is scheduled; the cycle itself takes
minutes. Poll /mqtt_status for the outcome.
security:
- BearerAuth: []
- ApiKeyAuth: []
responses:
'200':
description: Cycle started, or an error when one is already running
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'405':
description: Method not allowed
/neighbor_scopes:
get:
tags: [Network Policy]
summary: Last known region scopes per neighbour
description: >
Region scopes learned from the anon-regions query the neighbours publisher
issues, keyed by lowercase pubkey hex. One row per neighbour that has been
queried; neighbours never queried are simply absent. `scopes` is the last
answer given and an empty string is a real answer meaning the neighbour
serves unscoped traffic only. `status`/`queried_at` describe the most
recent query, which may have failed after a good answer, so `responded_at`
is what says how fresh `scopes` is. `served` carries this node's own scopes
in the same comma-separated form — the very string it sends when a neighbour
asks it the same question — so a client can tell which of a neighbour's
scopes it already shares.
security:
- BearerAuth: []
- ApiKeyAuth: []
responses:
'200':
description: >
Stored scope records, or an error when storage is unavailable (as it
briefly is during daemon startup) — `data` is absent in that case.
content:
application/json:
schema:
type: object
required: [success]
properties:
success:
type: boolean
error:
type: string
count:
type: integer
served:
type: object
required: [scopes]
properties:
scopes:
type: string
description: >
This node's own advertised scopes, comma-separated, `*`
first when it floods unscoped. Empty when they cannot be
read.
data:
type: object
additionalProperties:
$ref: '#/components/schemas/NeighborScopeRecord'
/query_neighbor_scopes:
post:
tags: [Network Policy]
summary: Query one neighbour's region scopes now
description: >
Sends a single route-direct anon-regions request and waits for the reply,
then stores and returns the outcome. Nothing is published to MQTT; the
periodic cycle owns the neighbors topic. The request only reaches a
zero-hop neighbour, and the responder rate-limits anonymous replies (4 per
3 minutes), so both a multi-hop target and a repeated query show up as
`timeout`. Errors when a neighbours cycle already holds the scope helper.
security:
- BearerAuth: []
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [pubkey]
properties:
pubkey:
type: string
description: Full 64-character public key hex of the neighbour
responses:
'200':
description: Query outcome, or an error when it could not be run
content:
application/json:
schema:
type: object
required: [success]
properties:
success:
type: boolean
error:
type: string
data:
type: object
required: [pubkey, status, scopes, transmitted]
properties:
pubkey:
type: string
status:
type: string
enum: [responded, timeout, send_failed]
scopes:
type: string
description: Comma-separated scope names; empty when unscoped
transmitted:
type: boolean
description: Whether the request actually reached the air
queried_at:
type: number
nullable: true
responded_at:
type: number
nullable: true
'405':
description: Method not allowed
/update_web_config:
post:
tags: [System]
@@ -4385,6 +4519,27 @@ components:
type: string
description: Error message
NeighborScopeRecord:
type: object
required: [scopes, status, queried_at]
properties:
scopes:
type: string
description: >
Comma-separated region scope names from the neighbour's last answer.
Empty means it answered that it serves unscoped traffic only.
responded_at:
type: number
nullable: true
description: Epoch seconds of the answer `scopes` came from
status:
type: string
enum: [responded, timeout, send_failed]
description: Outcome of the most recent query
queried_at:
type: number
description: Epoch seconds of the most recent query
NeighborLinkSnapshot:
type: object
properties:
+95
View File
@@ -475,6 +475,101 @@ def test_discovery_auto_add_skips_local_node_and_persists_remote():
storage.record_advert.assert_called_once()
def test_discovery_auto_add_persists_through_the_storage_actually_wired_in():
"""MeshCLI is constructed with the SQLiteHandler, not the StorageCollector.
See repeater/main.py:516 -> TextHelper(sqlite_handler=...storage.sqlite_handler)
-> MeshCLI(storage_handler=self.sqlite_handler). SQLiteHandler exposes
store_advert but no record_advert, so a persistence path that only looked for
record_advert made `discover.neighbors` silently record nothing in production
while passing tests that injected a hand-rolled double. Spec'ing the mock off
the real class is what keeps this honest.
"""
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
identity = SimpleNamespace(get_public_key=lambda: bytes.fromhex("11" * 32))
storage = MagicMock(spec=SQLiteHandler)
cli = MeshCLI(
"/tmp/cfg.yaml", _base_config(), _cfg_mgr(), identity=identity, storage_handler=storage
)
result = cli._auto_add_discovery_result(
{
"pub_key": "22" * 32,
"node_name": "Remote Repeater",
"node_type": 2,
"rssi": -70,
"response_snr": 4.25,
}
)
assert result["auto_added"] is True
storage.store_advert.assert_called_once()
record = storage.store_advert.call_args.args[0]
assert record["pubkey"] == "22" * 32
assert record["is_repeater"] is True
assert record["zero_hop"] is True
assert record["snr"] == 4.25
assert record["rssi"] == -70
def test_discovery_auto_add_preserves_existing_advert_name_and_location(tmp_path):
"""Discovery responses must not erase metadata learned from real adverts."""
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
from repeater.handler_helpers.discovery import persist_discovery_result
pubkey = "22" * 32
storage = SQLiteHandler(tmp_path)
storage.store_advert(
{
"timestamp": 100.0,
"pubkey": pubkey,
"node_name": "Named Repeater",
"is_repeater": True,
"route_type": 1,
"contact_type": "Repeater",
"latitude": 37.5,
"longitude": -122.25,
"rssi": -80,
"snr": 2.0,
"zero_hop": True,
}
)
assert persist_discovery_result(
storage,
{
"pub_key": pubkey,
"node_type": 2,
"rssi": -65,
"response_snr": 6.5,
},
)
neighbor = storage.get_neighbors()[pubkey]
assert neighbor["node_name"] == "Named Repeater"
assert neighbor["latitude"] == 37.5
assert neighbor["longitude"] == -122.25
assert neighbor["rssi"] == -65
assert neighbor["snr"] == 6.5
def test_discovery_auto_add_prefers_record_advert_when_the_collector_is_wired_in():
"""StorageCollector.record_advert also publishes the advert; prefer it."""
from repeater.data_acquisition.storage_collector import StorageCollector
identity = SimpleNamespace(get_public_key=lambda: bytes.fromhex("11" * 32))
storage = MagicMock(spec=StorageCollector)
cli = MeshCLI(
"/tmp/cfg.yaml", _base_config(), _cfg_mgr(), identity=identity, storage_handler=storage
)
result = cli._auto_add_discovery_result({"pub_key": "22" * 32, "node_type": 2})
assert result["auto_added"] is True
storage.record_advert.assert_called_once()
def test_cmd_set_save_failure_reports_error_and_skips_live_update():
mgr = _cfg_mgr(save_ok=False)
cli = MeshCLI("/tmp/cfg.yaml", _base_config(), mgr)
File diff suppressed because it is too large Load Diff