mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-10 02:43:02 +02:00
Merge pull request #379 from agessaman/feat/mqtt-neighbors
feat(mqtt): periodic neighbours publication
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
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
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}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
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
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
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
+1
-1
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
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
@@ -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
@@ -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
@@ -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};
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user