mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-07 17:33:16 +02:00
feat(neighbour-links): implement neighbour link tracking and history retrieval
This commit is contained in:
@@ -41,6 +41,14 @@ repeater:
|
||||
# Changing this value has no effect on current packet processing.
|
||||
score_threshold: 0.3
|
||||
|
||||
# Observation-only upstream neighbour link metrics.
|
||||
# These metrics are currently not used for forwarding, dedupe, TX delay,
|
||||
# route selection, or packet acceptance decisions yet ;-0.
|
||||
neighbour_link_metrics_enabled: true
|
||||
neighbour_link_ewma_alpha: 0.20
|
||||
neighbour_link_ttl_seconds: 86400
|
||||
neighbour_link_max_entries: 512
|
||||
|
||||
# Automatic advertisement interval in hours
|
||||
# The repeater will send an advertisement packet at this interval
|
||||
# Set to 0 to disable automatic adverts (manual only via web interface)
|
||||
|
||||
@@ -100,6 +100,8 @@ class SQLiteHandler:
|
||||
src_hash TEXT,
|
||||
dst_hash TEXT,
|
||||
path_hash TEXT,
|
||||
upstream_hash TEXT,
|
||||
upstream_hash_size INTEGER,
|
||||
header TEXT,
|
||||
transport_codes TEXT,
|
||||
payload TEXT,
|
||||
@@ -189,6 +191,10 @@ class SQLiteHandler:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_packets_upstream_time "
|
||||
"ON packets(upstream_hash, upstream_hash_size, timestamp)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)"
|
||||
)
|
||||
@@ -637,6 +643,35 @@ class SQLiteHandler:
|
||||
)
|
||||
logger.info(f"Migration '{migration_name}' applied successfully")
|
||||
|
||||
# Migration 12: Add upstream hash fields to packets for
|
||||
# neighbour-link history lookups and indexing.
|
||||
migration_name = "add_upstream_hash_to_packets"
|
||||
existing = conn.execute(
|
||||
"SELECT migration_name FROM migrations WHERE migration_name = ?",
|
||||
(migration_name,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
cursor = conn.execute("PRAGMA table_info(packets)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if "upstream_hash" not in columns:
|
||||
conn.execute("ALTER TABLE packets ADD COLUMN upstream_hash TEXT")
|
||||
logger.info("Added upstream_hash column to packets table")
|
||||
|
||||
if "upstream_hash_size" not in columns:
|
||||
conn.execute("ALTER TABLE packets ADD COLUMN upstream_hash_size INTEGER")
|
||||
logger.info("Added upstream_hash_size column to packets table")
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_packets_upstream_time "
|
||||
"ON packets(upstream_hash, upstream_hash_size, timestamp)"
|
||||
)
|
||||
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:
|
||||
@@ -732,10 +767,11 @@ class SQLiteHandler:
|
||||
INSERT INTO packets (
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
upstream_hash, upstream_hash_size,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
record.get("timestamp", time.time()),
|
||||
@@ -751,6 +787,8 @@ class SQLiteHandler:
|
||||
record.get("src_hash"),
|
||||
record.get("dst_hash"),
|
||||
record.get("path_hash"),
|
||||
record.get("upstream_hash"),
|
||||
record.get("upstream_hash_size"),
|
||||
record.get("header"),
|
||||
record.get("transport_codes"),
|
||||
record.get("payload"),
|
||||
@@ -1576,6 +1614,7 @@ class SQLiteHandler:
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
upstream_hash, upstream_hash_size,
|
||||
transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path,
|
||||
lbt_attempts, lbt_channel_busy
|
||||
@@ -1629,6 +1668,7 @@ class SQLiteHandler:
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
upstream_hash, upstream_hash_size,
|
||||
transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path,
|
||||
lbt_attempts, lbt_channel_busy
|
||||
@@ -1762,6 +1802,7 @@ class SQLiteHandler:
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
upstream_hash, upstream_hash_size,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
@@ -1788,6 +1829,7 @@ class SQLiteHandler:
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
upstream_hash, upstream_hash_size,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
@@ -1803,6 +1845,80 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get packet by id: {e}")
|
||||
return None
|
||||
|
||||
def get_neighbor_link_history(
|
||||
self,
|
||||
*,
|
||||
peer_hash: str,
|
||||
path_hash_size: int,
|
||||
hours: int = 24,
|
||||
limit: int = 1000,
|
||||
) -> list:
|
||||
try:
|
||||
normalized_hash = str(peer_hash or "").strip().upper()
|
||||
if not normalized_hash:
|
||||
return []
|
||||
|
||||
path_hash_size = int(path_hash_size)
|
||||
hours = max(1, int(hours))
|
||||
limit = max(1, min(int(limit), 5000))
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
timestamp,
|
||||
rssi,
|
||||
snr,
|
||||
score,
|
||||
is_duplicate,
|
||||
packet_hash,
|
||||
type,
|
||||
route,
|
||||
original_path
|
||||
FROM packets INDEXED BY idx_packets_upstream_time
|
||||
WHERE upstream_hash = ?
|
||||
AND upstream_hash_size = ?
|
||||
AND timestamp >= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(normalized_hash, path_hash_size, cutoff, limit),
|
||||
).fetchall()
|
||||
|
||||
history = []
|
||||
for row in rows:
|
||||
hop_count = None
|
||||
original_path = row["original_path"]
|
||||
if original_path:
|
||||
try:
|
||||
parsed = json.loads(original_path)
|
||||
if isinstance(parsed, list):
|
||||
hop_count = len(parsed)
|
||||
except Exception:
|
||||
hop_count = None
|
||||
|
||||
history.append(
|
||||
{
|
||||
"timestamp": row["timestamp"],
|
||||
"rssi": row["rssi"],
|
||||
"snr": row["snr"],
|
||||
"score": row["score"],
|
||||
"is_duplicate": bool(row["is_duplicate"]),
|
||||
"packet_hash": row["packet_hash"],
|
||||
"packet_type": row["type"],
|
||||
"route_type": row["route"],
|
||||
"path_hop_count": hop_count,
|
||||
}
|
||||
)
|
||||
|
||||
history.reverse()
|
||||
return history
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get neighbor link history: {e}")
|
||||
return []
|
||||
|
||||
def get_packet_type_stats(self, hours: int = 24) -> dict:
|
||||
try:
|
||||
now = time.time()
|
||||
|
||||
@@ -441,6 +441,21 @@ class StorageCollector:
|
||||
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
|
||||
return self.sqlite_handler.get_packet_by_id(packet_id)
|
||||
|
||||
def get_neighbor_link_history(
|
||||
self,
|
||||
*,
|
||||
peer_hash: str,
|
||||
path_hash_size: int,
|
||||
hours: int = 24,
|
||||
limit: int = 1000,
|
||||
) -> list:
|
||||
return self.sqlite_handler.get_neighbor_link_history(
|
||||
peer_hash=peer_hash,
|
||||
path_hash_size=path_hash_size,
|
||||
hours=hours,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def get_rrd_data(
|
||||
self,
|
||||
start_time: Optional[int] = None,
|
||||
|
||||
+58
-3
@@ -23,6 +23,7 @@ from openhop_core.protocol.packet_utils import PacketHeaderUtils, PathUtils
|
||||
|
||||
from repeater.airtime import AirtimeManager
|
||||
from repeater.data_acquisition import StorageCollector
|
||||
from repeater.neighbour_links import NeighbourLinkTracker
|
||||
from repeater.policy_engine import PolicyDecision, PolicyEngine
|
||||
|
||||
logger = logging.getLogger("RepeaterHandler")
|
||||
@@ -84,6 +85,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
self.neighbour_link_tracker = NeighbourLinkTracker(config)
|
||||
|
||||
radio = dispatcher.radio if dispatcher else None
|
||||
if radio:
|
||||
@@ -184,6 +186,8 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
||||
snr = metadata.get("snr", 0.0)
|
||||
rssi = metadata.get("rssi", 0)
|
||||
|
||||
# TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off)
|
||||
mode = self.config.get("repeater", {}).get("mode", "forward")
|
||||
@@ -234,8 +238,6 @@ class RepeaterHandler(BaseHandler):
|
||||
# clone the packet to avoid modifying the original
|
||||
processed_packet = copy.deepcopy(packet)
|
||||
|
||||
snr = metadata.get("snr", 0.0)
|
||||
rssi = metadata.get("rssi", 0)
|
||||
transmitted = False
|
||||
tx_delay_ms = 0.0
|
||||
drop_reason = None
|
||||
@@ -246,6 +248,25 @@ class RepeaterHandler(BaseHandler):
|
||||
original_path_hashes = packet.get_path_hashes_hex()
|
||||
path_hash_size = packet.get_path_hash_size()
|
||||
|
||||
if not local_transmission:
|
||||
payload_type = (
|
||||
packet.get_payload_type() if hasattr(packet, "get_payload_type") else None
|
||||
)
|
||||
score = self.calculate_packet_score(
|
||||
snr,
|
||||
len(packet.payload or b""),
|
||||
self.radio_config["spreading_factor"],
|
||||
)
|
||||
self.neighbour_link_tracker.observe(
|
||||
packet,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
score=score,
|
||||
is_duplicate=(pkt_hash_full in self.seen_packets),
|
||||
)
|
||||
|
||||
# Process for forwarding (skip if repeat disabled or if this is a local transmission).
|
||||
# Pass pkt_hash_full so flood_forward / direct_forward don't recompute SHA-256.
|
||||
result = (
|
||||
@@ -557,6 +578,22 @@ class RepeaterHandler(BaseHandler):
|
||||
path_hash_size = packet.get_path_hash_size()
|
||||
path_hash = self._path_hash_display(original_path_hashes)
|
||||
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
|
||||
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
||||
|
||||
score = self.calculate_packet_score(
|
||||
snr,
|
||||
len(packet.payload or b""),
|
||||
self.radio_config["spreading_factor"],
|
||||
)
|
||||
self.neighbour_link_tracker.observe(
|
||||
packet,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
rssi=float(rssi),
|
||||
snr=float(snr),
|
||||
score=score,
|
||||
is_duplicate=True,
|
||||
)
|
||||
|
||||
packet_record = self._build_packet_record(
|
||||
packet,
|
||||
@@ -572,7 +609,7 @@ class RepeaterHandler(BaseHandler):
|
||||
transmitted=False,
|
||||
drop_reason="Duplicate",
|
||||
is_duplicate=True,
|
||||
packet_hash=packet.calculate_packet_hash().hex().upper(),
|
||||
packet_hash=pkt_hash_full,
|
||||
)
|
||||
|
||||
if self.storage:
|
||||
@@ -655,6 +692,13 @@ class RepeaterHandler(BaseHandler):
|
||||
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
|
||||
payload = getattr(packet, "payload", None)
|
||||
payload_len = len(payload or b"")
|
||||
upstream_hash, upstream_hash_size = self.neighbour_link_tracker.get_upstream_peer_identity(
|
||||
packet,
|
||||
route_type,
|
||||
payload_type,
|
||||
path_hashes=original_path_hashes,
|
||||
path_hash_size=path_hash_size,
|
||||
)
|
||||
|
||||
# LoRa time-on-air for this packet (Semtech reference formula).
|
||||
# Computed once here so every downstream consumer (MQTT, SQLite, Glass,
|
||||
@@ -698,6 +742,8 @@ class RepeaterHandler(BaseHandler):
|
||||
"original_path": original_path_hashes or None,
|
||||
"forwarded_path": forwarded_path,
|
||||
"path_hash_size": path_hash_size,
|
||||
"upstream_hash": upstream_hash,
|
||||
"upstream_hash_size": upstream_hash_size,
|
||||
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
|
||||
"lbt_attempts": lbt_attempts,
|
||||
"lbt_backoff_delays_ms": lbt_backoff_delays_ms,
|
||||
@@ -1463,10 +1509,19 @@ class RepeaterHandler(BaseHandler):
|
||||
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
|
||||
self.cache_ttl = repeater_config.get("cache_ttl", 60)
|
||||
self.max_flood_hops = repeater_config.get("max_flood_hops", 64)
|
||||
self.neighbour_link_tracker.refresh_config(self.config)
|
||||
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
|
||||
with self.neighbour_link_tracker.lock:
|
||||
now_monotonic = time.monotonic()
|
||||
self.neighbour_link_tracker.purge_expired_locked(now_monotonic)
|
||||
while (
|
||||
len(self.neighbour_link_tracker.links) > self.neighbour_link_tracker.max_entries
|
||||
):
|
||||
self.neighbour_link_tracker.evict_stalest_locked()
|
||||
|
||||
# Note: Radio config changes require restart as they affect hardware
|
||||
# Note: Airtime manager has its own config reference that gets updated
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from openhop_core.protocol import Packet
|
||||
from openhop_core.protocol.constants import (
|
||||
PAYLOAD_TYPE_TRACE,
|
||||
ROUTE_TYPE_FLOOD,
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NeighbourLink:
|
||||
peer_hash: str
|
||||
path_hash_size: int
|
||||
|
||||
first_seen: float
|
||||
last_seen: float
|
||||
last_seen_monotonic: float
|
||||
|
||||
sample_count: int = 0
|
||||
duplicate_sample_count: int = 0
|
||||
|
||||
last_rssi: float = 0.0
|
||||
last_snr: float = 0.0
|
||||
last_score: float = 0.0
|
||||
|
||||
ewma_rssi: float = 0.0
|
||||
ewma_snr: float = 0.0
|
||||
ewma_score: float = 0.0
|
||||
|
||||
best_score: float = 0.0
|
||||
worst_score: float = 1.0
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
now: float,
|
||||
now_monotonic: float,
|
||||
rssi: float,
|
||||
snr: float,
|
||||
score: float,
|
||||
is_duplicate: bool,
|
||||
alpha: float,
|
||||
) -> None:
|
||||
first_sample = self.sample_count == 0
|
||||
self.sample_count += 1
|
||||
if is_duplicate:
|
||||
self.duplicate_sample_count += 1
|
||||
|
||||
self.last_seen = now
|
||||
self.last_seen_monotonic = now_monotonic
|
||||
self.last_rssi = rssi
|
||||
self.last_snr = snr
|
||||
self.last_score = score
|
||||
|
||||
if first_sample:
|
||||
self.first_seen = now
|
||||
self.ewma_rssi = rssi
|
||||
self.ewma_snr = snr
|
||||
self.ewma_score = score
|
||||
self.best_score = score
|
||||
self.worst_score = score
|
||||
return
|
||||
|
||||
self.ewma_rssi = alpha * rssi + (1.0 - alpha) * self.ewma_rssi
|
||||
self.ewma_snr = alpha * snr + (1.0 - alpha) * self.ewma_snr
|
||||
self.ewma_score = alpha * score + (1.0 - alpha) * self.ewma_score
|
||||
|
||||
if score > self.best_score:
|
||||
self.best_score = score
|
||||
if score < self.worst_score:
|
||||
self.worst_score = score
|
||||
|
||||
|
||||
class NeighbourLinkTracker:
|
||||
def __init__(self, config: dict):
|
||||
self._neighbour_links: dict[str, NeighbourLink] = {}
|
||||
self._neighbour_links_lock = threading.RLock()
|
||||
|
||||
self._metrics_enabled = True
|
||||
self._ewma_alpha = 0.20
|
||||
self._ttl_seconds = 86400.0
|
||||
self._max_entries = 512
|
||||
self.refresh_config(config)
|
||||
|
||||
@property
|
||||
def links(self) -> dict[str, NeighbourLink]:
|
||||
return self._neighbour_links
|
||||
|
||||
@property
|
||||
def lock(self) -> threading.RLock:
|
||||
return self._neighbour_links_lock
|
||||
|
||||
@property
|
||||
def metrics_enabled(self) -> bool:
|
||||
return self._metrics_enabled
|
||||
|
||||
@property
|
||||
def ewma_alpha(self) -> float:
|
||||
return self._ewma_alpha
|
||||
|
||||
@property
|
||||
def ttl_seconds(self) -> float:
|
||||
return self._ttl_seconds
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return self._max_entries
|
||||
|
||||
def refresh_config(self, config: dict) -> None:
|
||||
repeater_config = config.get("repeater", {})
|
||||
|
||||
self._metrics_enabled = bool(repeater_config.get("neighbour_link_metrics_enabled", True))
|
||||
|
||||
try:
|
||||
alpha = float(repeater_config.get("neighbour_link_ewma_alpha", 0.20))
|
||||
except (TypeError, ValueError):
|
||||
alpha = 0.20
|
||||
self._ewma_alpha = max(0.0, min(1.0, alpha))
|
||||
|
||||
try:
|
||||
ttl = float(repeater_config.get("neighbour_link_ttl_seconds", 86400))
|
||||
except (TypeError, ValueError):
|
||||
ttl = 86400.0
|
||||
self._ttl_seconds = max(1.0, ttl)
|
||||
|
||||
try:
|
||||
max_entries = int(repeater_config.get("neighbour_link_max_entries", 512))
|
||||
except (TypeError, ValueError):
|
||||
max_entries = 512
|
||||
self._max_entries = max(1, max_entries)
|
||||
|
||||
def purge_expired_locked(self, now_monotonic: float) -> None:
|
||||
expired_keys = [
|
||||
key
|
||||
for key, link in self._neighbour_links.items()
|
||||
if (now_monotonic - link.last_seen_monotonic) > self._ttl_seconds
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._neighbour_links[key]
|
||||
|
||||
def evict_stalest_locked(self) -> None:
|
||||
if not self._neighbour_links:
|
||||
return
|
||||
stalest_key = min(
|
||||
self._neighbour_links,
|
||||
key=lambda key: self._neighbour_links[key].last_seen_monotonic,
|
||||
)
|
||||
del self._neighbour_links[stalest_key]
|
||||
|
||||
@staticmethod
|
||||
def get_upstream_peer_identity(
|
||||
packet: Packet,
|
||||
route_type: int,
|
||||
payload_type: Optional[int],
|
||||
*,
|
||||
path_hashes=None,
|
||||
path_hash_size: Optional[int] = None,
|
||||
) -> Tuple[Optional[str], Optional[int]]:
|
||||
if route_type not in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
||||
return None, None
|
||||
if payload_type == PAYLOAD_TYPE_TRACE:
|
||||
return None, None
|
||||
|
||||
hashes = path_hashes if path_hashes is not None else packet.get_path_hashes_hex()
|
||||
if not hashes:
|
||||
return None, None
|
||||
|
||||
size = path_hash_size
|
||||
if size is None:
|
||||
size = packet.get_path_hash_size() if hasattr(packet, "get_path_hash_size") else None
|
||||
if not size or int(size) <= 0:
|
||||
return None, None
|
||||
|
||||
peer_hash = str(hashes[-1]).upper()
|
||||
if not peer_hash:
|
||||
return None, None
|
||||
|
||||
return peer_hash, int(size)
|
||||
|
||||
def observe(
|
||||
self,
|
||||
packet: Packet,
|
||||
*,
|
||||
route_type: int,
|
||||
payload_type: Optional[int],
|
||||
rssi: float,
|
||||
snr: float,
|
||||
score: float,
|
||||
is_duplicate: bool,
|
||||
) -> None:
|
||||
if not self._metrics_enabled:
|
||||
return
|
||||
|
||||
peer_hash, path_hash_size = self.get_upstream_peer_identity(
|
||||
packet,
|
||||
route_type,
|
||||
payload_type,
|
||||
)
|
||||
if not peer_hash or not path_hash_size:
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
now_monotonic = time.monotonic()
|
||||
key = f"{path_hash_size}:{peer_hash}"
|
||||
|
||||
with self._neighbour_links_lock:
|
||||
self.purge_expired_locked(now_monotonic)
|
||||
link = self._neighbour_links.get(key)
|
||||
|
||||
if link is None and len(self._neighbour_links) >= self._max_entries:
|
||||
self.evict_stalest_locked()
|
||||
|
||||
if link is None:
|
||||
link = NeighbourLink(
|
||||
peer_hash=peer_hash,
|
||||
path_hash_size=path_hash_size,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
last_seen_monotonic=now_monotonic,
|
||||
)
|
||||
self._neighbour_links[key] = link
|
||||
|
||||
link.update(
|
||||
now=now,
|
||||
now_monotonic=now_monotonic,
|
||||
rssi=float(rssi),
|
||||
snr=float(snr),
|
||||
score=float(score),
|
||||
is_duplicate=is_duplicate,
|
||||
alpha=self._ewma_alpha,
|
||||
)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
active_within_seconds: float = 900.0,
|
||||
) -> list[dict]:
|
||||
try:
|
||||
active_window = max(0.0, float(active_within_seconds))
|
||||
except (TypeError, ValueError):
|
||||
active_window = 900.0
|
||||
|
||||
now_monotonic = time.monotonic()
|
||||
snapshot = []
|
||||
with self._neighbour_links_lock:
|
||||
self.purge_expired_locked(now_monotonic)
|
||||
for link in self._neighbour_links.values():
|
||||
age_seconds = max(0.0, now_monotonic - link.last_seen_monotonic)
|
||||
snapshot.append(
|
||||
{
|
||||
"peer_hash": link.peer_hash,
|
||||
"path_hash_size": link.path_hash_size,
|
||||
"sample_count": link.sample_count,
|
||||
"duplicate_sample_count": link.duplicate_sample_count,
|
||||
"first_seen": link.first_seen,
|
||||
"last_seen": link.last_seen,
|
||||
"age_seconds": age_seconds,
|
||||
"active": age_seconds <= active_window,
|
||||
"last_rssi": link.last_rssi,
|
||||
"last_snr": link.last_snr,
|
||||
"last_score": link.last_score,
|
||||
"ewma_rssi": link.ewma_rssi,
|
||||
"ewma_snr": link.ewma_snr,
|
||||
"ewma_score": link.ewma_score,
|
||||
"best_score": link.best_score,
|
||||
"worst_score": link.worst_score,
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(snapshot, key=lambda item: item["last_seen"], reverse=True)
|
||||
@@ -87,6 +87,8 @@ POLICY_GROUP_KINDS = {
|
||||
# GET /api/packet_stats?hours=24 - Get packet statistics
|
||||
# GET /api/packet_type_stats?hours=24 - Get packet type statistics
|
||||
# GET /api/route_stats?hours=24 - Get route statistics
|
||||
# GET /api/neighbor_links?active_within_seconds=900 - Get in-memory observed upstream neighbour links
|
||||
# GET /api/neighbor_link_history?peer_hash=AB&path_hash_size=1&hours=24&limit=1000 - Get observed upstream history from packets table
|
||||
# GET /api/recent_packets?limit=100 - Get recent packets
|
||||
# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets
|
||||
# GET /api/packet_by_hash?packet_hash=abc123 - Get specific packet by hash
|
||||
@@ -3076,6 +3078,73 @@ class APIEndpoints:
|
||||
logger.error(f"Error getting route stats: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def neighbor_links(self, active_within_seconds=90, limit=500):
|
||||
try:
|
||||
handler = getattr(self.daemon_instance, "repeater_handler", None)
|
||||
tracker = (
|
||||
getattr(handler, "neighbour_link_tracker", None) if handler is not None else None
|
||||
)
|
||||
if tracker is None or not hasattr(tracker, "snapshot"):
|
||||
return self._error("Repeater handler not initialized")
|
||||
|
||||
active_window = float(active_within_seconds)
|
||||
row_limit = int(limit)
|
||||
if row_limit < 1:
|
||||
raise ValueError("limit must be >= 1")
|
||||
|
||||
links = tracker.snapshot(active_within_seconds=active_window)
|
||||
links = links[:row_limit]
|
||||
return self._success(
|
||||
{
|
||||
"links": links,
|
||||
"active_within_seconds": active_window,
|
||||
"limit": row_limit,
|
||||
"count": len(links),
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return self._error(f"Invalid parameter format: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neighbor links: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def neighbor_link_history(self, peer_hash=None, path_hash_size=None, hours=24, limit=1000):
|
||||
try:
|
||||
if not peer_hash:
|
||||
return self._error("peer_hash parameter required")
|
||||
if path_hash_size is None:
|
||||
return self._error("path_hash_size parameter required")
|
||||
|
||||
size = int(path_hash_size)
|
||||
window_hours = int(hours)
|
||||
row_limit = int(limit)
|
||||
|
||||
rows = self._get_storage().get_neighbor_link_history(
|
||||
peer_hash=str(peer_hash),
|
||||
path_hash_size=size,
|
||||
hours=window_hours,
|
||||
limit=row_limit,
|
||||
)
|
||||
return self._success(
|
||||
{
|
||||
"peer_hash": str(peer_hash).upper(),
|
||||
"path_hash_size": size,
|
||||
"hours": window_hours,
|
||||
"limit": row_limit,
|
||||
"rows": rows,
|
||||
"count": len(rows),
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return self._error(f"Invalid parameter format: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting neighbor link history: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def recent_packets(self, limit=100):
|
||||
|
||||
+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
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
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
+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
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-BmJnUIFH.js";import{t as d}from"./index-DF7On1cp.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-xl sm:text-2xl font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-sm 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-Cf4Quxb7.js";import{t as d}from"./index-DJjJNEBn.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-xl sm:text-2xl font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-sm 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-BmJnUIFH.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-Cf4Quxb7.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-DF7On1cp.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-CMJUE3iy.js";import{d as p}from"./index-DJjJNEBn.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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-auvhoQZt.js";export{e as useDataService};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./dataService-DRVYk7u4.js";export{e as useDataService};
|
||||
+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
@@ -1 +0,0 @@
|
||||
import{t as e}from"./packets-BoR1a5V0.js";export{e as usePacketStore};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{t as e}from"./packets-CgbgLl5w.js";export{e as usePacketStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./api-D8WOXa6q.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};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./system-BmJnUIFH.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-GPpxWKdp.js";import{t as o}from"./packets-BoR1a5V0.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-D8WOXa6q.js";import{t as o}from"./packets-CgbgLl5w.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};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./system-Cf4Quxb7.js";export{e as useSystemStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./websocket-DqKUMFFE.js";export{e as useWebSocketStore};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./websocket-CNooJnNy.js";export{e as useWebSocketStore};
|
||||
+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-GPpxWKdp.js";import{t as s}from"./packets-BoR1a5V0.js";import{t as c}from"./system-BmJnUIFH.js";import{t as l}from"./dataService-DRVYk7u4.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-D8WOXa6q.js";import{t as s}from"./packets-CgbgLl5w.js";import{t as c}from"./system-Cf4Quxb7.js";import{t as l}from"./dataService-auvhoQZt.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};
|
||||
@@ -8,20 +8,20 @@
|
||||
<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-DF7On1cp.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DJjJNEBn.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-GPpxWKdp.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/api-D8WOXa6q.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-D-_sbJKW.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-BoR1a5V0.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-BmJnUIFH.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dataService-DRVYk7u4.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-CNooJnNy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/packets-CgbgLl5w.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-Cf4Quxb7.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dataService-auvhoQZt.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-DqKUMFFE.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/constants-C3rXUIAq.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CD3mpKIx.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B230IDp9.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -961,6 +961,83 @@ paths:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/neighbor_links:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Get observed neighbour link snapshots
|
||||
description: Returns observation-only neighbour link metrics by upstream peer hash.
|
||||
parameters:
|
||||
- name: active_within_seconds
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 90
|
||||
minimum: 1
|
||||
description: Mark links as active when last-seen age is within this threshold.
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 500
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
description: Maximum number of link snapshots to return.
|
||||
responses:
|
||||
'200':
|
||||
description: Neighbour link snapshots
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NeighborLinksResponse'
|
||||
|
||||
/neighbor_link_history:
|
||||
get:
|
||||
tags: [Packets]
|
||||
summary: Get neighbour link packet history
|
||||
description: Returns historical packet observations for a single upstream peer hash.
|
||||
parameters:
|
||||
- name: peer_hash
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Upstream peer hash to query.
|
||||
- name: path_hash_size
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3
|
||||
description: Path hash size in bytes for the peer hash.
|
||||
- name: hours
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 24
|
||||
minimum: 1
|
||||
maximum: 168
|
||||
description: Time window in hours.
|
||||
- name: limit
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
default: 1000
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
description: Maximum rows to return.
|
||||
responses:
|
||||
'200':
|
||||
description: Neighbour link history
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NeighborLinkHistoryResponse'
|
||||
|
||||
# ============================================================================
|
||||
# Charts & RRD Endpoints
|
||||
# ============================================================================
|
||||
@@ -4308,6 +4385,116 @@ components:
|
||||
type: string
|
||||
description: Error message
|
||||
|
||||
NeighborLinkSnapshot:
|
||||
type: object
|
||||
properties:
|
||||
peer_hash:
|
||||
type: string
|
||||
path_hash_size:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3
|
||||
sample_count:
|
||||
type: integer
|
||||
duplicate_sample_count:
|
||||
type: integer
|
||||
first_seen:
|
||||
type: integer
|
||||
last_seen:
|
||||
type: integer
|
||||
age_seconds:
|
||||
type: number
|
||||
active:
|
||||
type: boolean
|
||||
last_rssi:
|
||||
type: number
|
||||
last_snr:
|
||||
type: number
|
||||
last_score:
|
||||
type: number
|
||||
ewma_rssi:
|
||||
type: number
|
||||
ewma_snr:
|
||||
type: number
|
||||
ewma_score:
|
||||
type: number
|
||||
best_score:
|
||||
type: number
|
||||
worst_score:
|
||||
type: number
|
||||
|
||||
NeighborLinksData:
|
||||
type: object
|
||||
properties:
|
||||
links:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NeighborLinkSnapshot'
|
||||
active_within_seconds:
|
||||
type: integer
|
||||
count:
|
||||
type: integer
|
||||
|
||||
NeighborLinksResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
$ref: '#/components/schemas/NeighborLinksData'
|
||||
|
||||
NeighborLinkHistoryRow:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: integer
|
||||
rssi:
|
||||
type: number
|
||||
nullable: true
|
||||
snr:
|
||||
type: number
|
||||
nullable: true
|
||||
score:
|
||||
type: number
|
||||
nullable: true
|
||||
is_duplicate:
|
||||
type: boolean
|
||||
packet_hash:
|
||||
type: string
|
||||
packet_type:
|
||||
type: integer
|
||||
route_type:
|
||||
type: integer
|
||||
path_hop_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
NeighborLinkHistoryData:
|
||||
type: object
|
||||
properties:
|
||||
peer_hash:
|
||||
type: string
|
||||
path_hash_size:
|
||||
type: integer
|
||||
hours:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NeighborLinkHistoryRow'
|
||||
count:
|
||||
type: integer
|
||||
|
||||
NeighborLinkHistoryResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
data:
|
||||
$ref: '#/components/schemas/NeighborLinkHistoryData'
|
||||
|
||||
LbtDiagnosticsResponse:
|
||||
type: object
|
||||
required: [start_time, end_time, bucket_seconds, severe_attempt_threshold, summary, buckets, packet_types, packet_type_buckets, correlations, limitations]
|
||||
|
||||
@@ -720,6 +720,53 @@ def test_packet_and_route_stats_endpoints(cherrypy_ctx):
|
||||
storage.get_route_stats.assert_called_once_with(hours=6)
|
||||
|
||||
|
||||
def test_neighbor_links_endpoint_returns_snapshot(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
tracker = SimpleNamespace(
|
||||
snapshot=MagicMock(
|
||||
return_value=[
|
||||
{"peer_hash": "AB", "sample_count": 2},
|
||||
{"peer_hash": "CD", "sample_count": 1},
|
||||
]
|
||||
)
|
||||
)
|
||||
repeater_handler = SimpleNamespace(neighbour_link_tracker=tracker)
|
||||
api.daemon_instance = SimpleNamespace(repeater_handler=repeater_handler)
|
||||
|
||||
result = api.neighbor_links(active_within_seconds="120", limit="1")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["count"] == 1
|
||||
assert result["data"]["active_within_seconds"] == 120.0
|
||||
assert result["data"]["limit"] == 1
|
||||
assert result["data"]["links"][0]["peer_hash"] == "AB"
|
||||
tracker.snapshot.assert_called_once_with(active_within_seconds=120.0)
|
||||
|
||||
|
||||
def test_neighbor_link_history_endpoint_filters_by_hash_and_size(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
rows = [{"packet_hash": "A1", "path_hop_count": 3}]
|
||||
storage = SimpleNamespace(get_neighbor_link_history=MagicMock(return_value=rows))
|
||||
_attach_storage(api, storage)
|
||||
|
||||
result = api.neighbor_link_history(peer_hash="ab", path_hash_size="2", hours="12", limit="50")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["data"]["peer_hash"] == "AB"
|
||||
assert result["data"]["path_hash_size"] == 2
|
||||
assert result["data"]["hours"] == 12
|
||||
assert result["data"]["limit"] == 50
|
||||
assert result["data"]["rows"] == rows
|
||||
storage.get_neighbor_link_history.assert_called_once_with(
|
||||
peer_hash="ab",
|
||||
path_hash_size=2,
|
||||
hours=12,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
|
||||
def test_recent_packets_and_bulk_packets(cherrypy_ctx):
|
||||
del cherrypy_ctx
|
||||
api = _make_api()
|
||||
|
||||
@@ -15,6 +15,7 @@ import pytest
|
||||
from openhop_core.protocol import Packet, PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
PAYLOAD_TYPE_TRACE,
|
||||
PH_ROUTE_MASK,
|
||||
PH_TYPE_SHIFT,
|
||||
ROUTE_TYPE_DIRECT,
|
||||
@@ -171,6 +172,28 @@ def _make_transport_direct_packet(
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_hashed_flood_packet(path_hashes: list[str], hash_size: int = 1, payload_type: int = 0x01):
|
||||
path_bytes = bytearray()
|
||||
for hash_hex in path_hashes:
|
||||
path_bytes.extend(bytes.fromhex(hash_hex))
|
||||
|
||||
pkt = _make_flood_packet(
|
||||
payload=b"\xaa\xbb\xcc", path=bytes(path_bytes), payload_type=payload_type
|
||||
)
|
||||
pkt.path_len = PathUtils.encode_path_len(hash_size, len(path_hashes))
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_hashed_transport_flood_packet(path_hashes: list[str], hash_size: int = 1):
|
||||
path_bytes = bytearray()
|
||||
for hash_hex in path_hashes:
|
||||
path_bytes.extend(bytes.fromhex(hash_hex))
|
||||
|
||||
pkt = _make_transport_flood_packet(payload=b"\x10\x20\x30", path=bytes(path_bytes))
|
||||
pkt.path_len = PathUtils.encode_path_len(hash_size, len(path_hashes))
|
||||
return pkt
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. flood_forward
|
||||
# ===================================================================
|
||||
@@ -1010,6 +1033,187 @@ class TestStatistics:
|
||||
assert stats["local_hash"] == f"0x{LOCAL_HASH:02x}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestNeighbourLinkObservation:
|
||||
@staticmethod
|
||||
def _observe_with_tracker(handler, pkt, *, rssi: float, snr: float, is_duplicate: bool) -> None:
|
||||
route_type = pkt.header & PH_ROUTE_MASK
|
||||
payload_type = pkt.get_payload_type() if hasattr(pkt, "get_payload_type") else None
|
||||
score = handler.calculate_packet_score(
|
||||
snr,
|
||||
len(pkt.payload or b""),
|
||||
handler.radio_config["spreading_factor"],
|
||||
)
|
||||
handler.neighbour_link_tracker.observe(
|
||||
pkt,
|
||||
route_type=route_type,
|
||||
payload_type=payload_type,
|
||||
rssi=rssi,
|
||||
snr=snr,
|
||||
score=score,
|
||||
is_duplicate=is_duplicate,
|
||||
)
|
||||
|
||||
async def test_received_flood_with_non_empty_path_creates_link_for_final_hash(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_flood_packet(["11", "22", "33"], hash_size=1)
|
||||
|
||||
await handler(pkt, {"rssi": -77, "snr": 5.5}, local_transmission=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert len(snapshot) == 1
|
||||
assert snapshot[0]["peer_hash"] == "33"
|
||||
assert snapshot[0]["path_hash_size"] == 1
|
||||
|
||||
async def test_uses_last_path_element_not_first_for_upstream_peer(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_flood_packet(["AA", "BB", "CC"], hash_size=1)
|
||||
|
||||
await handler(pkt, {"rssi": -80, "snr": 1.0}, local_transmission=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert snapshot[0]["peer_hash"] == "CC"
|
||||
assert snapshot[0]["peer_hash"] != "AA"
|
||||
|
||||
async def test_empty_path_flood_creates_no_neighbour_link(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_flood_packet([], hash_size=1)
|
||||
|
||||
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
|
||||
|
||||
assert handler.neighbour_link_tracker.snapshot() == []
|
||||
|
||||
async def test_direct_packets_create_no_neighbour_link(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0x44]))
|
||||
|
||||
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
|
||||
|
||||
assert handler.neighbour_link_tracker.snapshot() == []
|
||||
|
||||
async def test_trace_packets_create_no_neighbour_link(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1, payload_type=PAYLOAD_TYPE_TRACE)
|
||||
|
||||
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
|
||||
|
||||
assert handler.neighbour_link_tracker.snapshot() == []
|
||||
|
||||
async def test_local_transmissions_create_no_neighbour_link(self, handler):
|
||||
handler.config["repeater"]["mode"] = "no_tx"
|
||||
pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1)
|
||||
|
||||
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=True)
|
||||
|
||||
assert handler.neighbour_link_tracker.snapshot() == []
|
||||
|
||||
async def test_transport_flood_is_observed(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_transport_flood_packet(["19", "2A", "3B"], hash_size=1)
|
||||
|
||||
await handler(pkt, {"rssi": -60, "snr": 4.0}, local_transmission=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert len(snapshot) == 1
|
||||
assert snapshot[0]["peer_hash"] == "3B"
|
||||
|
||||
async def test_existing_packet_score_calculation_is_reused_unchanged(self, handler):
|
||||
handler.config["repeater"]["mode"] = "monitor"
|
||||
pkt = _make_hashed_flood_packet(["55"], hash_size=1)
|
||||
with patch.object(handler, "calculate_packet_score", return_value=0.42) as score_mock:
|
||||
await handler(pkt, {"rssi": -81.0, "snr": 3.25}, local_transmission=False)
|
||||
|
||||
score_mock.assert_any_call(
|
||||
3.25,
|
||||
len(pkt.payload or b""),
|
||||
handler.radio_config["spreading_factor"],
|
||||
)
|
||||
assert score_mock.call_count >= 1
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert snapshot[0]["last_score"] == pytest.approx(0.42)
|
||||
|
||||
def test_first_sample_initializes_ewma_directly(self, handler):
|
||||
pkt = _make_hashed_flood_packet(["66"], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-90.0, snr=7.0, is_duplicate=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert snapshot[0]["ewma_rssi"] == pytest.approx(-90.0)
|
||||
assert snapshot[0]["ewma_snr"] == pytest.approx(7.0)
|
||||
assert snapshot[0]["ewma_score"] == pytest.approx(snapshot[0]["last_score"])
|
||||
|
||||
def test_later_samples_apply_configured_ewma_alpha(self, handler):
|
||||
handler.config["repeater"]["neighbour_link_ewma_alpha"] = 0.5
|
||||
handler.reload_runtime_config()
|
||||
|
||||
pkt = _make_hashed_flood_packet(["77"], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-100.0, snr=1.0, is_duplicate=False)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-80.0, snr=5.0, is_duplicate=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert snapshot[0]["ewma_rssi"] == pytest.approx(-90.0)
|
||||
assert snapshot[0]["ewma_snr"] == pytest.approx(3.0)
|
||||
|
||||
def test_duplicate_samples_increment_duplicate_sample_count(self, handler):
|
||||
pkt = _make_hashed_flood_packet(["88"], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-75.0, snr=2.0, is_duplicate=False)
|
||||
handler.record_duplicate(pkt, rssi=-74, snr=1.5)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert snapshot[0]["sample_count"] == 2
|
||||
assert snapshot[0]["duplicate_sample_count"] == 1
|
||||
|
||||
def test_different_path_hash_widths_do_not_merge(self, handler):
|
||||
pkt_1b = _make_hashed_flood_packet(["AB"], hash_size=1)
|
||||
pkt_2b = _make_hashed_flood_packet(["00AB"], hash_size=2)
|
||||
|
||||
self._observe_with_tracker(handler, pkt_1b, rssi=-70.0, snr=3.0, is_duplicate=False)
|
||||
self._observe_with_tracker(handler, pkt_2b, rssi=-71.0, snr=3.1, is_duplicate=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert len(snapshot) == 2
|
||||
keys = {(row["path_hash_size"], row["peer_hash"]) for row in snapshot}
|
||||
assert keys == {(1, "AB"), (2, "00AB")}
|
||||
|
||||
def test_link_state_is_bounded(self, handler):
|
||||
handler.config["repeater"]["neighbour_link_max_entries"] = 2
|
||||
handler.config["repeater"]["neighbour_link_ttl_seconds"] = 86400
|
||||
handler.reload_runtime_config()
|
||||
|
||||
for peer in ("10", "20", "30"):
|
||||
pkt = _make_hashed_flood_packet([peer], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-70.0, snr=2.0, is_duplicate=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
assert len(snapshot) == 2
|
||||
peers = {row["peer_hash"] for row in snapshot}
|
||||
assert peers == {"20", "30"}
|
||||
|
||||
def test_expired_links_are_removed_using_monotonic_time(self, handler):
|
||||
handler.config["repeater"]["neighbour_link_ttl_seconds"] = 1
|
||||
handler.reload_runtime_config()
|
||||
|
||||
pkt = _make_hashed_flood_packet(["44"], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-65.0, snr=6.0, is_duplicate=False)
|
||||
|
||||
with handler.neighbour_link_tracker.lock:
|
||||
for link in handler.neighbour_link_tracker.links.values():
|
||||
link.last_seen_monotonic = time.monotonic() - 5.0
|
||||
|
||||
assert handler.neighbour_link_tracker.snapshot() == []
|
||||
|
||||
def test_snapshot_returns_plain_data_not_live_mapping(self, handler):
|
||||
pkt = _make_hashed_flood_packet(["99"], hash_size=1)
|
||||
self._observe_with_tracker(handler, pkt, rssi=-64.0, snr=6.0, is_duplicate=False)
|
||||
|
||||
snapshot = handler.neighbour_link_tracker.snapshot()
|
||||
snapshot[0]["sample_count"] = 999
|
||||
snapshot[0]["peer_hash"] = "MUTATED"
|
||||
|
||||
fresh = handler.neighbour_link_tracker.snapshot()
|
||||
assert fresh[0]["sample_count"] != 999
|
||||
assert fresh[0]["peer_hash"] == "99"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 15. Edge cases and regression tests
|
||||
# ===================================================================
|
||||
|
||||
@@ -656,9 +656,7 @@ async def test_protocol_request_real_crypto_consume_vs_collision_forward():
|
||||
genuine, _ = PacketBuilder.create_protocol_request(
|
||||
_SendDest(local.get_public_key()), sender, REQ_TYPE_GET_STATUS
|
||||
)
|
||||
with patch(
|
||||
"repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock
|
||||
):
|
||||
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
|
||||
assert await helper.process_request_packet(genuine) is True
|
||||
assert genuine.is_marked_do_not_retransmit()
|
||||
injector.assert_awaited() # a response was transmitted
|
||||
|
||||
@@ -200,6 +200,114 @@ def test_store_packet_returns_inserted_row_id(tmp_path):
|
||||
assert row[3] == 3
|
||||
|
||||
|
||||
def test_packets_table_has_upstream_columns_and_index(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
with h._connect() as conn:
|
||||
cols = conn.execute("PRAGMA table_info(packets)").fetchall()
|
||||
col_names = {col[1] for col in cols}
|
||||
idx_rows = conn.execute("PRAGMA index_list(packets)").fetchall()
|
||||
idx_names = {row[1] for row in idx_rows}
|
||||
|
||||
assert "upstream_hash" in col_names
|
||||
assert "upstream_hash_size" in col_names
|
||||
assert "idx_packets_upstream_time" in idx_names
|
||||
|
||||
|
||||
def test_store_packet_persists_upstream_fields(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
packet_id = h.store_packet(
|
||||
{
|
||||
"timestamp": 200.0,
|
||||
"type": 1,
|
||||
"route": 1,
|
||||
"length": 9,
|
||||
"transmitted": False,
|
||||
"packet_hash": "pkt-upstream",
|
||||
"upstream_hash": "AB",
|
||||
"upstream_hash_size": 2,
|
||||
"original_path": ["CD", "AB"],
|
||||
}
|
||||
)
|
||||
|
||||
row = h.get_packet_by_id(int(packet_id))
|
||||
assert row is not None
|
||||
assert row["upstream_hash"] == "AB"
|
||||
assert row["upstream_hash_size"] == 2
|
||||
|
||||
|
||||
def test_neighbor_link_history_uses_packets_table_and_filters_hash_and_size(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
base_ts = 1_700_000_000.0
|
||||
|
||||
h.store_packet(
|
||||
{
|
||||
"timestamp": base_ts,
|
||||
"type": 4,
|
||||
"route": 1,
|
||||
"length": 10,
|
||||
"rssi": -80,
|
||||
"snr": 3.5,
|
||||
"score": 0.4,
|
||||
"is_duplicate": False,
|
||||
"packet_hash": "match-1",
|
||||
"upstream_hash": "AA",
|
||||
"upstream_hash_size": 1,
|
||||
"original_path": ["10", "AA"],
|
||||
}
|
||||
)
|
||||
h.store_packet(
|
||||
{
|
||||
"timestamp": base_ts + 1.0,
|
||||
"type": 5,
|
||||
"route": 1,
|
||||
"length": 11,
|
||||
"rssi": -82,
|
||||
"snr": 2.5,
|
||||
"score": 0.35,
|
||||
"is_duplicate": True,
|
||||
"packet_hash": "match-2",
|
||||
"upstream_hash": "AA",
|
||||
"upstream_hash_size": 1,
|
||||
"original_path": ["20", "30", "AA"],
|
||||
}
|
||||
)
|
||||
# Same hash but different width: must not be merged.
|
||||
h.store_packet(
|
||||
{
|
||||
"timestamp": base_ts + 2.0,
|
||||
"type": 6,
|
||||
"route": 1,
|
||||
"length": 12,
|
||||
"packet_hash": "wrong-width",
|
||||
"upstream_hash": "AA",
|
||||
"upstream_hash_size": 2,
|
||||
"original_path": ["00AA"],
|
||||
}
|
||||
)
|
||||
# Different hash: must be filtered out.
|
||||
h.store_packet(
|
||||
{
|
||||
"timestamp": base_ts + 3.0,
|
||||
"type": 7,
|
||||
"route": 1,
|
||||
"length": 13,
|
||||
"packet_hash": "wrong-hash",
|
||||
"upstream_hash": "BB",
|
||||
"upstream_hash_size": 1,
|
||||
"original_path": ["BB"],
|
||||
}
|
||||
)
|
||||
|
||||
rows = h.get_neighbor_link_history(peer_hash="aa", path_hash_size=1, hours=50000, limit=100)
|
||||
|
||||
assert [row["packet_hash"] for row in rows] == ["match-1", "match-2"]
|
||||
assert rows[0]["path_hop_count"] == 2
|
||||
assert rows[1]["path_hop_count"] == 3
|
||||
assert rows[1]["is_duplicate"] is True
|
||||
|
||||
|
||||
def test_recent_packet_queries_include_ids_and_preserve_duplicate_hash_rows(tmp_path):
|
||||
h = _make_handler(tmp_path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user