From acf80797611c328723d74a00046f233e9a105729 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Sat, 11 Apr 2026 16:09:14 -0700
Subject: [PATCH 01/72] feat: Merge mqtt handler and letsmesh handlers
---
repeater/config.py | 26 +-
repeater/data_acquisition/__init__.py | 5 +-
repeater/data_acquisition/letsmesh_handler.py | 694 ---------------
repeater/data_acquisition/mqtt_handler.py | 811 +++++++++++++++---
.../data_acquisition/storage_collector.py | 84 +-
repeater/engine.py | 2 +-
repeater/web/api_endpoints.py | 178 ++--
7 files changed, 841 insertions(+), 959 deletions(-)
delete mode 100644 repeater/data_acquisition/letsmesh_handler.py
diff --git a/repeater/config.py b/repeater/config.py
index 604a85f..be8e095 100644
--- a/repeater/config.py
+++ b/repeater/config.py
@@ -11,13 +11,13 @@ logger = logging.getLogger("Config")
def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
"""
- Extract node name, radio configuration, and LetsMesh settings from config.
+ Extract node name, radio configuration, and MQTT settings from config.
Args:
config: Configuration dictionary
Returns:
- Dictionary with node_name, radio_config, and LetsMesh configuration
+ Dictionary with node_name, radio_config, and MQTT configuration
"""
node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater")
radio_config = config.get("radio", {})
@@ -30,26 +30,16 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
radio_bw_khz = radio_bw / 1_000
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
- letsmesh_config = config.get("letsmesh", {})
-
- from pymc_core.protocol.utils import PAYLOAD_TYPES
-
- disallowed_types = letsmesh_config.get("disallowed_packet_types", [])
- type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
-
- disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
- disallowed_hex = [val for val in disallowed_hex if val is not None] # Filter out invalid names
+ mqtt_config = config.get("mqtt", {})
return {
"node_name": node_name,
"radio_config": radio_config_str,
- "iata_code": letsmesh_config.get("iata_code", "TEST"),
- "broker_index": letsmesh_config.get("broker_index", 0),
- "status_interval": letsmesh_config.get("status_interval", 60),
- "model": letsmesh_config.get("model", "PyMC-Repeater"),
- "disallowed_packet_types": disallowed_hex,
- "email": letsmesh_config.get("email", ""),
- "owner": letsmesh_config.get("owner", ""),
+ "iata_code": mqtt_config.get("iata_code", "TEST"),
+ "status_interval": mqtt_config.get("status_interval", 60),
+ "model": mqtt_config.get("model", "PyMC-Repeater"),
+ "email": mqtt_config.get("email", ""),
+ "owner": mqtt_config.get("owner", ""),
}
diff --git a/repeater/data_acquisition/__init__.py b/repeater/data_acquisition/__init__.py
index 14a0e13..2ae1029 100644
--- a/repeater/data_acquisition/__init__.py
+++ b/repeater/data_acquisition/__init__.py
@@ -1,6 +1,7 @@
-from .mqtt_handler import MQTTHandler
+#from .mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_collector import StorageCollector
-__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
+#__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
+__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector"]
diff --git a/repeater/data_acquisition/letsmesh_handler.py b/repeater/data_acquisition/letsmesh_handler.py
deleted file mode 100644
index e1b74f7..0000000
--- a/repeater/data_acquisition/letsmesh_handler.py
+++ /dev/null
@@ -1,694 +0,0 @@
-import base64
-import binascii
-import json
-import logging
-import threading
-from datetime import datetime, timedelta
-from typing import Callable, Dict, List, Optional
-
-import paho.mqtt.client as mqtt
-from nacl.signing import SigningKey
-
-# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc
-try:
- from datetime import UTC
-except Exception:
- from datetime import timezone
- UTC = timezone.utc
-
-from repeater import __version__
-
-# Try to import paho-mqtt error code mappings
-try:
- from paho.mqtt.reasoncodes import ReasonCode
-
- HAS_REASON_CODES = True
-except ImportError:
- HAS_REASON_CODES = False
-
-logger = logging.getLogger("LetsMeshHandler")
-
-
-# --------------------------------------------------------------------
-# Helper: Base64URL without padding
-# --------------------------------------------------------------------
-def b64url(x: bytes) -> str:
- return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
-
-
-# --------------------------------------------------------------------
-# Let's Mesh MQTT Broker List (WebSocket Secure)
-# --------------------------------------------------------------------
-LETSMESH_BROKERS = [
- {
- "name": "Europe (LetsMesh v1)",
- "host": "mqtt-eu-v1.letsmesh.net",
- "port": 443,
- "audience": "mqtt-eu-v1.letsmesh.net",
- },
- {
- "name": "US West (LetsMesh v1)",
- "host": "mqtt-us-v1.letsmesh.net",
- "port": 443,
- "audience": "mqtt-us-v1.letsmesh.net",
- },
-]
-
-
-# ====================================================================
-# Single Broker Connection Manager
-# ====================================================================
-class _BrokerConnection:
- """
- Manages a single MQTT broker connection with independent lifecycle.
- Internal class - not exposed publicly.
- """
-
- def __init__(
- self,
- broker: dict,
- local_identity,
- public_key: str,
- iata_code: str,
- jwt_expiry_minutes: int,
- use_tls: bool,
- email: str,
- owner: str,
- broker_index: int = 0,
- on_connect_callback: Optional[Callable] = None,
- on_disconnect_callback: Optional[Callable] = None,
- ):
- self.broker = broker
- self.local_identity = local_identity
- self.public_key = public_key.upper()
- self.iata_code = iata_code
- self.jwt_expiry_minutes = jwt_expiry_minutes
- self.broker_index = broker_index
- self.use_tls = use_tls
- self.email = email
- self.owner = owner
- self._on_connect_callback = on_connect_callback
- self._on_disconnect_callback = on_disconnect_callback
- self._connect_time = None
- self._tls_verified = False
- self._running = False
- self._reconnect_attempts = 0
- self._reconnect_timer = None
- self._max_reconnect_delay = 300 # 5 minutes max
- self._jwt_refresh_timer = None
- client_id = f"meshcore_{self.public_key}_{broker['host']}"
- self.client = mqtt.Client(client_id=client_id, transport="websockets")
- self.client.on_connect = self._on_connect
- self.client.on_disconnect = self._on_disconnect
-
- def _generate_jwt(self) -> str:
- """Generate MeshCore-style Ed25519 JWT token"""
- now = datetime.now(UTC)
-
- header = {"alg": "Ed25519", "typ": "JWT"}
-
- payload = {
- "publicKey": self.public_key.upper(),
- "aud": self.broker["audience"],
- "iat": int(now.timestamp()),
- "exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()),
- }
-
- # Only include email/owner for verified TLS connections
- if self.use_tls and self._tls_verified and (self.email or self.owner):
- payload["email"] = self.email
- payload["owner"] = self.owner
- else:
- payload["email"] = ""
- payload["owner"] = ""
-
- # Encode header and payload (compact JSON - no spaces)
- header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
- payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
-
- signing_input = f"{header_b64}.{payload_b64}".encode()
-
- # Sign using LocalIdentity (supports both standard and firmware keys)
- try:
- signature = self.local_identity.sign(signing_input)
- except Exception as e:
- logger.error(f"JWT signing failed for {self.broker['name']}: {e}")
- logger.error(f" - public_key: {self.public_key}")
- logger.error(f" - signing_input length: {len(signing_input)}")
- raise
-
- signature_hex = binascii.hexlify(signature).decode()
- token = f"{header_b64}.{payload_b64}.{signature_hex}"
-
- logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
-
- return token
-
- def _on_connect(self, client, userdata, flags, rc):
- """MQTT connection callback"""
- if rc == 0:
- logger.info(f"Connected to {self.broker['name']}")
- self._running = True
- self._reconnect_attempts = 0 # Reset counter on success
- self._schedule_jwt_refresh() # Schedule proactive JWT refresh
- if self._on_connect_callback:
- self._on_connect_callback(self.broker["name"])
- else:
- error_msg = get_mqtt_error_message(rc, is_disconnect=False)
- logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
- self._schedule_reconnect()
-
- def _on_disconnect(self, client, userdata, rc):
- """MQTT disconnection callback"""
- was_running = self._running
- self._running = False
-
- if rc != 0: # Unexpected disconnect
- error_msg = get_mqtt_error_message(rc, is_disconnect=True)
- logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
- if was_running: # Only reconnect if we were intentionally connected
- self._schedule_reconnect(reason=error_msg)
- else:
- logger.info(f"Clean disconnect from {self.broker['name']}")
-
- if self._on_disconnect_callback:
- self._on_disconnect_callback(self.broker["name"])
-
- def _schedule_reconnect(self, reason: str = "connection lost"):
- """Schedule reconnection with exponential backoff"""
- if self._reconnect_timer:
- self._reconnect_timer.cancel()
-
- # Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
- delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
- self._reconnect_attempts += 1
-
- logger.info(
- f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})"
- )
- self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason))
- self._reconnect_timer.daemon = True
- self._reconnect_timer.start()
-
- def _attempt_reconnect(self, reason: str = "connection lost"):
- """Attempt to reconnect to broker with fresh JWT"""
- try:
- logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
-
- # Stop the loop if it's still running (websocket mode requires clean restart)
- try:
- self.client.loop_stop()
- except:
- pass
-
- self._set_jwt_credentials()
-
- # Reconnect and restart loop
- self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
- self.client.loop_start()
- self._loop_running = True
- except Exception as e:
- logger.error(f"Reconnection failed for {self.broker['name']}: {e}")
- self._schedule_reconnect() # Try again later
-
- def _set_jwt_credentials(self):
- """Set JWT token credentials before connecting (CONNECT handshake only)"""
- try:
- token = self._generate_jwt()
- username = f"v1_{self.public_key}"
- self.client.username_pw_set(username=username, password=token)
- self._connect_time = datetime.now(UTC)
- logger.debug(f"JWT credentials set for {self.broker['name']}")
- logger.debug(f"Using username: {username}")
- logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
- except Exception as e:
- logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
- raise
-
- def connect(self):
- """Establish connection to broker"""
- # Conditional TLS setup
- if self.use_tls:
- import ssl
-
- self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
- self.client.tls_insecure_set(False)
- self._tls_verified = True
- protocol = "wss"
- else:
- protocol = "ws"
-
- # Set JWT credentials before CONNECT handshake
- self._set_jwt_credentials()
-
- logger.info(
- f"Connecting to {self.broker['name']} "
- f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..."
- )
-
- self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
- self.client.loop_start()
- self._loop_running = True
-
- def disconnect(self):
- """Disconnect from broker"""
- self._running = False
- self._loop_running = False
-
- # Cancel any pending timers
- if self._reconnect_timer:
- self._reconnect_timer.cancel()
- self._reconnect_timer = None
- if self._jwt_refresh_timer:
- self._jwt_refresh_timer.cancel()
- self._jwt_refresh_timer = None
-
- self.client.loop_stop()
- self.client.disconnect()
- logger.info(f"Disconnected from {self.broker['name']}")
-
- def publish(self, topic: str, payload: str, retain: bool = False):
- """Publish message to broker"""
- if self._running:
- result = self.client.publish(topic, payload, retain=retain)
- return result
- return None
-
- def is_connected(self) -> bool:
- """Check if connection is active"""
- return self._running
-
- def has_pending_reconnect(self) -> bool:
- """Check if a reconnection is scheduled"""
- return self._reconnect_timer is not None and self._reconnect_timer.is_alive()
-
- def should_reconnect_for_token_expiry(self) -> bool:
- """Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)"""
- if not self._connect_time:
- return False
- elapsed = (datetime.now(UTC) - self._connect_time).total_seconds()
- expiry_seconds = self.jwt_expiry_minutes * 60
- # Stagger refresh by 5% per broker to prevent simultaneous disconnects
- # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
- stagger_offset = self.broker_index * 0.05
- refresh_threshold = 0.80 + stagger_offset
- return elapsed >= expiry_seconds * refresh_threshold
-
- def _schedule_jwt_refresh(self):
- """Schedule proactive JWT refresh before token expires"""
- if self._jwt_refresh_timer:
- self._jwt_refresh_timer.cancel()
-
- expiry_seconds = self.jwt_expiry_minutes * 60
- # Stagger refresh by 5% per broker to prevent simultaneous disconnects
- # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
- stagger_offset = self.broker_index * 0.05
- refresh_threshold = 0.80 + stagger_offset
- refresh_delay = expiry_seconds * refresh_threshold
-
- logger.info(
- f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s "
- f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)"
- )
- self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry)
- self._jwt_refresh_timer.daemon = True
- self._jwt_refresh_timer.start()
-
- def reconnect_for_token_expiry(self):
- """Proactively reconnect with new JWT before current one expires"""
- if not self._running:
- return
-
- logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
- self._running = False
- self._jwt_refresh_timer = None
-
- self._schedule_reconnect(reason="JWT token expiry")
- self.client.disconnect()
-
-
-# ====================================================================
-# MeshCore → MQTT Publisher with Ed25519 auth token
-# ====================================================================
-class MeshCoreToMqttJwtPusher:
-
- def __init__(
- self,
- local_identity,
- config: dict,
- jwt_expiry_minutes: int = 10,
- use_tls: bool = True,
- stats_provider: Optional[Callable[[], dict]] = None,
- ):
- # Store local identity and get public key
- self.local_identity = local_identity
- public_key = local_identity.get_public_key().hex().upper()
-
- # Extract values from config
- from ..config import get_node_info
-
- node_info = get_node_info(config)
-
- iata_code = node_info["iata_code"]
- broker_index = node_info.get("broker_index")
- self.email = node_info.get("email", "")
- self.owner = node_info.get("owner", "")
- status_interval = node_info["status_interval"]
- node_name = node_info["node_name"]
- radio_config = node_info["radio_config"]
-
- # Get additional brokers from config (optional)
- letsmesh_config = config.get("letsmesh", {})
- additional_brokers = letsmesh_config.get("additional_brokers", [])
-
- # Determine which brokers to connect to
- if broker_index == -2:
- # Custom brokers only - no built-in brokers
- self.brokers = []
- logger.info("Custom broker mode: using only user-defined brokers")
- elif broker_index is None or broker_index == -1:
- # Connect to all built-in brokers + additional ones
- self.brokers = LETSMESH_BROKERS.copy()
- logger.info(
- f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers"
- )
- else:
-
- if broker_index >= len(LETSMESH_BROKERS):
- raise ValueError(f"Invalid broker_index {broker_index}")
- self.brokers = [LETSMESH_BROKERS[broker_index]]
- logger.info(f"Single broker mode: connecting to {self.brokers[0]['name']}")
-
- # Add additional brokers from config
- if additional_brokers:
- for broker_config in additional_brokers:
- if all(k in broker_config for k in ["name", "host", "port", "audience"]):
- self.brokers.append(broker_config)
- logger.info(f"Added custom broker: {broker_config['name']}")
- else:
- logger.warning(f"Skipping invalid broker config: {broker_config}")
-
- # Validate that we have at least one broker
- if not self.brokers:
- raise ValueError(
- "No brokers configured. Either set broker_index to a valid value "
- "or provide additional_brokers in config."
- )
-
- self.local_identity = local_identity
- self.public_key = public_key
- self.iata_code = iata_code
- self.jwt_expiry_minutes = jwt_expiry_minutes
- self.use_tls = use_tls
- self.status_interval = status_interval
- self.app_version = __version__
- self.node_name = node_name
- self.radio_config = radio_config
- self.stats_provider = stats_provider
- self._status_task = None
- self._running = False
- self._lock = threading.Lock()
-
- # Create broker connections
- self.connections: List[_BrokerConnection] = []
- for idx, broker in enumerate(self.brokers):
- conn = _BrokerConnection(
- broker=broker,
- local_identity=self.local_identity,
- public_key=self.public_key,
- iata_code=self.iata_code,
- jwt_expiry_minutes=self.jwt_expiry_minutes,
- use_tls=self.use_tls,
- email=self.email,
- owner=self.owner,
- broker_index=idx,
- on_connect_callback=self._on_broker_connected,
- on_disconnect_callback=self._on_broker_disconnected,
- )
- self.connections.append(conn)
-
- logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
-
- def _on_broker_connected(self, broker_name: str):
- """Callback when a broker connects"""
- # Publish initial status on first connection
- if not self._status_task and self.status_interval > 0:
- self._running = True
- self.publish_status(
- state="online", origin=self.node_name, radio_config=self.radio_config
- )
- # Start heartbeat thread
- self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True)
- self._status_task.start()
- logger.info(f"Started status heartbeat (interval: {self.status_interval}s)")
-
- def _on_broker_disconnected(self, broker_name: str):
- """Callback when a broker disconnects"""
- # Check if all connections are down AND none have pending reconnects
- all_down = all(not conn.is_connected() for conn in self.connections)
- any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections)
-
- if all_down and not any_reconnecting:
- logger.warning("All broker connections lost with no pending reconnects")
- elif all_down:
- logger.info("All brokers temporarily disconnected, reconnects pending")
-
- def connect(self):
- """Establish connections to all configured brokers"""
- for idx, conn in enumerate(self.connections):
- try:
- if idx == 0:
- # Connect first broker immediately
- conn.connect()
- else:
- # Stagger additional brokers using background timers
- delay = idx * 30
- logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
- timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
- timer.daemon = True
- timer.start()
- except Exception as e:
- logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
-
- def _delayed_connect(self, conn):
- """Connect a broker after a delay (called by timer)"""
- try:
- conn.connect()
- except Exception as e:
- logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
-
- def disconnect(self):
- """Disconnect from all brokers"""
- # Stop the heartbeat loop
- self._running = False
-
- # Publish offline status before disconnecting
- self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
-
- import time
-
- time.sleep(0.5) # Give time for messages to be sent
-
- # Disconnect all brokers
- for conn in self.connections:
- try:
- conn.disconnect()
- except Exception as e:
- logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
-
- logger.info("Disconnected from all brokers")
-
- def _status_heartbeat_loop(self):
- """Background thread that publishes periodic status updates"""
- import time
-
- while self._running:
- try:
- # Publish status (JWT refresh now handled by individual broker timers)
- self.publish_status(
- state="online", origin=self.node_name, radio_config=self.radio_config
- )
- logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
-
- time.sleep(self.status_interval)
- except Exception as e:
- logger.error(f"Status heartbeat error: {e}")
- time.sleep(self.status_interval)
-
- # ----------------------------------------------------------------
- # Packet helpers
- # ----------------------------------------------------------------
- def _process_packet(self, pkt: dict) -> dict:
- return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt}
-
- def _topic(self, subtopic: str) -> str:
- return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
-
- def publish_packet(self, pkt: dict, subtopic="packets", retain=False):
- return self.publish(subtopic, self._process_packet(pkt), retain)
-
- def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False):
- pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2}
- return self.publish_packet(pkt, subtopic, retain)
-
- def publish_status(
- self,
- state: str = "online",
- location: Optional[dict] = None,
- extra_stats: Optional[dict] = None,
- origin: Optional[str] = None,
- radio_config: Optional[str] = None,
- ):
- """
- Publish device status/heartbeat message
-
- Args:
- state: Device state (online/offline)
- location: Optional dict with latitude/longitude
- extra_stats: Optional additional statistics to include
- origin: Node name/description
- radio_config: Radio configuration string (freq,bw,sf,cr)
- """
- # Get live stats from provider if available
- if self.stats_provider:
- live_stats = self.stats_provider()
- else:
- live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
-
- status = {
- "status": state,
- "timestamp": datetime.now(UTC).isoformat(),
- "origin": origin or self.node_name,
- "origin_id": self.public_key,
- "model": "PyMC-Repeater",
- "firmware_version": self.app_version,
- "radio": radio_config or self.radio_config,
- "client_version": f"pyMC_repeater/{self.app_version}",
- "stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
- }
-
- if location:
- status["location"] = location
-
- return self.publish("status", status, retain=False)
-
- def publish(self, subtopic: str, payload: dict, retain: bool = False):
- """Publish message to all connected brokers"""
- topic = self._topic(subtopic)
- message = json.dumps(payload)
-
- results = []
- with self._lock:
- for conn in self.connections:
- if conn.is_connected():
- result = conn.publish(topic, message, retain=retain)
- results.append((conn.broker["name"], result))
- logger.debug(f"Published to {conn.broker['name']}/{topic}")
-
- if not results:
- logger.warning(f"No active broker connections for publishing to {topic}")
-
- return results
-
-
-# ====================================================================
-# Helper Functions
-# ====================================================================
-
-
-def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str:
- """
- Get human-readable MQTT error message.
-
- Args:
- rc: Return code from paho-mqtt
- is_disconnect: True if from on_disconnect, False if from on_connect
-
- Returns:
- Human-readable error message
- """
- if HAS_REASON_CODES:
- try:
- # ReasonCode object has getName() method and value property
- reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc)
- name = reason.getName() if hasattr(reason, 'getName') else str(reason)
- return f"{name} (code {rc})"
- except Exception as e:
- # Log the exception for debugging
- logger.debug(f"Could not decode reason code {rc}: {e}")
-
- # Fallback to manual mappings - Extended with MQTT v5 codes
- connect_errors = {
- 0: "Connection accepted",
- 1: "Incorrect protocol version",
- 2: "Invalid client identifier",
- 3: "Server unavailable",
- 4: "Bad username or password (JWT invalid)",
- 5: "Not authorized (JWT signature/format invalid)",
- # MQTT v5 codes
- 128: "Unspecified error",
- 129: "Malformed packet",
- 130: "Protocol error",
- 131: "Implementation specific error",
- 132: "Unsupported protocol version",
- 133: "Client identifier not valid",
- 134: "Bad username or password",
- 135: "Not authorized",
- 136: "Server unavailable",
- 137: "Server busy",
- 138: "Banned",
- 140: "Bad authentication method",
- 144: "Topic name invalid",
- 149: "Packet too large",
- 151: "Quota exceeded",
- 153: "Payload format invalid",
- 154: "Retain not supported",
- 155: "QoS not supported",
- 156: "Use another server",
- 157: "Server moved",
- 159: "Connection rate exceeded",
- }
-
- disconnect_errors = {
- 0: "Normal disconnect",
- 1: "Unacceptable protocol version",
- 2: "Identifier rejected",
- 3: "Server unavailable",
- 4: "Bad username or password",
- 5: "Not authorized",
- 7: "Connection lost / network error",
- 16: "Connection lost / protocol error",
- 17: "Client timeout",
- # MQTT v5 codes
- 4: "Disconnect with Will message",
- 128: "Unspecified error",
- 129: "Malformed packet",
- 130: "Protocol error",
- 131: "Implementation specific error",
- 135: "Not authorized",
- 137: "Server busy",
- 139: "Server shutting down",
- 141: "Keep alive timeout",
- 142: "Session taken over",
- 143: "Topic filter invalid",
- 144: "Topic name invalid",
- 147: "Receive maximum exceeded",
- 148: "Topic alias invalid",
- 149: "Packet too large",
- 150: "Message rate too high",
- 151: "Quota exceeded",
- 152: "Administrative action",
- 153: "Payload format invalid",
- 154: "Retain not supported",
- 155: "QoS not supported",
- 156: "Use another server",
- 157: "Server moved",
- 158: "Shared subscriptions not supported",
- 159: "Connection rate exceeded",
- 160: "Maximum connect time",
- 161: "Subscription identifiers not supported",
- 162: "Wildcard subscriptions not supported",
- }
-
- error_dict = disconnect_errors if is_disconnect else connect_errors
- return error_dict.get(rc, f"Unknown error code {rc}")
diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py
index c08810d..6e874b0 100644
--- a/repeater/data_acquisition/mqtt_handler.py
+++ b/repeater/data_acquisition/mqtt_handler.py
@@ -1,133 +1,720 @@
+import base64
+import binascii
import json
import logging
-import ssl
-from typing import Any, Dict, Optional
+import string
+import threading
+from datetime import datetime, timedelta
+from typing import Callable, Dict, List, Optional
+import paho.mqtt.client as mqtt
+from nacl.signing import SigningKey
+
+# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc
try:
- import paho.mqtt.client as mqtt
+ from datetime import UTC
+except Exception:
+ from datetime import timezone
+ UTC = timezone.utc
- MQTT_AVAILABLE = True
+from repeater import __version__
+
+# Try to import paho-mqtt error code mappings
+try:
+ from paho.mqtt.reasoncodes import ReasonCode
+
+ HAS_REASON_CODES = True
except ImportError:
- MQTT_AVAILABLE = False
-
-from .storage_utils import PacketRecord
+ HAS_REASON_CODES = False
logger = logging.getLogger("MQTTHandler")
-class MQTTHandler:
- def __init__(self, mqtt_config: dict, node_name: str = "unknown", node_id: str = "unknown"):
- self.mqtt_config = mqtt_config
- self.node_name = node_name
- self.node_id = node_id
- self.client = None
- self.available = MQTT_AVAILABLE
- self._init_client()
+# --------------------------------------------------------------------
+# Helper: Base64URL without padding
+# --------------------------------------------------------------------
+def b64url(x: bytes) -> str:
+ return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
- def _init_client(self):
- if not self.available or not self.mqtt_config.get("enabled", False):
- logger.info("MQTT disabled or not available")
- return
-
- try:
- # Use WebSocket transport if configured, otherwise use standard TCP
- transport = "websockets" if self.mqtt_config.get("use_websockets", False) else "tcp"
- self.client = mqtt.Client(transport=transport)
-
- if transport == "websockets":
- logger.info("Using WebSocket transport for MQTT")
-
- # Configure TLS/SSL if enabled
- tls_config = self.mqtt_config.get("tls", {})
- if tls_config.get("enabled", False):
- tls_params = {
- "cert_reqs": ssl.CERT_REQUIRED,
- "tls_version": ssl.PROTOCOL_TLS,
- }
-
- # CA certificate for server verification (optional - uses system certs if not specified)
- ca_cert = tls_config.get("ca_cert")
- if ca_cert:
- tls_params["ca_certs"] = ca_cert
- logger.info("Using custom CA certificate for MQTT TLS")
- else:
- logger.info("Using system default CA certificates for MQTT TLS")
-
- # Client certificate and key (for mutual TLS)
- client_cert = tls_config.get("client_cert")
- client_key = tls_config.get("client_key")
- if client_cert:
- tls_params["certfile"] = client_cert
- if client_key:
- tls_params["keyfile"] = client_key
-
- # Allow insecure connections (skip cert verification)
- if tls_config.get("insecure", False):
- tls_params["cert_reqs"] = ssl.CERT_NONE
- logger.warning("MQTT TLS certificate verification disabled (insecure mode)")
-
- self.client.tls_set(**tls_params)
- logger.info("MQTT TLS/SSL configured")
-
- username = self.mqtt_config.get("username")
- password = self.mqtt_config.get("password")
- if username:
- self.client.username_pw_set(username, password)
-
- broker = self.mqtt_config.get("broker", "localhost")
- port = self.mqtt_config.get("port", 1883)
-
- secure = "(TLS)" if tls_config.get("enabled", False) else ""
- logger.info(f"Connecting to MQTT broker {broker}:{port} {secure}...")
-
- self.client.connect(broker, port, 60)
- self.client.loop_start()
- logger.info(f"MQTT client successfully connected")
-
- except Exception as e:
- logger.error(f"Failed to initialize MQTT: {e}")
- self.client = None
- def publish(self, record: dict, record_type: str):
- """
- Publish record to MQTT.
- Packets MUST use PacketRecord format. Non-packet records use original format.
+# # --------------------------------------------------------------------
+# # Let's Mesh MQTT Broker List (WebSocket Secure)
+# # --------------------------------------------------------------------
+# LETSMESH_BROKERS = [
+# {
+# "name": "Europe (LetsMesh v1)",
+# "host": "mqtt-eu-v1.letsmesh.net",
+# "port": 443,
+# "audience": "mqtt-eu-v1.letsmesh.net",
+# "use_jwt_auth": True,
+# "transport": "websockets",
+# "enabled": True,
+# },
+# {
+# "name": "US West (LetsMesh v1)",
+# "host": "mqtt-us-v1.letsmesh.net",
+# "port": 443,
+# "audience": "mqtt-us-v1.letsmesh.net",
+# "use_jwt_auth": True,
+# "transport": "websockets",
+# "enabled": True,
+# },
+# ]
+
+
+# ====================================================================
+# Single Broker Connection Manager
+# ====================================================================
+class _BrokerConnection:
+ """
+ Manages a single MQTT broker connection with independent lifecycle.
+ Internal class - not exposed publicly.
+ """
+
+ def __init__(
+ self,
+ broker: dict,
+ local_identity,
+ public_key: str,
+ iata_code: str,
+ jwt_expiry_minutes: int,
+ use_tls: bool,
+ email: str,
+ owner: str,
+ on_connect_callback: Optional[Callable] = None,
+ on_disconnect_callback: Optional[Callable] = None
+ ):
+ self.broker = broker
+ self.local_identity = local_identity
+ self.public_key = public_key.upper()
+ self.iata_code = iata_code
+ self.jwt_expiry_minutes = jwt_expiry_minutes
+ self.use_tls = use_tls
+ self.email = email
+ self.owner = owner
+ self._on_connect_callback = on_connect_callback
+ self._on_disconnect_callback = on_disconnect_callback
+ self._connect_time = None
+ self._tls_verified = False
+ self._running = False
+ self._reconnect_attempts = 0
+ self._reconnect_timer = None
+ self._max_reconnect_delay = 300 # 5 minutes max
+ self._jwt_refresh_timer = None
+ self.transport= broker.get('transport', 'websockets')
+ client_id = f"meshcore_{self.public_key}_{broker['host']}"
+ self.client = mqtt.Client(client_id=client_id, transport=self.transport)
+ self.client.on_connect = self._on_connect
+ self.client.on_disconnect = self._on_disconnect
+ self.use_jwt_auth = broker.get('use_jwt_auth', False)
+ self.username = broker.get('username', None)
+ self.password = broker.get('password', None)
- Args:
- record: The record dictionary to publish
- record_type: Type of record (packet, advert, noise_floor, etc.)
- """
- if not self.client:
- return
-
+ from pymc_core.protocol.utils import PAYLOAD_TYPES
+
+ disallowed_types = broker.get("disallowed_packet_types", [])
+ type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
+
+ self.disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
+ self.disallowed_hex = [val for val in self.disallowed_hex if val is not None] # Filter out invalid names
+
+
+ def _generate_jwt(self) -> str:
+ """Generate MeshCore-style Ed25519 JWT token"""
+ now = datetime.now(UTC)
+
+ header = {"alg": "Ed25519", "typ": "JWT"}
+
+ payload = {
+ "publicKey": self.public_key.upper(),
+ "aud": self.broker["audience"],
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()),
+ }
+
+ if "audience" in self.broker:
+ payload["aud"] = self.broker["audience"]
+
+ # Only include email/owner for verified TLS connections
+ if self.use_tls and self._tls_verified and (self.email or self.owner):
+ payload["email"] = self.email
+ payload["owner"] = self.owner
+ else:
+ payload["email"] = ""
+ payload["owner"] = ""
+
+ # Encode header and payload (compact JSON - no spaces)
+ header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode())
+ payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode())
+
+ signing_input = f"{header_b64}.{payload_b64}".encode()
+
+ # Sign using LocalIdentity (supports both standard and firmware keys)
try:
- base_topic = self.mqtt_config.get("base_topic", "meshcore/repeater")
- topic = f"{base_topic}/{self.node_name}/{record_type}"
+ signature = self.local_identity.sign(signing_input)
+ except Exception as e:
+ logger.error(f"JWT signing failed for {self.broker['name']}: {e}")
+ logger.error(f" - public_key: {self.public_key}")
+ logger.error(f" - signing_input length: {len(signing_input)}")
+ raise
- if record_type == "packet":
- packet_record = PacketRecord.from_packet_record(
- record, origin=self.node_name, origin_id=self.node_id
- )
- if not packet_record:
- logger.debug(
- "Skipping MQTT publish: packet missing required data for PacketRecord"
- )
- return
+ signature_hex = binascii.hexlify(signature).decode()
+ token = f"{header_b64}.{payload_b64}.{signature_hex}"
- payload = packet_record.to_dict()
- logger.debug("Publishing packet using PacketRecord format")
+ logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...")
+
+ return token
+
+ def _on_connect(self, client, userdata, flags, rc):
+ """MQTT connection callback"""
+ if rc == 0:
+ logger.info(f"Connected to {self.broker['name']}")
+ self._running = True
+ self._reconnect_attempts = 0 # Reset counter on success
+ if self.use_jwt_auth:
+ self._schedule_jwt_refresh() # Schedule proactive JWT refresh
+ if self._on_connect_callback:
+ self._on_connect_callback(self.broker["name"])
+ else:
+ error_msg = get_mqtt_error_message(rc, is_disconnect=False)
+ logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}")
+ self._schedule_reconnect()
+
+ def _on_disconnect(self, client, userdata, rc):
+ """MQTT disconnection callback"""
+ was_running = self._running
+ self._running = False
+
+ if rc != 0: # Unexpected disconnect
+ error_msg = get_mqtt_error_message(rc, is_disconnect=True)
+ logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}")
+ if was_running: # Only reconnect if we were intentionally connected
+ self._schedule_reconnect(reason=error_msg)
+ else:
+ logger.info(f"Clean disconnect from {self.broker['name']}")
+
+ if self._on_disconnect_callback:
+ self._on_disconnect_callback(self.broker["name"])
+
+ def _schedule_reconnect(self, reason: str = "connection lost"):
+ """Schedule reconnection with exponential backoff"""
+ if self._reconnect_timer:
+ self._reconnect_timer.cancel()
+
+ # Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
+ delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay)
+ self._reconnect_attempts += 1
+
+ logger.info(
+ f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})"
+ )
+ self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason))
+ self._reconnect_timer.daemon = True
+ self._reconnect_timer.start()
+
+ def _attempt_reconnect(self, reason: str = "connection lost"):
+ """Attempt to reconnect to broker with fresh JWT"""
+ try:
+ logger.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...")
+
+ # Stop the loop if it's still running (websocket mode requires clean restart)
+ try:
+ self.client.loop_stop()
+ except:
+ pass
+
+ self._set_credentials()
+
+ # Reconnect and restart loop
+ self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
+ self.client.loop_start()
+ self._loop_running = True
+ except Exception as e:
+ logger.error(f"Reconnection failed for {self.broker['name']}: {e}")
+ self._schedule_reconnect() # Try again later
+
+ def _set_credentials(self):
+ """Set credentials before connecting (CONNECT handshake only)"""
+ try:
+ if self.use_jwt_auth:
+ logger.debug(f"Generating JWT credentials for {self.broker['name']}...")
+ token = self._generate_jwt()
+ username = f"v1_{self.public_key}"
+ self.client.username_pw_set(username=username, password=token)
+ logger.debug(f"Credentials set for {self.broker['name']}")
+ logger.debug(f"Using username: {username}")
+ logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}")
+ elif self.username and self.password:
+ logger.info(f"Using provided credentials for {self.broker['name']} (username: {self.username})")
+ self.client.username_pw_set(username=self.username, password=self.password)
else:
- payload = {k: v for k, v in record.items() if v is not None}
+ logger.info(f"No credentials set for {self.broker['name']} (JWT auth disabled and no username/password provided)")
- message = json.dumps(payload, default=str)
- self.client.publish(topic, message, qos=0, retain=False)
- logger.debug(f"Published to {topic}")
+ self._connect_time = datetime.now(UTC)
except Exception as e:
- logger.error(f"Failed to publish to MQTT: {e}")
+ logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}")
+ raise
- def close(self):
- if self.client:
- self.client.loop_stop()
- self.client.disconnect()
- logger.info("MQTT client disconnected")
\ No newline at end of file
+ def connect(self):
+ """Establish connection to broker"""
+ # Conditional TLS setup
+ if self.transport == "websockets":
+ if self.use_tls:
+ import ssl
+
+ self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
+ self.client.tls_insecure_set(False)
+ self._tls_verified = True
+ protocol = "wss"
+ else:
+ protocol = "ws"
+ elif self.transport == "tcp":
+ protocol = "mqtt"
+ else:
+ raise ValueError(f"Invalid transport '{self.transport}' for {self.broker['name']}")
+
+ # Set JWT credentials before CONNECT handshake
+ self._set_credentials()
+
+ logger.info(
+ f"Connecting to {self.broker['name']} "
+ f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..."
+ )
+
+ self.client.connect(self.broker["host"], self.broker["port"], keepalive=60)
+ self.client.loop_start()
+ self._loop_running = True
+
+ def disconnect(self):
+ """Disconnect from broker"""
+ self._running = False
+ self._loop_running = False
+
+ # Cancel any pending timers
+ if self._reconnect_timer:
+ self._reconnect_timer.cancel()
+ self._reconnect_timer = None
+ if self._jwt_refresh_timer:
+ self._jwt_refresh_timer.cancel()
+ self._jwt_refresh_timer = None
+
+ self.client.loop_stop()
+ self.client.disconnect()
+ logger.info(f"Disconnected from {self.broker['name']}")
+
+ def publish(self, topic: str, payload: str, retain: bool = False):
+ """Publish message to broker"""
+ if self._running:
+ result = self.client.publish(topic, payload, retain=retain)
+ return result
+ return None
+
+ def is_connected(self) -> bool:
+ """Check if connection is active"""
+ return self._running
+
+ def has_pending_reconnect(self) -> bool:
+ """Check if a reconnection is scheduled"""
+ return self._reconnect_timer is not None and self._reconnect_timer.is_alive()
+
+ def should_reconnect_for_token_expiry(self) -> bool:
+ """Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)"""
+ if not self._connect_time:
+ return False
+ elapsed = (datetime.now(UTC) - self._connect_time).total_seconds()
+ expiry_seconds = self.jwt_expiry_minutes * 60
+ # Stagger refresh by 5% per broker to prevent simultaneous disconnects
+ # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
+ stagger_offset = self.broker_index * 0.05
+ refresh_threshold = 0.80 + stagger_offset
+ return elapsed >= expiry_seconds * refresh_threshold
+
+ def _schedule_jwt_refresh(self):
+ """Schedule proactive JWT refresh before token expires"""
+ if self._jwt_refresh_timer:
+ self._jwt_refresh_timer.cancel()
+
+ expiry_seconds = self.jwt_expiry_minutes * 60
+ # Stagger refresh by 5% per broker to prevent simultaneous disconnects
+ # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc.
+ stagger_offset = self.broker_index * 0.05
+ refresh_threshold = 0.80 + stagger_offset
+ refresh_delay = expiry_seconds * refresh_threshold
+
+ logger.info(
+ f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s "
+ f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)"
+ )
+ self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry)
+ self._jwt_refresh_timer.daemon = True
+ self._jwt_refresh_timer.start()
+
+ def reconnect_for_token_expiry(self):
+ """Proactively reconnect with new JWT before current one expires"""
+ if not self._running:
+ return
+
+ logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...")
+ self._running = False
+ self._jwt_refresh_timer = None
+
+ self._schedule_reconnect(reason="JWT token expiry")
+ self.client.disconnect()
+
+
+# ====================================================================
+# MeshCore → MQTT Publisher with Ed25519 auth token
+# ====================================================================
+class MeshCoreToMqttPusher:
+
+ def __init__(
+ self,
+ local_identity,
+ config: dict,
+ jwt_expiry_minutes: int = 10,
+ use_tls: bool = True,
+ stats_provider: Optional[Callable[[], dict]] = None,
+ ):
+ # Store local identity and get public key
+ self.local_identity = local_identity
+ public_key = local_identity.get_public_key().hex().upper()
+
+ # Extract values from config
+ from ..config import get_node_info
+
+ node_info = get_node_info(config)
+
+ iata_code = node_info["iata_code"]
+ broker_index = node_info.get("broker_index")
+ self.email = node_info.get("email", "")
+ self.owner = node_info.get("owner", "")
+ status_interval = node_info["status_interval"]
+ node_name = node_info["node_name"]
+ radio_config = node_info["radio_config"]
+
+ # Get additional brokers from config (optional)
+ mqtt_config = config.get("mqtt", {})
+ brokers = mqtt_config.get("brokers", [])
+
+ # Add additional brokers from config
+ if brokers:
+ for broker_config in brokers:
+ if all(k in broker_config for k in ["name", "host", "port", "enabled"]):
+ if broker_config["enabled"]:
+ self.brokers.append(broker_config)
+ logger.info(f"Added broker: {broker_config['name']}")
+ else:
+ logger.info(f"Broker disabled in config, skipping: {broker_config['name']}")
+ else:
+ logger.warning(f"Skipping invalid broker config: {broker_config}")
+
+ # Validate that we have at least one broker
+ # if not self.brokers:
+ # raise ValueError(
+ # "No brokers configured. Either set broker_index to a valid value "
+ # "or provide additional_brokers in config."
+ # )
+
+ self.local_identity = local_identity
+ self.public_key = public_key
+ self.iata_code = iata_code
+ self.jwt_expiry_minutes = jwt_expiry_minutes
+ self.use_tls = use_tls
+ self.status_interval = status_interval
+ self.app_version = __version__
+ self.node_name = node_name
+ self.radio_config = radio_config
+ self.stats_provider = stats_provider
+ self._status_task = None
+ self._running = False
+ self._lock = threading.Lock()
+
+ # Create broker connections
+ self.connections: List[_BrokerConnection] = []
+ for idx, broker in enumerate(self.brokers):
+ conn = _BrokerConnection(
+ broker=broker,
+ local_identity=self.local_identity,
+ public_key=self.public_key,
+ iata_code=self.iata_code,
+ jwt_expiry_minutes=self.jwt_expiry_minutes,
+ use_tls=self.use_tls,
+ email=self.email,
+ owner=self.owner,
+ broker_index=idx,
+ on_connect_callback=self._on_broker_connected,
+ on_disconnect_callback=self._on_broker_disconnected,
+ )
+ self.connections.append(conn)
+
+ logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
+
+ def _on_broker_connected(self, broker_name: str):
+ """Callback when a broker connects"""
+ # Publish initial status on first connection
+ if not self._status_task and self.status_interval > 0:
+ self._running = True
+ logger.info(f"Publishing initial status for {broker_name}...")
+ self.publish_status(
+ state="online", origin=self.node_name, radio_config=self.radio_config
+ )
+ # Start heartbeat thread
+ self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True)
+ self._status_task.start()
+ logger.info(f"Started status heartbeat (interval: {self.status_interval}s)")
+
+ def _on_broker_disconnected(self, broker_name: str):
+ """Callback when a broker disconnects"""
+ # Check if all connections are down AND none have pending reconnects
+ all_down = all(not conn.is_connected() for conn in self.connections)
+ any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections)
+
+ if all_down and not any_reconnecting:
+ logger.warning("All broker connections lost with no pending reconnects")
+ elif all_down:
+ logger.info("All brokers temporarily disconnected, reconnects pending")
+
+ def connect(self):
+ """Establish connections to all configured brokers"""
+ for idx, conn in enumerate(self.connections):
+ try:
+ if idx == 0:
+ # Connect first broker immediately
+ conn.connect()
+ else:
+ # Stagger additional brokers using background timers
+ delay = idx * 30
+ logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s")
+ timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c))
+ timer.daemon = True
+ timer.start()
+ except Exception as e:
+ logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
+
+ def _delayed_connect(self, conn):
+ """Connect a broker after a delay (called by timer)"""
+ try:
+ conn.connect()
+ except Exception as e:
+ logger.error(f"Failed to connect to {conn.broker['name']}: {e}")
+
+ def disconnect(self):
+ """Disconnect from all brokers"""
+ # Stop the heartbeat loop
+ self._running = False
+
+ # Publish offline status before disconnecting
+ self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config)
+
+ import time
+
+ time.sleep(0.5) # Give time for messages to be sent
+
+ # Disconnect all brokers
+ for conn in self.connections:
+ try:
+ conn.disconnect()
+ except Exception as e:
+ logger.error(f"Error disconnecting from {conn.broker['name']}: {e}")
+
+ logger.info("Disconnected from all brokers")
+
+ def _status_heartbeat_loop(self):
+ """Background thread that publishes periodic status updates"""
+ import time
+
+ while self._running:
+ try:
+ # Publish status (JWT refresh now handled by individual broker timers)
+ self.publish_status(
+ state="online", origin=self.node_name, radio_config=self.radio_config
+ )
+ logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)")
+
+ time.sleep(self.status_interval)
+ except Exception as e:
+ logger.error(f"Status heartbeat error: {e}")
+ time.sleep(self.status_interval)
+
+ # ----------------------------------------------------------------
+ # Packet helpers
+ # ----------------------------------------------------------------
+ def _process_packet(self, pkt: dict) -> dict:
+ return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt}
+
+ def _topic(self, subtopic: str) -> str:
+ return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
+
+ def publish_packet(self, pkt: dict, packet_type: string, subtopic="packets", retain=False):
+ if packet_type in self.disallowed_packet_types:
+ logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
+ return
+
+ return self.publish(subtopic, self._process_packet(pkt), retain)
+
+ def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False):
+ pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2}
+ return self.publish_packet(pkt, "raw", subtopic, retain)
+
+ def publish_status(
+ self,
+ state: str = "online",
+ location: Optional[dict] = None,
+ extra_stats: Optional[dict] = None,
+ origin: Optional[str] = None,
+ radio_config: Optional[str] = None,
+ ):
+ """
+ Publish device status/heartbeat message
+
+ Args:
+ state: Device state (online/offline)
+ location: Optional dict with latitude/longitude
+ extra_stats: Optional additional statistics to include
+ origin: Node name/description
+ radio_config: Radio configuration string (freq,bw,sf,cr)
+ """
+ # Get live stats from provider if available
+ if self.stats_provider:
+ live_stats = self.stats_provider()
+ else:
+ live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
+
+ status = {
+ "status": state,
+ "timestamp": datetime.now(UTC).isoformat(),
+ "origin": origin or self.node_name,
+ "origin_id": self.public_key,
+ "model": "PyMC-Repeater",
+ "firmware_version": self.app_version,
+ "radio": radio_config or self.radio_config,
+ "client_version": f"pyMC_repeater/{self.app_version}",
+ "stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
+ }
+
+ if location:
+ status["location"] = location
+
+ return self.publish("status", status, retain=True, qos=1)
+
+ def publish(self, subtopic: str, payload: dict, retain: bool = False):
+ """Publish message to all connected brokers"""
+ topic = self._topic(subtopic)
+ message = json.dumps(payload)
+
+ results = []
+ with self._lock:
+ for conn in self.connections:
+ if conn.is_connected():
+ result = conn.publish(topic, message, retain=retain)
+ results.append((conn.broker["name"], result))
+ logger.debug(f"Published to {conn.broker['name']}/{topic}")
+
+ if not results:
+ logger.warning(f"No active broker connections for publishing to {topic}")
+
+ return results
+
+
+# ====================================================================
+# Helper Functions
+# ====================================================================
+
+
+def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str:
+ """
+ Get human-readable MQTT error message.
+
+ Args:
+ rc: Return code from paho-mqtt
+ is_disconnect: True if from on_disconnect, False if from on_connect
+
+ Returns:
+ Human-readable error message
+ """
+ if HAS_REASON_CODES:
+ try:
+ # ReasonCode object has getName() method and value property
+ reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc)
+ name = reason.getName() if hasattr(reason, 'getName') else str(reason)
+ return f"{name} (code {rc})"
+ except Exception as e:
+ # Log the exception for debugging
+ logger.debug(f"Could not decode reason code {rc}: {e}")
+
+ # Fallback to manual mappings - Extended with MQTT v5 codes
+ connect_errors = {
+ 0: "Connection accepted",
+ 1: "Incorrect protocol version",
+ 2: "Invalid client identifier",
+ 3: "Server unavailable",
+ 4: "Bad username or password (JWT invalid)",
+ 5: "Not authorized (JWT signature/format invalid)",
+ # MQTT v5 codes
+ 128: "Unspecified error",
+ 129: "Malformed packet",
+ 130: "Protocol error",
+ 131: "Implementation specific error",
+ 132: "Unsupported protocol version",
+ 133: "Client identifier not valid",
+ 134: "Bad username or password",
+ 135: "Not authorized",
+ 136: "Server unavailable",
+ 137: "Server busy",
+ 138: "Banned",
+ 140: "Bad authentication method",
+ 144: "Topic name invalid",
+ 149: "Packet too large",
+ 151: "Quota exceeded",
+ 153: "Payload format invalid",
+ 154: "Retain not supported",
+ 155: "QoS not supported",
+ 156: "Use another server",
+ 157: "Server moved",
+ 159: "Connection rate exceeded",
+ }
+
+ disconnect_errors = {
+ 0: "Normal disconnect",
+ 1: "Unacceptable protocol version",
+ 2: "Identifier rejected",
+ 3: "Server unavailable",
+ 4: "Bad username or password",
+ 5: "Not authorized",
+ 7: "Connection lost / network error",
+ 16: "Connection lost / protocol error",
+ 17: "Client timeout",
+ # MQTT v5 codes
+ 4: "Disconnect with Will message",
+ 128: "Unspecified error",
+ 129: "Malformed packet",
+ 130: "Protocol error",
+ 131: "Implementation specific error",
+ 135: "Not authorized",
+ 137: "Server busy",
+ 139: "Server shutting down",
+ 141: "Keep alive timeout",
+ 142: "Session taken over",
+ 143: "Topic filter invalid",
+ 144: "Topic name invalid",
+ 147: "Receive maximum exceeded",
+ 148: "Topic alias invalid",
+ 149: "Packet too large",
+ 150: "Message rate too high",
+ 151: "Quota exceeded",
+ 152: "Administrative action",
+ 153: "Payload format invalid",
+ 154: "Retain not supported",
+ 155: "QoS not supported",
+ 156: "Use another server",
+ 157: "Server moved",
+ 158: "Shared subscriptions not supported",
+ 159: "Connection rate exceeded",
+ 160: "Maximum connect time",
+ 161: "Subscription identifiers not supported",
+ 162: "Wildcard subscriptions not supported",
+ }
+
+ error_dict = disconnect_errors if is_disconnect else connect_errors
+ return error_dict.get(rc, f"Unknown error code {rc}")
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index 494aa3e..e15db01 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -5,8 +5,8 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
-from .letsmesh_handler import MeshCoreToMqttJwtPusher
-from .mqtt_handler import MQTTHandler
+from .mqtt_handler import MeshCoreToMqttPusher
+#from .old_mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
@@ -32,40 +32,40 @@ class StorageCollector:
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir)
- self.mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
+# self.old_mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
- # Initialize LetsMesh handler if configured
- self.letsmesh_handler = None
- if config.get("letsmesh", {}).get("enabled", False) and local_identity:
+ # Initialize MQTT handler if configured
+ self.mqtt_handler = None
+ if config.get("mqtt", {}) and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
- self.letsmesh_handler = MeshCoreToMqttJwtPusher(
+ self.mqtt_handler = MeshCoreToMqttPusher(
local_identity=local_identity,
config=config,
stats_provider=self._get_live_stats,
)
- self.letsmesh_handler.connect()
+ self.mqtt_handler.connect()
# Get disallowed packet types from config
from ..config import get_node_info
- node_info = get_node_info(config)
- self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
+ #node_info = get_node_info(config)
+ #self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
public_key_hex = local_identity.get_public_key().hex()
logger.info(
- f"LetsMesh handler initialized with public key: {public_key_hex[:16]}..."
+ f"MQTT handler initialized with public key: {public_key_hex[:16]}..."
)
- if self.disallowed_packet_types:
- logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
- else:
- logger.info("All packet types allowed")
+ #if self.disallowed_packet_types:
+ # logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
+ #else:
+ # logger.info("All packet types allowed")
except Exception as e:
- logger.error(f"Failed to initialize LetsMesh handler: {e}")
- self.letsmesh_handler = None
- self.disallowed_packet_types = set()
- else:
- self.disallowed_packet_types = set()
+ logger.error(f"Failed to initialize MQTT handler: {e}")
+ self.mqtt_handler = None
+ #self.disallowed_packet_types = set()
+ #else:
+ # self.disallowed_packet_types = set()
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
@@ -146,7 +146,7 @@ class StorageCollector:
self.sqlite_handler.store_packet(packet_record)
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
- self.mqtt_handler.publish(packet_record, "packet")
+ #self.old_mqtt_handler.publish(packet_record, "packet")
# Broadcast to WebSocket clients for real-time updates
if self.websocket_available:
@@ -170,17 +170,17 @@ class StorageCollector:
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
- # Publish to LetsMesh if enabled (skip invalid packets if requested)
- if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
- logger.debug(
- f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
- )
- else:
- self._publish_to_letsmesh(packet_record)
+ # # Publish to LetsMesh if enabled (skip invalid packets if requested)
+ # if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
+ # logger.debug(
+ # f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
+ # )
+ # else:
+ self._publish_to_letsmesh(packet_record)
def _publish_to_letsmesh(self, packet_record: dict):
"""Publish packet to LetsMesh broker if enabled and allowed"""
- if not self.letsmesh_handler:
+ if not self.mqtt_handler:
return
try:
@@ -189,17 +189,17 @@ class StorageCollector:
logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field")
return
- if packet_type in self.disallowed_packet_types:
- logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
- return
+ # if packet_type in self.disallowed_packet_types:
+ # logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
+ # return
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
packet = PacketRecord.from_packet_record(
- packet_record, origin=node_name, origin_id=self.letsmesh_handler.public_key
+ packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
)
if packet:
- self.letsmesh_handler.publish_packet(packet.to_dict())
+ self.mqtt_handler.publish_packet(packet.to_dict(), packet_type)
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
else:
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
@@ -209,18 +209,18 @@ class StorageCollector:
def record_advert(self, advert_record: dict):
self.sqlite_handler.store_advert(advert_record)
- self.mqtt_handler.publish(advert_record, "advert")
+ #self.old_mqtt_handler.publish(advert_record, "advert")
def record_noise_floor(self, noise_floor_dbm: float):
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
self.sqlite_handler.store_noise_floor(noise_record)
- self.mqtt_handler.publish(noise_record, "noise_floor")
+ #self.old_mqtt_handler.publish(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
- self.mqtt_handler.publish(crc_record, "crc_errors")
+ #self.old_mqtt_handler.publish(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
@@ -305,13 +305,13 @@ class StorageCollector:
return self.sqlite_handler.get_noise_floor_stats(hours)
def close(self):
- self.mqtt_handler.close()
- if self.letsmesh_handler:
+ #self.old_mqtt_handler.close()
+ if self.mqtt_handler:
try:
- self.letsmesh_handler.disconnect()
- logger.info("LetsMesh handler disconnected")
+ self.mqtt_handler.disconnect()
+ logger.info("MQTT handler disconnected")
except Exception as e:
- logger.error(f"Error disconnecting LetsMesh handler: {e}")
+ logger.error(f"Error disconnecting MQTT handler: {e}")
def create_transport_key(
self,
diff --git a/repeater/engine.py b/repeater/engine.py
index 533c63f..e04cc89 100644
--- a/repeater/engine.py
+++ b/repeater/engine.py
@@ -1138,7 +1138,7 @@ class RepeaterHandler(BaseHandler):
"unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
},
- "letsmesh": self.config.get("letsmesh", {}),
+ #"mqtt": self.config.get("mqtt", {}),
},
"public_key": None,
}
diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py
index 750109e..645f876 100644
--- a/repeater/web/api_endpoints.py
+++ b/repeater/web/api_endpoints.py
@@ -54,8 +54,8 @@ logger = logging.getLogger("HTTPServer")
# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config
# POST /api/update_radio_config - Update radio configuration
# POST /api/update_advert_rate_limit_config - Update advert rate limiting settings
-# GET /api/letsmesh_status - Get LetsMesh Observer connection status
-# POST /api/update_letsmesh_config - Update LetsMesh Observer configuration
+# GET /api/mqtt_status - Get MQTT Observer connection status
+# POST /api/update_mqtt_config - Update MQTT Observer configuration
# Packets
# GET /api/packet_stats?hours=24 - Get packet statistics
@@ -999,18 +999,18 @@ class APIEndpoints:
@cherrypy.expose
@cherrypy.tools.json_out()
- def letsmesh_status(self):
- """Get LetsMesh connection status and configuration."""
+ def mqtt_status(self):
+ """Get MQTT connection status and configuration."""
self._set_cors_headers()
try:
- letsmesh_cfg = self.config.get("letsmesh", {})
- enabled = letsmesh_cfg.get("enabled", False)
+ mqtt_cfg = self.config.get("mqtt", {})
+ enabled = mqtt_cfg.get("enabled", False)
- # Walk the chain to the letsmesh_handler
+ # Walk the chain to the mqtt_handler
handler = None
try:
storage = self._get_storage()
- handler = getattr(storage, "letsmesh_handler", None)
+ handler = getattr(storage, "mqtt_handler", None)
except Exception:
pass
@@ -1030,100 +1030,98 @@ class APIEndpoints:
"brokers": connected_brokers,
})
except Exception as e:
- logger.error(f"Error getting LetsMesh status: {e}")
+ logger.error(f"Error getting MQTT status: {e}")
return self._error(str(e))
- @cherrypy.expose
- @cherrypy.tools.json_out()
- @cherrypy.tools.json_in()
- def update_letsmesh_config(self):
- """Update LetsMesh Observer configuration.
+ # @cherrypy.expose
+ # @cherrypy.tools.json_out()
+ # @cherrypy.tools.json_in()
+ # def update_mqtt_config(self):
+ # """Update MQTT Observer configuration.
- POST /api/update_letsmesh_config
- Body: {
- "enabled": true,
- "iata_code": "SFO",
- "broker_index": 0,
- "status_interval": 300,
- "owner": "Callsign",
- "email": "user@example.com",
- "disallowed_packet_types": ["ACK"]
- }
- """
- self._set_cors_headers()
+ # POST /api/update_mqtt_config
+ # Body: {
+ # "iata_code": "SFO",
+ # "status_interval": 300,
+ # "owner": "Callsign",
+ # "email": "user@example.com",
+ # "disallowed_packet_types": ["ACK"]
+ # }
+ # """
+ # self._set_cors_headers()
- if cherrypy.request.method == "OPTIONS":
- return ""
+ # if cherrypy.request.method == "OPTIONS":
+ # return ""
- try:
- self._require_post()
- data = cherrypy.request.json or {}
+ # try:
+ # self._require_post()
+ # data = cherrypy.request.json or {}
- if not data:
- return self._error("No configuration updates provided")
+ # if not data:
+ # return self._error("No configuration updates provided")
- letsmesh_updates = {}
+ # letsmesh_updates = {}
- if "enabled" in data:
- letsmesh_updates["enabled"] = bool(data["enabled"])
- if "iata_code" in data:
- letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
- if "broker_index" in data:
- letsmesh_updates["broker_index"] = int(data["broker_index"])
- if "status_interval" in data:
- letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
- if "owner" in data:
- letsmesh_updates["owner"] = str(data["owner"]).strip()
- if "email" in data:
- letsmesh_updates["email"] = str(data["email"]).strip()
- if "disallowed_packet_types" in data:
- letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
- if "additional_brokers" in data:
- brokers = data["additional_brokers"]
- if not isinstance(brokers, list):
- return self._error("additional_brokers must be a list")
- validated = []
- for i, b in enumerate(brokers):
- if not isinstance(b, dict):
- return self._error(f"Broker at index {i} must be an object")
- for field in ("name", "host", "audience"):
- if not b.get(field, "").strip():
- return self._error(f"Broker at index {i} missing required field: {field}")
- try:
- port = int(b.get("port", 443))
- except (ValueError, TypeError):
- return self._error(f"Broker at index {i} has invalid port")
- validated.append({
- "name": str(b["name"]).strip(),
- "host": str(b["host"]).strip(),
- "port": port,
- "audience": str(b["audience"]).strip(),
- })
- letsmesh_updates["additional_brokers"] = validated
+ # if "enabled" in data:
+ # letsmesh_updates["enabled"] = bool(data["enabled"])
+ # if "iata_code" in data:
+ # letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
+ # if "broker_index" in data:
+ # letsmesh_updates["broker_index"] = int(data["broker_index"])
+ # if "status_interval" in data:
+ # letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
+ # if "owner" in data:
+ # letsmesh_updates["owner"] = str(data["owner"]).strip()
+ # if "email" in data:
+ # letsmesh_updates["email"] = str(data["email"]).strip()
+ # if "disallowed_packet_types" in data:
+ # letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
+ # if "additional_brokers" in data:
+ # brokers = data["additional_brokers"]
+ # if not isinstance(brokers, list):
+ # return self._error("additional_brokers must be a list")
+ # validated = []
+ # for i, b in enumerate(brokers):
+ # if not isinstance(b, dict):
+ # return self._error(f"Broker at index {i} must be an object")
+ # for field in ("name", "host", "audience"):
+ # if not b.get(field, "").strip():
+ # return self._error(f"Broker at index {i} missing required field: {field}")
+ # try:
+ # port = int(b.get("port", 443))
+ # except (ValueError, TypeError):
+ # return self._error(f"Broker at index {i} has invalid port")
+ # validated.append({
+ # "name": str(b["name"]).strip(),
+ # "host": str(b["host"]).strip(),
+ # "port": port,
+ # "audience": str(b["audience"]).strip(),
+ # })
+ # letsmesh_updates["additional_brokers"] = validated
- if not letsmesh_updates:
- return self._error("No valid settings provided")
+ # if not letsmesh_updates:
+ # return self._error("No valid settings provided")
- result = self.config_manager.update_and_save(
- updates={"letsmesh": letsmesh_updates},
- live_update=False, # Restart required for LetsMesh handler changes
- )
+ # result = self.config_manager.update_and_save(
+ # updates={"letsmesh": letsmesh_updates},
+ # live_update=False, # Restart required for LetsMesh handler changes
+ # )
- if result.get("success"):
- logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
- return self._success({
- "persisted": result.get("saved", False),
- "restart_required": True,
- "message": "Observer settings saved. Restart the service for changes to take effect.",
- })
- else:
- return self._error(result.get("error", "Failed to update LetsMesh configuration"))
+ # if result.get("success"):
+ # logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
+ # return self._success({
+ # "persisted": result.get("saved", False),
+ # "restart_required": True,
+ # "message": "Observer settings saved. Restart the service for changes to take effect.",
+ # })
+ # else:
+ # return self._error(result.get("error", "Failed to update LetsMesh configuration"))
- except cherrypy.HTTPError:
- raise
- except Exception as e:
- logger.error(f"Error updating LetsMesh config: {e}")
- return self._error(str(e))
+ # except cherrypy.HTTPError:
+ # raise
+ # except Exception as e:
+ # logger.error(f"Error updating LetsMesh config: {e}")
+ # return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
From 7256807fdd142ec8ec51b0bb71ff6df3d808db93 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Sat, 11 Apr 2026 20:46:42 -0700
Subject: [PATCH 02/72] feat: Bring back disallowed types
---
repeater/data_acquisition/mqtt_handler.py | 61 +++++++------------
.../data_acquisition/storage_collector.py | 4 +-
2 files changed, 24 insertions(+), 41 deletions(-)
diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py
index 6e874b0..7b8c125 100644
--- a/repeater/data_acquisition/mqtt_handler.py
+++ b/repeater/data_acquisition/mqtt_handler.py
@@ -37,31 +37,6 @@ def b64url(x: bytes) -> str:
return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
-# # --------------------------------------------------------------------
-# # Let's Mesh MQTT Broker List (WebSocket Secure)
-# # --------------------------------------------------------------------
-# LETSMESH_BROKERS = [
-# {
-# "name": "Europe (LetsMesh v1)",
-# "host": "mqtt-eu-v1.letsmesh.net",
-# "port": 443,
-# "audience": "mqtt-eu-v1.letsmesh.net",
-# "use_jwt_auth": True,
-# "transport": "websockets",
-# "enabled": True,
-# },
-# {
-# "name": "US West (LetsMesh v1)",
-# "host": "mqtt-us-v1.letsmesh.net",
-# "port": 443,
-# "audience": "mqtt-us-v1.letsmesh.net",
-# "use_jwt_auth": True,
-# "transport": "websockets",
-# "enabled": True,
-# },
-# ]
-
-
# ====================================================================
# Single Broker Connection Manager
# ====================================================================
@@ -81,6 +56,7 @@ class _BrokerConnection:
use_tls: bool,
email: str,
owner: str,
+ broker_index: int,
on_connect_callback: Optional[Callable] = None,
on_disconnect_callback: Optional[Callable] = None
):
@@ -92,6 +68,7 @@ class _BrokerConnection:
self.use_tls = use_tls
self.email = email
self.owner = owner
+ self.broker_index = broker_index
self._on_connect_callback = on_connect_callback
self._on_disconnect_callback = on_disconnect_callback
self._connect_time = None
@@ -101,7 +78,7 @@ class _BrokerConnection:
self._reconnect_timer = None
self._max_reconnect_delay = 300 # 5 minutes max
self._jwt_refresh_timer = None
- self.transport= broker.get('transport', 'websockets')
+ self.transport = broker.get('transport', 'websockets')
client_id = f"meshcore_{self.public_key}_{broker['host']}"
self.client = mqtt.Client(client_id=client_id, transport=self.transport)
self.client.on_connect = self._on_connect
@@ -115,8 +92,8 @@ class _BrokerConnection:
disallowed_types = broker.get("disallowed_packet_types", [])
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
- self.disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types]
- self.disallowed_hex = [val for val in self.disallowed_hex if val is not None] # Filter out invalid names
+ self.disallowed_types = [type_name_map.get(name.upper(), None) for name in disallowed_types]
+ self.disallowed_types = [val for val in self.disallowed_types if val is not None] # Filter out invalid names
def _generate_jwt(self) -> str:
@@ -303,11 +280,14 @@ class _BrokerConnection:
self.client.disconnect()
logger.info(f"Disconnected from {self.broker['name']}")
- def publish(self, topic: str, payload: str, retain: bool = False):
+ def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0):
"""Publish message to broker"""
+ logger.debug(f"Publishing to topic '{topic}' with payload: {payload}: self._running={self._running}")
if self._running:
- result = self.client.publish(topic, payload, retain=retain)
+ result = self.client.publish(topic, payload, retain=retain, qos=qos)
return result
+ else:
+ logger.warning(f"Cannot publish to {self.broker['name']} - not connected")
return None
def is_connected(self) -> bool:
@@ -386,7 +366,6 @@ class MeshCoreToMqttPusher:
node_info = get_node_info(config)
iata_code = node_info["iata_code"]
- broker_index = node_info.get("broker_index")
self.email = node_info.get("email", "")
self.owner = node_info.get("owner", "")
status_interval = node_info["status_interval"]
@@ -398,6 +377,7 @@ class MeshCoreToMqttPusher:
brokers = mqtt_config.get("brokers", [])
# Add additional brokers from config
+ self.brokers = []
if brokers:
for broker_config in brokers:
if all(k in broker_config for k in ["name", "host", "port", "enabled"]):
@@ -546,16 +526,12 @@ class MeshCoreToMqttPusher:
def _topic(self, subtopic: str) -> str:
return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
- def publish_packet(self, pkt: dict, packet_type: string, subtopic="packets", retain=False):
- if packet_type in self.disallowed_packet_types:
- logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
- return
-
+ def publish_packet(self, pkt: dict, subtopic="packets", retain=False):
return self.publish(subtopic, self._process_packet(pkt), retain)
def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False):
pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2}
- return self.publish_packet(pkt, "raw", subtopic, retain)
+ return self.publish_packet(pkt, subtopic, retain)
def publish_status(
self,
@@ -598,16 +574,23 @@ class MeshCoreToMqttPusher:
return self.publish("status", status, retain=True, qos=1)
- def publish(self, subtopic: str, payload: dict, retain: bool = False):
+ def publish(self, subtopic: str, payload: dict, retain: bool = False, qos: int = 0):
"""Publish message to all connected brokers"""
topic = self._topic(subtopic)
message = json.dumps(payload)
+ logger.debug(f"Publishing to topic '{topic}' with payload: {message}")
+
+ packet_type = payload.get("type")
+
results = []
with self._lock:
for conn in self.connections:
if conn.is_connected():
- result = conn.publish(topic, message, retain=retain)
+ if packet_type in conn.disallowed_types:
+ logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
+ return
+ result = conn.publish(topic, message, retain=retain, qos=qos)
results.append((conn.broker["name"], result))
logger.debug(f"Published to {conn.broker['name']}/{topic}")
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index cddf7c8..e835005 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -198,8 +198,8 @@ class StorageCollector:
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
)
- if packet:
- self.mqtt_handler.publish_packet(packet.to_dict(), packet_type)
+ if packet:
+ self.mqtt_handler.publish_packet(packet.to_dict())
logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
else:
logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
From 64530a623ee96e56bdedef43d0bd9c13525bfb48 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Sat, 11 Apr 2026 21:44:26 -0700
Subject: [PATCH 03/72] refactor: Updated letsmesh references
---
.../data_acquisition/storage_collector.py | 28 +++++++++----------
repeater/engine.py | 8 +++---
2 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index e835005..a46793b 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -130,12 +130,12 @@ class StorageCollector:
return stats
- def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
- """Record packet to storage and publish to MQTT/LetsMesh
+ def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
+ """Record packet to storage and publish to MQTT
Args:
packet_record: Dictionary containing packet information
- skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh
+ skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt
"""
logger.debug(
f"Recording packet: type={packet_record.get('type')}, "
@@ -170,23 +170,23 @@ class StorageCollector:
except Exception as e:
logger.debug(f"WebSocket broadcast failed: {e}")
- # # Publish to LetsMesh if enabled (skip invalid packets if requested)
- # if skip_letsmesh_if_invalid and packet_record.get("drop_reason"):
+ # # Publish to mqtt if enabled (skip invalid packets if requested)
+ # if skip_mqtt_if_invalid and packet_record.get("drop_reason"):
# logger.debug(
- # f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}"
+ # f"Skipping mqtt publish for packet with drop_reason: {packet_record.get('drop_reason')}"
# )
# else:
- self._publish_to_letsmesh(packet_record)
+ self._publish_to_mqtt(packet_record)
- def _publish_to_letsmesh(self, packet_record: dict):
- """Publish packet to LetsMesh broker if enabled and allowed"""
+ def _publish_to_mqtt(self, packet_record: dict):
+ """Publish packet to mqtt broker if enabled and allowed"""
if not self.mqtt_handler:
return
try:
packet_type = packet_record.get("type")
if packet_type is None:
- logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field")
+ logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
return
# if packet_type in self.disallowed_packet_types:
@@ -198,14 +198,14 @@ class StorageCollector:
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
)
- if packet:
+ if packet:
self.mqtt_handler.publish_packet(packet.to_dict())
- logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh")
+ logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt")
else:
- logger.debug("Skipped LetsMesh publish: packet missing raw_packet data")
+ logger.debug("Skipped mqtt publish: packet missing raw_packet data")
except Exception as e:
- logger.error(f"Failed to publish packet to LetsMesh: {e}", exc_info=True)
+ logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True)
def record_advert(self, advert_record: dict):
self.sqlite_handler.store_advert(advert_record)
diff --git a/repeater/engine.py b/repeater/engine.py
index e04cc89..38f47df 100644
--- a/repeater/engine.py
+++ b/repeater/engine.py
@@ -364,8 +364,8 @@ class RepeaterHandler(BaseHandler):
try:
# Only skip LetsMesh for actual invalid/bad packets
invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"]
- skip_letsmesh = drop_reason in invalid_reasons if drop_reason else False
- self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=skip_letsmesh)
+ skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
+ self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt)
except Exception as e:
logger.error(f"Failed to store packet record: {e}")
@@ -450,7 +450,7 @@ class RepeaterHandler(BaseHandler):
dst_hash,
)
try:
- self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
+ self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
except Exception as e:
logger.error(f"Failed to store packet record (record_packet_only): {e}")
return
@@ -493,7 +493,7 @@ class RepeaterHandler(BaseHandler):
if self.storage:
try:
- self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=False)
+ self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False)
except Exception as e:
logger.error(f"Failed to store duplicate record: {e}")
From 3b7de6061b2647f3e9ad5b00009f93c43762977e Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Sat, 11 Apr 2026 22:26:40 -0700
Subject: [PATCH 04/72] docs: Updated example config
---
config.yaml.example | 125 ++++++++++++--------------------------------
1 file changed, 32 insertions(+), 93 deletions(-)
diff --git a/config.yaml.example b/config.yaml.example
index a8292b8..a347b9b 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -270,49 +270,6 @@ duty_cycle:
# Maximum airtime per minute in milliseconds
max_airtime_per_minute: 3600
-
-# MQTT Publishing Configuration (Optional)
-mqtt:
- # Enable/disable MQTT publishing
- enabled: false
-
- # MQTT broker settings
- broker: "localhost"
- port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets
-
- # Use WebSocket transport instead of standard TCP
- # Typically uses ports: 80 (ws://), 443 (wss://), or 9001
- use_websockets: false
-
- # Authentication (optional)
- username: null
- password: null
-
- # TLS/SSL configuration (optional)
- # For public brokers with trusted certificates, just enable TLS:
- # tls:
- # enabled: true
- tls:
- enabled: false
-
- # Advanced TLS options (usually not needed for public brokers):
-
- # Custom CA certificate for server verification
- # Leave null to use system default CA certificates (recommended)
- ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt"
-
- # Client certificate and key for mutual TLS (rarely needed)
- client_cert: null # e.g., "/etc/pymc/client.crt"
- client_key: null # e.g., "/etc/pymc/client.key"
-
- # Skip certificate verification (insecure, not recommended)
- insecure: false
-
- # Base topic for publishing
- # Messages will be published to: {base_topic}/{node_name}/{packet|advert}
- base_topic: "meshcore/repeater"
-
-
# Storage Configuration
storage:
# Directory for persistent storage files (SQLite, RRD).
@@ -330,63 +287,29 @@ storage:
# - 1 hour resolution for 1 year
-letsmesh:
- enabled: false
+mqtt:
iata_code: "Test" # e.g., "SFO", "LHR", "Test"
-
- # ============================================================
- # BROKER SELECTION MODE - Choose how to connect to brokers
- # ============================================================
- #
- # EXAMPLE 1: Single built-in broker (default, most common)
- # Connect to Europe only - simple, low bandwidth
- broker_index: 0 # 0 = Europe, 1 = US West
-
- # EXAMPLE 2: All built-in brokers for maximum redundancy
- # Survives single broker failure, best uptime
- # broker_index: -1 # or null - connects to both EU and US
-
- # EXAMPLE 3: Only custom brokers (private/self-hosted)
- # Ignores built-in LetsMesh brokers completely
- # broker_index: -2
- # additional_brokers:
- # - name: "Private Server"
- # host: "mqtt.myserver.com"
- # port: 443
- # audience: "mqtt.myserver.com"
-
- # EXAMPLE 4: Single built-in + custom backup
- # Use EU primary with your own backup
- # broker_index: 0
- # additional_brokers:
- # - name: "Backup Server"
- # host: "mqtt-backup.mydomain.com"
- # port: 8883
- # audience: "mqtt-backup.mydomain.com"
-
- # EXAMPLE 5: All built-in + multiple custom (maximum redundancy)
- # EU + US + your own servers - best for critical deployments
- # broker_index: -1
- # additional_brokers:
- # - name: "Custom Primary"
- # host: "mqtt-1.mydomain.com"
- # port: 443
- # audience: "mqtt-1.mydomain.com"
- # - name: "Custom Backup"
- # host: "mqtt-2.mydomain.com"
- # port: 443
- # audience: "mqtt-2.mydomain.com"
- # ============================================================
-
- status_interval: 300
+ status_interval: 300 # How often a status message is sent (in seconds)
owner: ""
email: ""
+ brokers: []
- # Block specific packet types from being published to LetsMesh
+ # Below is the broker object schema:
+ # enabled: true|false # Enable this specific mqtt broker
+ # name: "" # Internal name for this broker
+ # host: "" # hostname or ip of mqtt endpoints
+ # port: # Typically 443 for websocket endpoints or 1883 for tcp
+ # transport: "tcp" or "websockets"
+ # audience: "" # For JWT auth'd endpoints, this is usually the host unless always stated by endpoint owners
+ # use_jwt_auth: true|false # Does this endpoint require JWT auth
+ # username: "" # Username for basic auth. If empty or missing, uses anonymous access
+ # password: "" # Password for basic auth. Required if username is set
+
+ # Block specific packet types from being published to the MQTT endpoint
# If not specified or empty list, all types are published
# Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT,
# GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM
- disallowed_packet_types: []
+ # disallowed_packet_types: []
# - REQ # Don't publish requests
# - RESPONSE # Don't publish responses
# - TXT_MSG # Don't publish text messages
@@ -399,6 +322,22 @@ letsmesh:
# - TRACE # Don't publish trace packets
# - RAW_CUSTOM # Don't publish custom raw packets
+ # Example of using the US and EU LetsMesh endpoints
+ # brokers:
+ # - name: US West (LetsMesh v1)
+ # host: mqtt-us-v1.letsmesh.net
+ # port: 443
+ # audience: mqtt-us-v1.letsmesh.net
+ # use_jwt_auth: true
+ # enabled: true
+
+ # - name: Europe (LetsMesh v1)
+ # host: mqtt-eu-v1.letsmesh.net
+ # port: 443
+ # audience: mqtt-eu-v1.letsmesh.net
+ # use_jwt_auth: true
+ # enabled: true
+
logging:
# Log level: DEBUG, INFO, WARNING, ERROR
level: INFO
From f18e5909fb1d80820e4c913b83fab7d38eb7438f Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Sat, 11 Apr 2026 22:27:01 -0700
Subject: [PATCH 05/72] refactor: Clear out dead code
---
repeater/data_acquisition/storage_collector.py | 17 -----------------
1 file changed, 17 deletions(-)
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index a46793b..517e3b4 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -27,12 +27,8 @@ class StorageCollector:
self.storage_dir = Path(storage_dir_cfg)
self.storage_dir.mkdir(parents=True, exist_ok=True)
- node_name = config.get("repeater", {}).get("node_name", "unknown")
- node_id = local_identity.get_public_key().hex() if local_identity else "unknown"
-
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir)
-# self.old_mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id)
# Initialize MQTT handler if configured
self.mqtt_handler = None
@@ -46,26 +42,13 @@ class StorageCollector:
)
self.mqtt_handler.connect()
- # Get disallowed packet types from config
- from ..config import get_node_info
-
- #node_info = get_node_info(config)
- #self.disallowed_packet_types = set(node_info["disallowed_packet_types"])
-
public_key_hex = local_identity.get_public_key().hex()
logger.info(
f"MQTT handler initialized with public key: {public_key_hex[:16]}..."
)
- #if self.disallowed_packet_types:
- # logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}")
- #else:
- # logger.info("All packet types allowed")
except Exception as e:
logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None
- #self.disallowed_packet_types = set()
- #else:
- # self.disallowed_packet_types = set()
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
From 27fa2381ea3a5c015529f726e681c3509934ef1d Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Wed, 15 Apr 2026 21:20:11 -0700
Subject: [PATCH 06/72] feat:
* Added retain status message bool
* Added back old templates
* Added migration path from old mqtt and letsmesh configs to new mqtt_broker config
---
repeater/config.py | 3 +-
repeater/data_acquisition/mqtt_handler.py | 281 ++++++++++++++----
.../data_acquisition/storage_collector.py | 14 +-
repeater/data_acquisition/storage_utils.py | 2 +-
repeater/engine.py | 6 +-
repeater/web/api_endpoints.py | 186 ++++++------
6 files changed, 337 insertions(+), 155 deletions(-)
diff --git a/repeater/config.py b/repeater/config.py
index be8e095..d9bcc93 100644
--- a/repeater/config.py
+++ b/repeater/config.py
@@ -30,7 +30,8 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
radio_bw_khz = radio_bw / 1_000
radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}"
- mqtt_config = config.get("mqtt", {})
+ # Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist
+ mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {}))
return {
"node_name": node_name,
diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py
index 7b8c125..3cf89f6 100644
--- a/repeater/data_acquisition/mqtt_handler.py
+++ b/repeater/data_acquisition/mqtt_handler.py
@@ -17,7 +17,7 @@ except Exception:
from datetime import timezone
UTC = timezone.utc
-from repeater import __version__
+from repeater import __version__, config
# Try to import paho-mqtt error code mappings
try:
@@ -36,6 +36,23 @@ logger = logging.getLogger("MQTTHandler")
def b64url(x: bytes) -> str:
return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
+LETSMESH_BROKERS = [
+ {
+ "name": "Europe (LetsMesh v1)",
+ "host": "mqtt-eu-v1.letsmesh.net",
+ "port": 443,
+ "audience": "mqtt-eu-v1.letsmesh.net",
+ "use_jwt_auth": True,
+ },
+ {
+ "name": "US West (LetsMesh v1)",
+ "host": "mqtt-us-v1.letsmesh.net",
+ "port": 443,
+ "audience": "mqtt-us-v1.letsmesh.net",
+ "use_jwt_auth": True,
+ },
+]
+
# ====================================================================
# Single Broker Connection Manager
@@ -53,10 +70,10 @@ class _BrokerConnection:
public_key: str,
iata_code: str,
jwt_expiry_minutes: int,
- use_tls: bool,
email: str,
owner: str,
broker_index: int,
+ node_name: str,
on_connect_callback: Optional[Callable] = None,
on_disconnect_callback: Optional[Callable] = None
):
@@ -65,27 +82,46 @@ class _BrokerConnection:
self.public_key = public_key.upper()
self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
- self.use_tls = use_tls
self.email = email
self.owner = owner
+ self.node_name = node_name
self.broker_index = broker_index
self._on_connect_callback = on_connect_callback
self._on_disconnect_callback = on_disconnect_callback
self._connect_time = None
- self._tls_verified = False
self._running = False
self._reconnect_attempts = 0
self._reconnect_timer = None
self._max_reconnect_delay = 300 # 5 minutes max
self._jwt_refresh_timer = None
self.transport = broker.get('transport', 'websockets')
- client_id = f"meshcore_{self.public_key}_{broker['host']}"
- self.client = mqtt.Client(client_id=client_id, transport=self.transport)
- self.client.on_connect = self._on_connect
- self.client.on_disconnect = self._on_disconnect
+
self.use_jwt_auth = broker.get('use_jwt_auth', False)
self.username = broker.get('username', None)
self.password = broker.get('password', None)
+
+ self.format=broker.get("format", "letsmesh")
+ self.tls=broker.get("tls", None)
+
+ client_id = f"meshcore_{self.public_key}_{broker['host']}_{self.format}"
+ self.client = mqtt.Client(client_id=client_id, transport=self.transport)
+ self.client.on_connect = self._on_connect
+ self.client.on_disconnect = self._on_disconnect
+
+ # If None, will be use defaults depending on the format value
+ self.base_topic=broker.get("base_topic", None)
+
+ self.enabled = broker.get("enabled", False)
+ self.retain_status = broker.get("retain_status", False)
+
+ if self.base_topic is None:
+ if self.format == "mqtt":
+ self.base_topic = f"meshcore/repeater/{self.node_name}"
+ elif self.format == "letsmesh":
+ self.base_topic = f"meshcore/{self.iata_code}/{self.public_key}"
+ else:
+ logger.warning(f"Unknown broker format '{self.format}' for {self.broker['name']}, using default base topic")
+ self.base_topic = f"meshcore/{self.iata_code}/{self.public_key}"
from pymc_core.protocol.utils import PAYLOAD_TYPES
@@ -280,16 +316,29 @@ class _BrokerConnection:
self.client.disconnect()
logger.info(f"Disconnected from {self.broker['name']}")
- def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0):
+ def publish(self, subtopic: str, payload: str, retain: bool = False, qos: int = 0):
"""Publish message to broker"""
- logger.debug(f"Publishing to topic '{topic}' with payload: {payload}: self._running={self._running}")
+
+ # Legacy MQTT config uses singular "packet" topic, while LetsMesh uses "packets". Handle this for compatibility.
+ if self.format == "mqtt" and subtopic == "packets":
+ subtopic = "packet"
+
+ if(subtopic == "status"): # Override the status topic retain and qos settings based on broker configuration
+ retain = self.retain_status
+ qos = 1 if self.retain_status else 0
+
+ logger.debug(f"Publishing to topic '{self.base_topic}/{subtopic}' with payload: [{payload}]. Running={self._running}. Retain={retain}, QoS={qos}")
if self._running:
- result = self.client.publish(topic, payload, retain=retain, qos=qos)
+ result = self.client.publish(f"{self.base_topic}/{subtopic}", payload, retain=retain, qos=qos)
return result
else:
logger.warning(f"Cannot publish to {self.broker['name']} - not connected")
return None
+ def is_enabled(self) -> bool:
+ """Check if connection is enabled"""
+ return self.enabled
+
def is_connected(self) -> bool:
"""Check if connection is active"""
return self._running
@@ -344,7 +393,7 @@ class _BrokerConnection:
# ====================================================================
-# MeshCore → MQTT Publisher with Ed25519 auth token
+# MeshCore → MQTT Publisher
# ====================================================================
class MeshCoreToMqttPusher:
@@ -365,51 +414,54 @@ class MeshCoreToMqttPusher:
node_info = get_node_info(config)
- iata_code = node_info["iata_code"]
+ self.iata_code = node_info["iata_code"]
self.email = node_info.get("email", "")
self.owner = node_info.get("owner", "")
- status_interval = node_info["status_interval"]
- node_name = node_info["node_name"]
- radio_config = node_info["radio_config"]
-
- # Get additional brokers from config (optional)
- mqtt_config = config.get("mqtt", {})
- brokers = mqtt_config.get("brokers", [])
-
- # Add additional brokers from config
- self.brokers = []
- if brokers:
- for broker_config in brokers:
- if all(k in broker_config for k in ["name", "host", "port", "enabled"]):
- if broker_config["enabled"]:
- self.brokers.append(broker_config)
- logger.info(f"Added broker: {broker_config['name']}")
- else:
- logger.info(f"Broker disabled in config, skipping: {broker_config['name']}")
- else:
- logger.warning(f"Skipping invalid broker config: {broker_config}")
-
- # Validate that we have at least one broker
- # if not self.brokers:
- # raise ValueError(
- # "No brokers configured. Either set broker_index to a valid value "
- # "or provide additional_brokers in config."
- # )
-
+ self.status_interval = node_info["status_interval"]
+ self.node_name = node_info["node_name"]
self.local_identity = local_identity
self.public_key = public_key
- self.iata_code = iata_code
self.jwt_expiry_minutes = jwt_expiry_minutes
- self.use_tls = use_tls
- self.status_interval = status_interval
self.app_version = __version__
- self.node_name = node_name
- self.radio_config = radio_config
+ self.radio_config = node_info["radio_config"]
self.stats_provider = stats_provider
self._status_task = None
self._running = False
self._lock = threading.Lock()
+ # Initialize brokers list
+ mqtt_brokers_config = config.get("mqtt_brokers", {})
+ letsmesh_config = config.get("letsmesh", {})
+ mqtt_config = config.get("mqtt", {})
+
+ brokers = []
+ if mqtt_brokers_config:
+ # Pull in brokers from mqtt_brokers config
+ brokers.extend(mqtt_brokers_config.get("brokers", []))
+
+ if letsmesh_config or mqtt_config:
+ logger.warning("Multiple MQTT broker configurations found (mqtt_brokers, letsmesh, mqtt). Only mqtt_brokers will be used")
+
+ else:
+ if mqtt_config:
+ imported_mqtt_config = self.convert_mqtt_to_broker_config(mqtt_config)
+ brokers.append(imported_mqtt_config)
+
+ if letsmesh_config:
+ imported_letsmesh_configs = self.convert_letsmesh_to_broker_config(letsmesh_config)
+ brokers.extend(imported_letsmesh_configs)
+
+ self.brokers = []
+ if brokers:
+ for broker_config in brokers:
+ if all(k in broker_config for k in ["name", "host", "port", "enabled"]):
+ self.brokers.append(broker_config)
+ logger.info(f"Added broker: {broker_config['name']}")
+ else:
+ logger.warning(f"Skipping invalid broker config: {broker_config}")
+
+
+
# Create broker connections
self.connections: List[_BrokerConnection] = []
for idx, broker in enumerate(self.brokers):
@@ -419,10 +471,10 @@ class MeshCoreToMqttPusher:
public_key=self.public_key,
iata_code=self.iata_code,
jwt_expiry_minutes=self.jwt_expiry_minutes,
- use_tls=self.use_tls,
email=self.email,
owner=self.owner,
broker_index=idx,
+ node_name=self.node_name,
on_connect_callback=self._on_broker_connected,
on_disconnect_callback=self._on_broker_disconnected,
)
@@ -430,6 +482,100 @@ class MeshCoreToMqttPusher:
logger.info(f"Initialized with {len(self.connections)} broker connection(s)")
+ # Convert legacy configration to new one
+ if not mqtt_brokers_config:
+ logger.info("Storing mqtt_brokers config from legacy mqtt/letsmesh configuration")
+ mqtt_brokers_config = {
+ "iata_code": self.iata_code,
+ "status_interval": self.status_interval,
+ "owner": self.owner,
+ "email": self.email,
+ "brokers": brokers
+ }
+
+ # Update the configuration with the new configuration
+ config["mqtt_brokers"] = mqtt_brokers_config
+
+ def convert_mqtt_to_broker_config(self, mqtt_cfg: dict) -> dict:
+ """Convert legacy MQTT config format to internal broker config format"""
+ logger.info(f"Imported MQTT broker from 'mqtt' config: {mqtt_cfg['broker']}")
+ transport = "websockets" if mqtt_cfg.get("use_websockets", False) else "tcp"
+ return {
+ "enabled": mqtt_cfg.get("enabled", False),
+ "name": mqtt_cfg["broker"],
+ "host": mqtt_cfg["broker"],
+ "port": mqtt_cfg["port"],
+ "use_jwt_auth": False, # The legacy MQTT config does not support JWT auth, so we set this to False
+ "username": mqtt_cfg.get("username", None),
+ "password": mqtt_cfg.get("password", None),
+ "transport": transport,
+ "tls": mqtt_cfg.get("tls", None),
+ "format": "mqtt",
+ "base_topic": mqtt_cfg.get("base_topic", None),
+ }
+
+ def convert_letsmesh_to_broker_config(self, letsmesh_cfg: dict) -> List[dict]:
+ """Convert LetsMesh config format to internal broker config format"""
+
+ brokers = []
+
+ enabled = letsmesh_cfg.get("enabled", False)
+
+ idx = letsmesh_cfg.get("broker_index", None)
+ if idx == 0 or idx == 1:
+ broker_info = LETSMESH_BROKERS[idx]
+ logger.info(f"Imported LetsMesh broker from 'letsmesh' config: {broker_info['name']}")
+ brokers.append({
+ "enabled": enabled,
+ "name": broker_info["name"],
+ "host": broker_info["host"],
+ "port": broker_info["port"],
+ "audience": broker_info["audience"],
+ "use_jwt_auth": True,
+ "transport": "websockets",
+ "tls": None,
+ "format": "letsmesh",
+ "base_topic": None,
+ "retain_status": False
+ })
+ elif idx < 0:
+ if idx == -1:
+ brokers.extend({
+ "enabled": enabled,
+ "name": broker_info["name"],
+ "host": broker_info["host"],
+ "port": broker_info["port"],
+ "audience": broker_info["audience"],
+ "use_jwt_auth": True,
+ "transport": "websockets",
+ "tls": None,
+ "format": "letsmesh",
+ "base_topic": None,
+ "retain_status": False
+ } for broker_info in LETSMESH_BROKERS)
+
+ additional = letsmesh_cfg.get("additional_brokers", [])
+ for add_broker in additional:
+ logger.info(f"Imported additional LetsMesh broker from 'letsmesh' config: {add_broker['name']}")
+ brokers.append({
+ "enabled": enabled,
+ "name": add_broker["name"],
+ "host": add_broker["host"],
+ "port": add_broker["port"],
+ "audience": add_broker["audience"],
+ "use_jwt_auth": True,
+ "transport": "websockets",
+ "use_jwt_auth": add_broker.get("use_jwt_auth", True),
+ "transport": add_broker.get("transport", "websockets"),
+ "tls": None,
+ "format": "letsmesh",
+ "base_topic": None,
+ "retain_status": False
+ })
+
+
+ return brokers # Placeholder for now - we will implement this if we need to support the old letsmesh config format
+
def _on_broker_connected(self, broker_name: str):
"""Callback when a broker connects"""
# Publish initial status on first connection
@@ -523,9 +669,6 @@ class MeshCoreToMqttPusher:
def _process_packet(self, pkt: dict) -> dict:
return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt}
- def _topic(self, subtopic: str) -> str:
- return f"meshcore/{self.iata_code}/{self.public_key}/{subtopic}"
-
def publish_packet(self, pkt: dict, subtopic="packets", retain=False):
return self.publish(subtopic, self._process_packet(pkt), retain)
@@ -576,26 +719,50 @@ class MeshCoreToMqttPusher:
def publish(self, subtopic: str, payload: dict, retain: bool = False, qos: int = 0):
"""Publish message to all connected brokers"""
- topic = self._topic(subtopic)
message = json.dumps(payload)
- logger.debug(f"Publishing to topic '{topic}' with payload: {message}")
+ # _BrokerConnection now handles topic prefixing, so we only log the subtopic here
+ logger.debug(f"Publishing to topic '{subtopic}' with payload: {message}")
packet_type = payload.get("type")
results = []
with self._lock:
for conn in self.connections:
- if conn.is_connected():
+ if conn.enabled and conn.is_connected():
if packet_type in conn.disallowed_types:
logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
- return
- result = conn.publish(topic, message, retain=retain, qos=qos)
+ continue
+ result = conn.publish(subtopic, message, retain=retain, qos=qos)
results.append((conn.broker["name"], result))
- logger.debug(f"Published to {conn.broker['name']}/{topic}")
+ logger.debug(f"Published to {conn.broker['name']} -- {subtopic}")
if not results:
- logger.warning(f"No active broker connections for publishing to {topic}")
+ logger.warning(f"No active broker connections for publishing to {subtopic}")
+
+ return results
+
+
+ def publish_mqtt(self, subtopic: str, payload: dict, retain: bool = False, qos: int = 0):
+ """Publish message to all connected brokers"""
+ message = json.dumps(payload)
+
+ # _BrokerConnection now handles topic prefixing, so we only log the subtopic here
+ logger.debug(f"Publishing to topic '{subtopic}' with payload: {message}")
+
+ results = []
+ with self._lock:
+ for conn in self.connections:
+ if conn.enabled and conn.is_connected():
+ if conn.format != "mqtt":
+ logger.debug(f"Skipped publishing to {conn.broker['name']} (wrong format)")
+ continue
+ result = conn.publish(subtopic, message, retain=retain, qos=qos)
+ results.append((conn.broker["name"], result))
+ logger.debug(f"Published to {conn.broker['name']} -- {subtopic}")
+
+ if not results:
+ logger.warning(f"No active broker connections for publishing to {subtopic}")
return results
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index 517e3b4..8df6ec4 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -32,7 +32,7 @@ class StorageCollector:
# Initialize MQTT handler if configured
self.mqtt_handler = None
- if config.get("mqtt", {}) and local_identity:
+ if (config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})) and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
self.mqtt_handler = MeshCoreToMqttPusher(
@@ -159,9 +159,9 @@ class StorageCollector:
# f"Skipping mqtt publish for packet with drop_reason: {packet_record.get('drop_reason')}"
# )
# else:
- self._publish_to_mqtt(packet_record)
+ self._publish_packet_to_mqtt(packet_record)
- def _publish_to_mqtt(self, packet_record: dict):
+ def _publish_packet_to_mqtt(self, packet_record: dict):
"""Publish packet to mqtt broker if enabled and allowed"""
if not self.mqtt_handler:
return
@@ -172,10 +172,6 @@ class StorageCollector:
logger.error("Cannot publish to mqtt: packet_record missing 'type' field")
return
- # if packet_type in self.disallowed_packet_types:
- # logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)")
- # return
-
node_name = self.config.get("repeater", {}).get("node_name", "Unknown")
packet = PacketRecord.from_packet_record(
packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key
@@ -192,12 +188,12 @@ class StorageCollector:
def record_advert(self, advert_record: dict):
self.sqlite_handler.store_advert(advert_record)
- #self.old_mqtt_handler.publish(advert_record, "advert")
+ self.mqtt_handler.publish_mqtt("advert", advert_record)
def record_noise_floor(self, noise_floor_dbm: float):
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
self.sqlite_handler.store_noise_floor(noise_record)
- #self.old_mqtt_handler.publish(noise_record, "noise_floor")
+ self.mqtt_handler.publish_mqtt("noise_floor", noise_record)
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll."""
diff --git a/repeater/data_acquisition/storage_utils.py b/repeater/data_acquisition/storage_utils.py
index bde938e..f0d0751 100644
--- a/repeater/data_acquisition/storage_utils.py
+++ b/repeater/data_acquisition/storage_utils.py
@@ -10,7 +10,7 @@ class PacketRecord:
"""
Data class for packet record format.
Converts internal packet_record format to standardized publish format.
- Reusable across MQTT, LetsMesh, and other handlers.
+ Reusable across MQTT and other handlers.
"""
origin: str
diff --git a/repeater/engine.py b/repeater/engine.py
index 38f47df..8ca0a18 100644
--- a/repeater/engine.py
+++ b/repeater/engine.py
@@ -359,10 +359,10 @@ class RepeaterHandler(BaseHandler):
)
# Store packet record to persistent storage
- # Skip LetsMesh only for invalid packets (not duplicates or operational drops)
+ # Skip mqtt only for invalid packets (not duplicates or operational drops)
if self.storage:
try:
- # Only skip LetsMesh for actual invalid/bad packets
+ # Only skip mqtt for actual invalid/bad packets
invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"]
skip_mqtt = drop_reason in invalid_reasons if drop_reason else False
self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt)
@@ -1138,7 +1138,7 @@ class RepeaterHandler(BaseHandler):
"unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
},
- #"mqtt": self.config.get("mqtt", {}),
+ "mqtt_brokers": self.config.get("mqtt_brokers", {}),
},
"public_key": None,
}
diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py
index 03a0ba1..7799f8b 100644
--- a/repeater/web/api_endpoints.py
+++ b/repeater/web/api_endpoints.py
@@ -1003,8 +1003,7 @@ class APIEndpoints:
"""Get MQTT connection status and configuration."""
self._set_cors_headers()
try:
- mqtt_cfg = self.config.get("mqtt", {})
- enabled = mqtt_cfg.get("enabled", False)
+ # mqtt_cfg = self.config.get("mqtt_brokers", {})
# Walk the chain to the mqtt_handler
handler = None
@@ -1018,14 +1017,17 @@ class APIEndpoints:
if handler:
for conn in getattr(handler, "connections", []):
connected_brokers.append({
+ "enabled": conn.enabled,
"name": conn.broker.get("name", ""),
"host": conn.broker.get("host", ""),
- "connected": conn.is_connected(),
- "reconnecting": conn.has_pending_reconnect(),
+ "status": {
+ "connected": conn.is_connected(),
+ "reconnecting": conn.has_pending_reconnect(),
+ },
+ "format": conn.format
})
return self._success({
- "enabled": enabled,
"handler_active": handler is not None,
"brokers": connected_brokers,
})
@@ -1033,95 +1035,111 @@ class APIEndpoints:
logger.error(f"Error getting MQTT status: {e}")
return self._error(str(e))
- # @cherrypy.expose
- # @cherrypy.tools.json_out()
- # @cherrypy.tools.json_in()
- # def update_mqtt_config(self):
- # """Update MQTT Observer configuration.
+ @cherrypy.expose
+ @cherrypy.tools.json_out()
+ @cherrypy.tools.json_in()
+ def update_mqtt_config(self):
+ """Update MQTT Observer configuration.
- # POST /api/update_mqtt_config
- # Body: {
- # "iata_code": "SFO",
- # "status_interval": 300,
- # "owner": "Callsign",
- # "email": "user@example.com",
- # "disallowed_packet_types": ["ACK"]
- # }
- # """
- # self._set_cors_headers()
+ POST /api/update_mqtt_config
+ Body: {
+ "iata_code": "SFO",
+ "status_interval": 300,
+ "owner": "Callsign",
+ "email": "user@example.com",
+ "brokers": [
+ {
+
+ }]
+ }
+ """
+ self._set_cors_headers()
- # if cherrypy.request.method == "OPTIONS":
- # return ""
+ if cherrypy.request.method == "OPTIONS":
+ return ""
- # try:
- # self._require_post()
- # data = cherrypy.request.json or {}
+ try:
+ self._require_post()
+ data = cherrypy.request.json or {}
- # if not data:
- # return self._error("No configuration updates provided")
+ if not data:
+ return self._error("No configuration updates provided")
- # letsmesh_updates = {}
+ mqtt_updates = {}
- # if "enabled" in data:
- # letsmesh_updates["enabled"] = bool(data["enabled"])
- # if "iata_code" in data:
- # letsmesh_updates["iata_code"] = str(data["iata_code"]).strip()
- # if "broker_index" in data:
- # letsmesh_updates["broker_index"] = int(data["broker_index"])
- # if "status_interval" in data:
- # letsmesh_updates["status_interval"] = max(60, int(data["status_interval"]))
- # if "owner" in data:
- # letsmesh_updates["owner"] = str(data["owner"]).strip()
- # if "email" in data:
- # letsmesh_updates["email"] = str(data["email"]).strip()
- # if "disallowed_packet_types" in data:
- # letsmesh_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"])
- # if "additional_brokers" in data:
- # brokers = data["additional_brokers"]
- # if not isinstance(brokers, list):
- # return self._error("additional_brokers must be a list")
- # validated = []
- # for i, b in enumerate(brokers):
- # if not isinstance(b, dict):
- # return self._error(f"Broker at index {i} must be an object")
- # for field in ("name", "host", "audience"):
- # if not b.get(field, "").strip():
- # return self._error(f"Broker at index {i} missing required field: {field}")
- # try:
- # port = int(b.get("port", 443))
- # except (ValueError, TypeError):
- # return self._error(f"Broker at index {i} has invalid port")
- # validated.append({
- # "name": str(b["name"]).strip(),
- # "host": str(b["host"]).strip(),
- # "port": port,
- # "audience": str(b["audience"]).strip(),
- # })
- # letsmesh_updates["additional_brokers"] = validated
+ if "iata_code" in data:
+ mqtt_updates["iata_code"] = str(data["iata_code"]).strip()
+ if "status_interval" in data:
+ mqtt_updates["status_interval"] = max(60, int(data["status_interval"]))
+ if "owner" in data:
+ mqtt_updates["owner"] = str(data["owner"]).strip()
+ if "email" in data:
+ mqtt_updates["email"] = str(data["email"]).strip()
+ # 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")
+ validated = []
+ for i, b in enumerate(brokers):
+ if not isinstance(b, dict):
+ return self._error(f"Broker at index {i} must be an object")
+ for field in ("name", "host", "port", "format"):
+ if not b.get(field, ""):
+ return self._error(f"Broker at index {i} missing required field: {field}")
+
+ try:
+ port = int(b.get("port", 443))
+ except (ValueError, TypeError):
+ return self._error(f"Broker at index {i} has invalid port")
+
+ new_broker = {
+ "enabled": b.get("enabled", False),
+ "name": str(b["name"]).strip(),
+ "transport": str(b.get("transport", "websockets")).strip(),
+ "host": str(b["host"]).strip(),
+ "port": port,
+ "format": str(b["format"]).strip(),
+ "disallowed_packet_types": list(b.get("disallowed_packet_types", [])),
+ "retain_status": bool(b.get("retain_status", False)),
+ }
+
+ if b.get("use_jwt_auth", False):
+ new_broker["use_jwt_auth"] = True
+ new_broker["audience"] = str(b["audience"]).strip()
+ else:
+ new_broker["use_jwt_auth"] = False
+ new_broker["username"] = b.get("username", None)
+ new_broker["password"] = b.get("password", None)
- # if not letsmesh_updates:
- # return self._error("No valid settings provided")
+ validated.append(new_broker)
- # result = self.config_manager.update_and_save(
- # updates={"letsmesh": letsmesh_updates},
- # live_update=False, # Restart required for LetsMesh handler changes
- # )
+ mqtt_updates["brokers"] = validated
- # if result.get("success"):
- # logger.info(f"LetsMesh config updated: {list(letsmesh_updates.keys())}")
- # return self._success({
- # "persisted": result.get("saved", False),
- # "restart_required": True,
- # "message": "Observer settings saved. Restart the service for changes to take effect.",
- # })
- # else:
- # return self._error(result.get("error", "Failed to update LetsMesh configuration"))
+ if not mqtt_updates:
+ return self._error("No valid settings provided")
- # except cherrypy.HTTPError:
- # raise
- # except Exception as e:
- # logger.error(f"Error updating LetsMesh config: {e}")
- # return self._error(str(e))
+ result = self.config_manager.update_and_save(
+ updates={"mqtt_brokers": mqtt_updates, "mqtt": None, "letsmesh": None},
+ live_update=False, # Restart required for MQTT handler changes
+ )
+
+ if result.get("success"):
+ logger.info(f"MQTT config updated: {list(mqtt_updates.keys())}")
+ return self._success({
+ "persisted": result.get("saved", False),
+ "restart_required": True,
+ "message": "Observer settings saved. Restart the service for changes to take effect.",
+ })
+ else:
+ return self._error(result.get("error", "Failed to update LetsMesh configuration"))
+
+ except cherrypy.HTTPError:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating LetsMesh config: {e}")
+ return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
From 01aed0db2bfa331f13e15c6182fe2babb7cc70ae Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Wed, 15 Apr 2026 21:22:02 -0700
Subject: [PATCH 07/72] docs: Added retain_status message to example config
---
config.yaml.example | 2 ++
1 file changed, 2 insertions(+)
diff --git a/config.yaml.example b/config.yaml.example
index a347b9b..f4c8dd2 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -304,6 +304,8 @@ mqtt:
# use_jwt_auth: true|false # Does this endpoint require JWT auth
# username: "" # Username for basic auth. If empty or missing, uses anonymous access
# password: "" # Password for basic auth. Required if username is set
+ # format: letsmesh|mqtt
+ # 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)
# Block specific packet types from being published to the MQTT endpoint
# If not specified or empty list, all types are published
From 4569ff8653c40abed682542ba6705f80427a9060 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Wed, 15 Apr 2026 22:05:54 -0700
Subject: [PATCH 08/72] feat: publish crc_records to mqtt
---
repeater/data_acquisition/storage_collector.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py
index 8df6ec4..917b2e1 100644
--- a/repeater/data_acquisition/storage_collector.py
+++ b/repeater/data_acquisition/storage_collector.py
@@ -199,7 +199,7 @@ class StorageCollector:
"""Record a batch of CRC errors detected since last poll."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
- #self.old_mqtt_handler.publish(crc_record, "crc_errors")
+ self.mqtt_handler.publish_mqtt("crc_errors", crc_record)
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
From 6b531e85e7dcb1a03aabe083c85f7658649c4825 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Wed, 15 Apr 2026 22:32:22 -0700
Subject: [PATCH 09/72] feat: TLS pass
---
repeater/data_acquisition/mqtt_handler.py | 33 +++++++++++++++--------
1 file changed, 22 insertions(+), 11 deletions(-)
diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py
index 3cf89f6..a4405c4 100644
--- a/repeater/data_acquisition/mqtt_handler.py
+++ b/repeater/data_acquisition/mqtt_handler.py
@@ -43,6 +43,10 @@ LETSMESH_BROKERS = [
"port": 443,
"audience": "mqtt-eu-v1.letsmesh.net",
"use_jwt_auth": True,
+ "tls": {
+ "enabled": True,
+ "insecure": False,
+ },
},
{
"name": "US West (LetsMesh v1)",
@@ -50,6 +54,10 @@ LETSMESH_BROKERS = [
"port": 443,
"audience": "mqtt-us-v1.letsmesh.net",
"use_jwt_auth": True,
+ "tls": {
+ "enabled": True,
+ "insecure": False,
+ },
},
]
@@ -149,7 +157,7 @@ class _BrokerConnection:
payload["aud"] = self.broker["audience"]
# Only include email/owner for verified TLS connections
- if self.use_tls and self._tls_verified and (self.email or self.owner):
+ if self.tls and self.tls.get("enabled", False) and self._tls_verified and (self.email or self.owner):
payload["email"] = self.email
payload["owner"] = self.owner
else:
@@ -273,11 +281,11 @@ class _BrokerConnection:
"""Establish connection to broker"""
# Conditional TLS setup
if self.transport == "websockets":
- if self.use_tls:
+ if self.tls and self.tls.get("enabled", True):
import ssl
self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT)
- self.client.tls_insecure_set(False)
+ self.client.tls_insecure_set(self.tls.get("insecure", False))
self._tls_verified = True
protocol = "wss"
else:
@@ -402,7 +410,6 @@ class MeshCoreToMqttPusher:
local_identity,
config: dict,
jwt_expiry_minutes: int = 10,
- use_tls: bool = True,
stats_provider: Optional[Callable[[], dict]] = None,
):
# Store local identity and get public key
@@ -533,10 +540,10 @@ class MeshCoreToMqttPusher:
"audience": broker_info["audience"],
"use_jwt_auth": True,
"transport": "websockets",
- "tls": None,
"format": "letsmesh",
"base_topic": None,
- "retain_status": False
+ "retain_status": False,
+ "tls": broker_info["tls"],
})
elif idx < 0:
if idx == -1:
@@ -548,10 +555,10 @@ class MeshCoreToMqttPusher:
"audience": broker_info["audience"],
"use_jwt_auth": True,
"transport": "websockets",
- "tls": None,
"format": "letsmesh",
"base_topic": None,
- "retain_status": False
+ "retain_status": False,
+ "tls": broker_info["tls"],
} for broker_info in LETSMESH_BROKERS)
additional = letsmesh_cfg.get("additional_brokers", [])
@@ -567,14 +574,17 @@ class MeshCoreToMqttPusher:
"transport": "websockets",
"use_jwt_auth": add_broker.get("use_jwt_auth", True),
"transport": add_broker.get("transport", "websockets"),
- "tls": None,
"format": "letsmesh",
"base_topic": None,
- "retain_status": False
+ "retain_status": False,
+ "tls": {
+ "enabled": add_broker.get("tls", {}).get("enabled", True),
+ "insecure": add_broker.get("tls", {}).get("insecure", False),
+ }
})
- return brokers # Placeholder for now - we will implement this if we need to support the old letsmesh config format
+ return brokers
def _on_broker_connected(self, broker_name: str):
"""Callback when a broker connects"""
@@ -756,6 +766,7 @@ class MeshCoreToMqttPusher:
if conn.enabled and conn.is_connected():
if conn.format != "mqtt":
logger.debug(f"Skipped publishing to {conn.broker['name']} (wrong format)")
+ results.append((conn.broker["name"], None)) # Indicate skipped due to format mismatch
continue
result = conn.publish(subtopic, message, retain=retain, qos=qos)
results.append((conn.broker["name"], result))
From 6d133efdbef5d394dbf65886f77cf59da59e2be9 Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Thu, 16 Apr 2026 13:23:46 -0700
Subject: [PATCH 10/72] fix: If we're using websockets, default to tls enabled
= true if we're using port 443
---
repeater/web/api_endpoints.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py
index 7799f8b..2fb6988 100644
--- a/repeater/web/api_endpoints.py
+++ b/repeater/web/api_endpoints.py
@@ -1103,6 +1103,10 @@ class APIEndpoints:
"format": str(b["format"]).strip(),
"disallowed_packet_types": list(b.get("disallowed_packet_types", [])),
"retain_status": bool(b.get("retain_status", False)),
+ "tls": {
+ "enabled": bool(b.get("tls", {}).get("enabled", True if port == 443 else False)),
+ "insecure": bool(b.get("tls", {}).get("insecure", False)),
+ }
}
if b.get("use_jwt_auth", False):
From f641761b05ed44ff9afeeb33994893cdfccdbbdd Mon Sep 17 00:00:00 2001
From: Rigear <278971+Rigear@users.noreply.github.com>
Date: Thu, 16 Apr 2026 14:54:30 -0700
Subject: [PATCH 11/72] feat: UI updated from
https://github.com/Rigear/pyMC-RepeaterUI/commit/4a24b6d2c7699294c9f90ea3f0d05b0456b8b3e2
---
...ibration-Cwr0Kq49.js => CADCalibration-BmDQq19C.js} | 2 +-
.../{Companions-DU19yZyB.js => Companions-q2YtVqaW.js} | 2 +-
...uration-DavFlb5x.css => Configuration-CYfHaSvS.css} | 2 +-
repeater/web/html/assets/Configuration-UerYmVHF.js | 2 --
repeater/web/html/assets/Configuration-ZZ9QMoWg.js | 2 ++
...irmDialog-BafURQpE.js => ConfirmDialog-o59TbpOe.js} | 2 +-
.../{Dashboard-Bf4ERov9.js => Dashboard-Bdz_sy30.js} | 2 +-
.../assets/{Login-B5lFAaMX.js => Login-C9OKVXIt.js} | 2 +-
.../html/assets/{Logs-BpG7T8_d.js => Logs-ClWCVjRj.js} | 2 +-
...ageDialog-D2OlpbZ7.js => MessageDialog-DeNZ46o8.js} | 2 +-
.../{Neighbors-WHAK_7hU.js => Neighbors-DUsfBrXh.js} | 2 +-
repeater/web/html/assets/RFNoiseFloor-CqsbZ-PE.js | 1 -
repeater/web/html/assets/RFNoiseFloor-RxGxhb7H.js | 1 +
...RoomServers-o3kDed-S.js => RoomServers-B5yAppv0.js} | 2 +-
.../{Sessions-B8ZVRIGt.js => Sessions-B-imie3i.js} | 2 +-
.../assets/{Setup-Cs8nXwGE.js => Setup-BIgxFJ6B.js} | 2 +-
.../{Statistics-CeTg6NYy.js => Statistics-BuEpeoe0.js} | 2 +-
...SystemStats-B7qxcRYp.js => SystemStats-t88aS3zn.js} | 2 +-
.../{Terminal-D1kRkrmc.js => Terminal-BM9potCU.js} | 2 +-
.../html/assets/{api-CiSov_eM.js => api-1i-Hebdq.js} | 4 ++--
repeater/web/html/assets/index-BIGNPUD_.css | 2 ++
repeater/web/html/assets/index-C29IW84J.css | 2 --
.../assets/{index-BPH8UrxU.js => index-DvBnlrdp.js} | 4 ++--
.../{packets-Bg0pkGLO.js => packets-Bp9NyRR7.js} | 2 +-
.../assets/{system-Bocs8bSU.js => system-Dp7XsVzd.js} | 2 +-
...uality-DQTATYAm.js => useSignalQuality-BWCuqYAr.js} | 2 +-
repeater/web/html/index.html | 10 +++++-----
27 files changed, 32 insertions(+), 32 deletions(-)
rename repeater/web/html/assets/{CADCalibration-Cwr0Kq49.js => CADCalibration-BmDQq19C.js} (98%)
rename repeater/web/html/assets/{Companions-DU19yZyB.js => Companions-q2YtVqaW.js} (99%)
rename repeater/web/html/assets/{Configuration-DavFlb5x.css => Configuration-CYfHaSvS.css} (98%)
delete mode 100644 repeater/web/html/assets/Configuration-UerYmVHF.js
create mode 100644 repeater/web/html/assets/Configuration-ZZ9QMoWg.js
rename repeater/web/html/assets/{ConfirmDialog-BafURQpE.js => ConfirmDialog-o59TbpOe.js} (97%)
rename repeater/web/html/assets/{Dashboard-Bf4ERov9.js => Dashboard-Bdz_sy30.js} (99%)
rename repeater/web/html/assets/{Login-B5lFAaMX.js => Login-C9OKVXIt.js} (98%)
rename repeater/web/html/assets/{Logs-BpG7T8_d.js => Logs-ClWCVjRj.js} (98%)
rename repeater/web/html/assets/{MessageDialog-D2OlpbZ7.js => MessageDialog-DeNZ46o8.js} (94%)
rename repeater/web/html/assets/{Neighbors-WHAK_7hU.js => Neighbors-DUsfBrXh.js} (99%)
delete mode 100644 repeater/web/html/assets/RFNoiseFloor-CqsbZ-PE.js
create mode 100644 repeater/web/html/assets/RFNoiseFloor-RxGxhb7H.js
rename repeater/web/html/assets/{RoomServers-o3kDed-S.js => RoomServers-B5yAppv0.js} (99%)
rename repeater/web/html/assets/{Sessions-B8ZVRIGt.js => Sessions-B-imie3i.js} (99%)
rename repeater/web/html/assets/{Setup-Cs8nXwGE.js => Setup-BIgxFJ6B.js} (99%)
rename repeater/web/html/assets/{Statistics-CeTg6NYy.js => Statistics-BuEpeoe0.js} (99%)
rename repeater/web/html/assets/{SystemStats-B7qxcRYp.js => SystemStats-t88aS3zn.js} (99%)
rename repeater/web/html/assets/{Terminal-D1kRkrmc.js => Terminal-BM9potCU.js} (99%)
rename repeater/web/html/assets/{api-CiSov_eM.js => api-1i-Hebdq.js} (95%)
create mode 100644 repeater/web/html/assets/index-BIGNPUD_.css
delete mode 100644 repeater/web/html/assets/index-C29IW84J.css
rename repeater/web/html/assets/{index-BPH8UrxU.js => index-DvBnlrdp.js} (99%)
rename repeater/web/html/assets/{packets-Bg0pkGLO.js => packets-Bp9NyRR7.js} (97%)
rename repeater/web/html/assets/{system-Bocs8bSU.js => system-Dp7XsVzd.js} (96%)
rename repeater/web/html/assets/{useSignalQuality-DQTATYAm.js => useSignalQuality-BWCuqYAr.js} (91%)
diff --git a/repeater/web/html/assets/CADCalibration-Cwr0Kq49.js b/repeater/web/html/assets/CADCalibration-BmDQq19C.js
similarity index 98%
rename from repeater/web/html/assets/CADCalibration-Cwr0Kq49.js
rename to repeater/web/html/assets/CADCalibration-BmDQq19C.js
index db0db79..c2b82c2 100644
--- a/repeater/web/html/assets/CADCalibration-Cwr0Kq49.js
+++ b/repeater/web/html/assets/CADCalibration-BmDQq19C.js
@@ -1 +1 @@
-import{r as e}from"./chunk-DECur_0Z.js";import{C as t,S as n,dt as r,f as i,g as a,l as o,o as s,p as c,s as l,u,ut as d,w as f,z as p}from"./runtime-core.esm-bundler-IofF4kUm.js";import{s as m,t as h}from"./api-CiSov_eM.js";import{t as g}from"./system-Bocs8bSU.js";import{t as _}from"./_plugin-vue_export-helper-V-yks4gF.js";import{t as v}from"./plotly.min-Bnm7le34.js";var y=e(v(),1),b={class:`p-6 space-y-6`},ee={class:`glass-card rounded-[15px] p-6`},te={class:`flex justify-center`},ne={class:`flex gap-4`},re=[`disabled`],ie=[`disabled`],ae={class:`glass-card rounded-[15px] p-6 space-y-4`},oe={class:`text-content-primary dark:text-content-primary`},se={key:0,class:`p-4 bg-primary/10 border border-primary/30 rounded-lg`},ce={class:`text-content-primary dark:text-primary`},le={class:`space-y-2`},ue={class:`w-full bg-white/10 rounded-full h-2`},de={class:`text-content-secondary dark:text-content-muted text-sm`},x={class:`grid grid-cols-2 md:grid-cols-4 gap-4`},fe={class:`glass-card rounded-[15px] p-4 text-center`},S={class:`text-2xl font-bold text-primary`},C={class:`glass-card rounded-[15px] p-4 text-center`},w={class:`text-2xl font-bold text-primary`},T={class:`glass-card rounded-[15px] p-4 text-center`},E={class:`text-2xl font-bold text-primary`},D={class:`glass-card rounded-[15px] p-4 text-center`},O={class:`text-2xl font-bold text-primary`},k={key:0,class:`glass-card rounded-[15px] p-6 space-y-4`},A={key:0,class:`p-4 bg-accent-green/10 border border-accent-green/30 rounded-lg`},j={class:`text-content-primary dark:text-content-primary mb-4`},M={key:1,class:`p-4 bg-secondary/20 border border-secondary/40 rounded-lg`},N=_(a({name:`CADCalibrationView`,__name:`CADCalibration`,setup(e){let a=g(),_=s(()=>document.documentElement.classList.contains(`dark`)),v=()=>{let e=_.value;return{title:e?`#F9FAFB`:`#111827`,subtitle:e?`#9CA3AF`:`#6B7280`,axis:e?`#D1D5DB`:`#374151`,tick:e?`#9CA3AF`:`#6B7280`,grid:e?`rgba(148, 163, 184, 0.1)`:`rgba(107, 114, 128, 0.15)`,zeroline:e?`rgba(148, 163, 184, 0.2)`:`rgba(107, 114, 128, 0.25)`,line:e?`rgba(148, 163, 184, 0.3)`:`rgba(107, 114, 128, 0.35)`,colorbarBorder:e?`rgba(255,255,255,0.2)`:`rgba(0,0,0,0.15)`,markerLine:e?`rgba(255,255,255,0.2)`:`rgba(0,0,0,0.15)`}},N=p(!1),P=p(null),F=p(null),I=p({}),L=p(null),R=p([]),z=p({}),B=p(`Ready to start calibration`),V=p(0),H=p(0),U=p(0),W=p(0),G=p(0),K=p(0),q=p(null),J=p(!1),Y=p(!1),X=p(!1),Z=p(!1),Q=null,pe={responsive:!0,displayModeBar:!0,modeBarButtonsToRemove:[`pan2d`,`select2d`,`lasso2d`,`autoScale2d`],displaylogo:!1,toImageButtonOptions:{format:`png`,filename:`cad-calibration-heatmap`,height:600,width:800,scale:2}};function me(){let e=v(),t=[{x:[],y:[],z:[],mode:`markers`,type:`scatter`,marker:{size:12,color:[],colorscale:[[0,`rgba(75, 85, 99, 0.4)`],[.1,`rgba(6, 182, 212, 0.3)`],[.5,`rgba(6, 182, 212, 0.6)`],[1,`rgba(16, 185, 129, 0.9)`]],showscale:!0,colorbar:{title:{text:`Detection Rate (%)`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},bgcolor:`rgba(0,0,0,0)`,bordercolor:e.colorbarBorder,borderwidth:1,thickness:15},line:{color:e.markerLine,width:1}},hovertemplate:`Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Channel Activity Detection Calibration`,font:{color:e.title,size:18},x:.5},xaxis:{title:{text:`CAD Peak Threshold`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},yaxis:{title:{text:`CAD Min Threshold`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},plot_bgcolor:`rgba(0, 0, 0, 0)`,paper_bgcolor:`rgba(0, 0, 0, 0)`,font:{color:e.title,family:`Inter, system-ui, sans-serif`},margin:{l:80,r:80,t:100,b:80},showlegend:!1};y.default.newPlot(`plotly-chart`,t,n,pe)}function he(){if(Object.keys(I.value).length===0)return;let e=Object.values(I.value),t=[],n=[],r=[];for(let i of e)t.push(i.det_peak),n.push(i.det_min),r.push(i.detection_rate);let i={x:[t],y:[n],"marker.color":[r],hovertemplate:`Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Status: Tested
Manage companion identities (TCP frame server)
Manage companion identities (TCP frame server)
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Activity (Last 24 Hours)
Activity (Last 24 Hours)
Sign in to access your dashboard
No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file +import{E as e,S as t,dt as n,f as r,g as i,l as a,lt as o,o as s,p as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-1i-Hebdq.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=s(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=s(()=>Array.from(R.value).sort()),J=s(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({ERROR:`text-red-600 dark:text-red-400 bg-red-900/20`,WARNING:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,WARN:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,INFO:`text-blue-600 dark:text-blue-400 bg-blue-900/20`,DEBUG:`text-gray-400 bg-gray-900/20`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{ERROR:`bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50`,WARNING:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,WARN:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,INFO:`bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50`,DEBUG:`bg-gray-500/20 text-gray-400 border-gray-500/50`}[e]||`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.value,class:`flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50`},[(f(),d(`svg`,{class:o([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15`},null,-1)]],2)),c(` `+n(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Loggers `),u(`button`,{onClick:ne,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Loggers `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Levels `),u(`button`,{onClick:ie,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Levels `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10`])},n(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},n(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})])],-1),i[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,n(V.value),1),u(`button`,{onClick:G,class:`px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors`},` Try Again `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/MessageDialog-D2OlpbZ7.js b/repeater/web/html/assets/MessageDialog-DeNZ46o8.js similarity index 94% rename from repeater/web/html/assets/MessageDialog-D2OlpbZ7.js rename to repeater/web/html/assets/MessageDialog-DeNZ46o8.js index b196c6f..870f161 100644 --- a/repeater/web/html/assets/MessageDialog-D2OlpbZ7.js +++ b/repeater/web/html/assets/MessageDialog-DeNZ46o8.js @@ -1 +1 @@ -import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-BPH8UrxU.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file +import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-DvBnlrdp.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-WHAK_7hU.js b/repeater/web/html/assets/Neighbors-DUsfBrXh.js similarity index 99% rename from repeater/web/html/assets/Neighbors-WHAK_7hU.js rename to repeater/web/html/assets/Neighbors-DUsfBrXh.js index aaa97f2..e54a13b 100644 --- a/repeater/web/html/assets/Neighbors-WHAK_7hU.js +++ b/repeater/web/html/assets/Neighbors-DUsfBrXh.js @@ -1,4 +1,4 @@ -import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,E as r,S as i,b as a,c as o,dt as s,f as c,g as l,i as u,j as d,k as f,l as p,lt as m,m as h,o as g,p as _,r as v,s as y,u as b,ut as x,w as S,z as C}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as w}from"./api-CiSov_eM.js";import{t as T}from"./system-Bocs8bSU.js";import{t as E}from"./_plugin-vue_export-helper-V-yks4gF.js";import{d as D,f as O,m as k,s as A,u as j}from"./index-BPH8UrxU.js";import{t as M}from"./leaflet-src-BtX0-WJ4.js";/* empty css */import{n as N,t as P}from"./preferences-N3Pls1rF.js";import{t as F}from"./useSignalQuality-DQTATYAm.js";var I={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6`},L={class:`flex items-center gap-3`},R={class:`flex-1 min-w-0`},z={class:`text-content-primary dark:text-content-primary font-medium truncate`},B={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},V={key:0,class:`text-white/50 text-xs`},H={key:1,class:`text-white/50 text-xs`},U=l({__name:`DeleteNeighborModal`,props:{show:{type:Boolean},neighbor:{}},emits:[`close`,`delete`],setup(e,{emit:t}){let n=e,r=t,i=()=>{n.neighbor&&(r(`delete`,n.neighbor.id),a())},a=()=>{r(`close`)},o=e=>{e.target===e.currentTarget&&a()};return(t,n)=>e.show&&e.neighbor?(S(),b(`div`,{key:0,onClick:o,class:`fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[y(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:n[0]||=k(()=>{},[`stop`])},[y(`div`,{class:`flex items-center gap-3 mb-6`},[n[2]||=y(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z`})],-1),n[3]||=y(`div`,null,[y(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Delete Neighbor `),y(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mt-1`},` Are you sure you want to delete this neighbor? `)],-1),y(`button`,{onClick:a,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[1]||=[y(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),y(`div`,I,[y(`div`,L,[y(`div`,R,[y(`div`,z,s(e.neighbor?.node_name||e.neighbor?.long_name||e.neighbor?.short_name||`Unknown`),1),y(`div`,B,` ID: `+s(e.neighbor?.node_num_hex||e.neighbor?.node_num||e.neighbor?.id||`N/A`),1),e.neighbor?.contact_type?(S(),b(`div`,V,s(e.neighbor.contact_type),1)):p(``,!0),e.neighbor?.hw_model?(S(),b(`div`,H,s(e.neighbor.hw_model),1)):p(``,!0)])])]),n[4]||=y(`div`,{class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},[y(`div`,{class:`flex items-center gap-2 text-accent-red text-sm`},[y(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})]),y(`span`,null,`This action cannot be undone`)])],-1),y(`div`,{class:`flex gap-3`},[y(`button`,{onClick:a,class:`flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors`},` Cancel `),y(`button`,{onClick:i,class:`flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium`},` Delete `)])])])):p(``,!0)}}),W={class:`bg-gradient-to-r from-primary/20 to-accent-cyan/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4`},G={class:`flex items-center justify-between`},K={class:`flex items-center gap-3`},ee={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},te={class:`p-6`},q={key:0,class:`text-center py-8`},ne={key:1,class:`text-center py-8`},re={class:`text-content-secondary dark:text-content-muted text-sm`},ie={key:2,class:`space-y-4`},ae={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},oe={class:`flex items-center justify-between mb-2`},se={class:`flex items-baseline gap-2`},ce={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},le={class:`grid grid-cols-2 gap-3`},ue={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},de={class:`flex items-center gap-2 mb-2`},fe={class:`flex gap-0.5`},pe={class:`flex items-baseline gap-1`},me={class:`text-xl font-bold text-content-primary dark:text-content-primary`},he={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},ge={class:`flex items-baseline gap-1`},_e={class:`text-xl font-bold text-content-primary dark:text-content-primary`},ve={key:0,class:`flex items-start gap-3 bg-amber-500/10 border border-amber-500/30 rounded-[12px] p-3`},ye={class:`text-xs leading-relaxed`},be={class:`font-semibold text-amber-600 dark:text-amber-400 mb-0.5`},xe={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},Se={class:`relative`},Ce={class:`flex items-center gap-2 overflow-x-auto pb-2`},we={key:0,class:`relative flex items-center`},Te={key:0,class:`absolute left-1/2 -translate-x-1/2 animate-pulse`},Ee={class:`text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between`},De={key:0,class:`text-cyan-500 dark:text-primary animate-pulse`},Oe={class:`flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2`},ke=E(l({__name:`PingResultModal`,props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:[`close`],setup(e,{emit:n}){let i=e,a=n,c=T(),{getSignalQuality:l}=F(),d=C(0),_=C(!1),x=g(()=>{let e=c.stats?.config?.radio?.spreading_factor??7,t=c.stats?.config?.radio?.bandwidth??125,n=c.stats?.config?.radio?.coding_rate??5;return 2**e/t*(8+4.25*(n-4)+20)}),w=g(()=>{if(!i.result)return{color:`text-gray-400`,label:`Unknown`};let e=i.result.rtt_ms,t=x.value,n=i.result.path.length,r=2*t*n+500*n;return eManage room server identities and messages
No messages yet
Be the first to start the conversation
Manage room server identities and messages
No messages yet
Be the first to start the conversation
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Packet Rate (RX/TX PER HOUR)
Packet Rate (RX/TX PER HOUR)
In Progress
In Progress
Manage companion identities (TCP frame server)
Manage companion identities (TCP frame server)
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Activity (Last 24 Hours)
Activity (Last 24 Hours)
Sign in to access your dashboard
No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file +import{E as e,S as t,dt as n,f as r,g as i,l as a,lt as o,o as s,p as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-iZry0Vmp.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=s(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=s(()=>Array.from(R.value).sort()),J=s(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({ERROR:`text-red-600 dark:text-red-400 bg-red-900/20`,WARNING:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,WARN:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,INFO:`text-blue-600 dark:text-blue-400 bg-blue-900/20`,DEBUG:`text-gray-400 bg-gray-900/20`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{ERROR:`bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50`,WARNING:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,WARN:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,INFO:`bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50`,DEBUG:`bg-gray-500/20 text-gray-400 border-gray-500/50`}[e]||`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.value,class:`flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50`},[(f(),d(`svg`,{class:o([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15`},null,-1)]],2)),c(` `+n(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Loggers `),u(`button`,{onClick:ne,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Loggers `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Levels `),u(`button`,{onClick:ie,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Levels `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10`])},n(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},n(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})])],-1),i[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,n(V.value),1),u(`button`,{onClick:G,class:`px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors`},` Try Again `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/MessageDialog-DeNZ46o8.js b/repeater/web/html/assets/MessageDialog-Bo2BPor8.js similarity index 94% rename from repeater/web/html/assets/MessageDialog-DeNZ46o8.js rename to repeater/web/html/assets/MessageDialog-Bo2BPor8.js index 870f161..d55c47a 100644 --- a/repeater/web/html/assets/MessageDialog-DeNZ46o8.js +++ b/repeater/web/html/assets/MessageDialog-Bo2BPor8.js @@ -1 +1 @@ -import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-DvBnlrdp.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file +import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-C4RaXVWf.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-DUsfBrXh.js b/repeater/web/html/assets/Neighbors-DzwrDOOB.js similarity index 99% rename from repeater/web/html/assets/Neighbors-DUsfBrXh.js rename to repeater/web/html/assets/Neighbors-DzwrDOOB.js index e54a13b..d437576 100644 --- a/repeater/web/html/assets/Neighbors-DUsfBrXh.js +++ b/repeater/web/html/assets/Neighbors-DzwrDOOB.js @@ -1,4 +1,4 @@ -import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,E as r,S as i,b as a,c as o,dt as s,f as c,g as l,i as u,j as d,k as f,l as p,lt as m,m as h,o as g,p as _,r as v,s as y,u as b,ut as x,w as S,z as C}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as w}from"./api-1i-Hebdq.js";import{t as T}from"./system-Dp7XsVzd.js";import{t as E}from"./_plugin-vue_export-helper-V-yks4gF.js";import{d as D,f as O,m as k,s as A,u as j}from"./index-DvBnlrdp.js";import{t as M}from"./leaflet-src-BtX0-WJ4.js";/* empty css */import{n as N,t as P}from"./preferences-N3Pls1rF.js";import{t as F}from"./useSignalQuality-BWCuqYAr.js";var I={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6`},L={class:`flex items-center gap-3`},R={class:`flex-1 min-w-0`},z={class:`text-content-primary dark:text-content-primary font-medium truncate`},B={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},V={key:0,class:`text-white/50 text-xs`},H={key:1,class:`text-white/50 text-xs`},U=l({__name:`DeleteNeighborModal`,props:{show:{type:Boolean},neighbor:{}},emits:[`close`,`delete`],setup(e,{emit:t}){let n=e,r=t,i=()=>{n.neighbor&&(r(`delete`,n.neighbor.id),a())},a=()=>{r(`close`)},o=e=>{e.target===e.currentTarget&&a()};return(t,n)=>e.show&&e.neighbor?(S(),b(`div`,{key:0,onClick:o,class:`fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[y(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:n[0]||=k(()=>{},[`stop`])},[y(`div`,{class:`flex items-center gap-3 mb-6`},[n[2]||=y(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z`})],-1),n[3]||=y(`div`,null,[y(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Delete Neighbor `),y(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mt-1`},` Are you sure you want to delete this neighbor? `)],-1),y(`button`,{onClick:a,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[1]||=[y(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),y(`div`,I,[y(`div`,L,[y(`div`,R,[y(`div`,z,s(e.neighbor?.node_name||e.neighbor?.long_name||e.neighbor?.short_name||`Unknown`),1),y(`div`,B,` ID: `+s(e.neighbor?.node_num_hex||e.neighbor?.node_num||e.neighbor?.id||`N/A`),1),e.neighbor?.contact_type?(S(),b(`div`,V,s(e.neighbor.contact_type),1)):p(``,!0),e.neighbor?.hw_model?(S(),b(`div`,H,s(e.neighbor.hw_model),1)):p(``,!0)])])]),n[4]||=y(`div`,{class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},[y(`div`,{class:`flex items-center gap-2 text-accent-red text-sm`},[y(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})]),y(`span`,null,`This action cannot be undone`)])],-1),y(`div`,{class:`flex gap-3`},[y(`button`,{onClick:a,class:`flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors`},` Cancel `),y(`button`,{onClick:i,class:`flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium`},` Delete `)])])])):p(``,!0)}}),W={class:`bg-gradient-to-r from-primary/20 to-accent-cyan/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4`},G={class:`flex items-center justify-between`},K={class:`flex items-center gap-3`},ee={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},te={class:`p-6`},q={key:0,class:`text-center py-8`},ne={key:1,class:`text-center py-8`},re={class:`text-content-secondary dark:text-content-muted text-sm`},ie={key:2,class:`space-y-4`},ae={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},oe={class:`flex items-center justify-between mb-2`},se={class:`flex items-baseline gap-2`},ce={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},le={class:`grid grid-cols-2 gap-3`},ue={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},de={class:`flex items-center gap-2 mb-2`},fe={class:`flex gap-0.5`},pe={class:`flex items-baseline gap-1`},me={class:`text-xl font-bold text-content-primary dark:text-content-primary`},he={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},ge={class:`flex items-baseline gap-1`},_e={class:`text-xl font-bold text-content-primary dark:text-content-primary`},ve={key:0,class:`flex items-start gap-3 bg-amber-500/10 border border-amber-500/30 rounded-[12px] p-3`},ye={class:`text-xs leading-relaxed`},be={class:`font-semibold text-amber-600 dark:text-amber-400 mb-0.5`},xe={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},Se={class:`relative`},Ce={class:`flex items-center gap-2 overflow-x-auto pb-2`},we={key:0,class:`relative flex items-center`},Te={key:0,class:`absolute left-1/2 -translate-x-1/2 animate-pulse`},Ee={class:`text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between`},De={key:0,class:`text-cyan-500 dark:text-primary animate-pulse`},Oe={class:`flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2`},ke=E(l({__name:`PingResultModal`,props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:[`close`],setup(e,{emit:n}){let i=e,a=n,c=T(),{getSignalQuality:l}=F(),d=C(0),_=C(!1),x=g(()=>{let e=c.stats?.config?.radio?.spreading_factor??7,t=c.stats?.config?.radio?.bandwidth??125,n=c.stats?.config?.radio?.coding_rate??5;return 2**e/t*(8+4.25*(n-4)+20)}),w=g(()=>{if(!i.result)return{color:`text-gray-400`,label:`Unknown`};let e=i.result.rtt_ms,t=x.value,n=i.result.path.length,r=2*t*n+500*n;return eManage room server identities and messages
No messages yet
Be the first to start the conversation
Manage room server identities and messages
No messages yet
Be the first to start the conversation
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Packet Rate (RX/TX PER HOUR)
Packet Rate (RX/TX PER HOUR)
In Progress
In Progress
Manage companion identities (TCP frame server)
Manage companion identities (TCP frame server)
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Activity (Last 24 Hours)
Activity (Last 24 Hours)
Sign in to access your dashboard
No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file +import{E as e,S as t,dt as n,f as r,g as i,l as a,lt as o,o as s,p as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-DjLVJkR1.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=s(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=s(()=>Array.from(R.value).sort()),J=s(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({ERROR:`text-red-600 dark:text-red-400 bg-red-900/20`,WARNING:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,WARN:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,INFO:`text-blue-600 dark:text-blue-400 bg-blue-900/20`,DEBUG:`text-gray-400 bg-gray-900/20`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{ERROR:`bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50`,WARNING:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,WARN:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,INFO:`bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50`,DEBUG:`bg-gray-500/20 text-gray-400 border-gray-500/50`}[e]||`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.value,class:`flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50`},[(f(),d(`svg`,{class:o([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15`},null,-1)]],2)),c(` `+n(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Loggers `),u(`button`,{onClick:ne,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Loggers `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Levels `),u(`button`,{onClick:ie,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Levels `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10`])},n(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},n(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})])],-1),i[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,n(V.value),1),u(`button`,{onClick:G,class:`px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors`},` Try Again `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/MessageDialog-Bo2BPor8.js b/repeater/web/html/assets/MessageDialog-Cp4W1enq.js similarity index 94% rename from repeater/web/html/assets/MessageDialog-Bo2BPor8.js rename to repeater/web/html/assets/MessageDialog-Cp4W1enq.js index d55c47a..97d2362 100644 --- a/repeater/web/html/assets/MessageDialog-Bo2BPor8.js +++ b/repeater/web/html/assets/MessageDialog-Cp4W1enq.js @@ -1 +1 @@ -import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-C4RaXVWf.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file +import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-DzwrDOOB.js b/repeater/web/html/assets/Neighbors-BamkiPcU.js similarity index 99% rename from repeater/web/html/assets/Neighbors-DzwrDOOB.js rename to repeater/web/html/assets/Neighbors-BamkiPcU.js index d437576..ce6e8b5 100644 --- a/repeater/web/html/assets/Neighbors-DzwrDOOB.js +++ b/repeater/web/html/assets/Neighbors-BamkiPcU.js @@ -1,4 +1,4 @@ -import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,E as r,S as i,b as a,c as o,dt as s,f as c,g as l,i as u,j as d,k as f,l as p,lt as m,m as h,o as g,p as _,r as v,s as y,u as b,ut as x,w as S,z as C}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as w}from"./api-iZry0Vmp.js";import{t as T}from"./system-CQxRKLgj.js";import{t as E}from"./_plugin-vue_export-helper-V-yks4gF.js";import{d as D,f as O,m as k,s as A,u as j}from"./index-C4RaXVWf.js";import{t as M}from"./leaflet-src-BtX0-WJ4.js";/* empty css */import{n as N,t as P}from"./preferences-N3Pls1rF.js";import{t as F}from"./useSignalQuality-C2S7bHL9.js";var I={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6`},L={class:`flex items-center gap-3`},R={class:`flex-1 min-w-0`},z={class:`text-content-primary dark:text-content-primary font-medium truncate`},B={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},V={key:0,class:`text-white/50 text-xs`},H={key:1,class:`text-white/50 text-xs`},U=l({__name:`DeleteNeighborModal`,props:{show:{type:Boolean},neighbor:{}},emits:[`close`,`delete`],setup(e,{emit:t}){let n=e,r=t,i=()=>{n.neighbor&&(r(`delete`,n.neighbor.id),a())},a=()=>{r(`close`)},o=e=>{e.target===e.currentTarget&&a()};return(t,n)=>e.show&&e.neighbor?(S(),b(`div`,{key:0,onClick:o,class:`fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[y(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:n[0]||=k(()=>{},[`stop`])},[y(`div`,{class:`flex items-center gap-3 mb-6`},[n[2]||=y(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z`})],-1),n[3]||=y(`div`,null,[y(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Delete Neighbor `),y(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mt-1`},` Are you sure you want to delete this neighbor? `)],-1),y(`button`,{onClick:a,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[1]||=[y(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),y(`div`,I,[y(`div`,L,[y(`div`,R,[y(`div`,z,s(e.neighbor?.node_name||e.neighbor?.long_name||e.neighbor?.short_name||`Unknown`),1),y(`div`,B,` ID: `+s(e.neighbor?.node_num_hex||e.neighbor?.node_num||e.neighbor?.id||`N/A`),1),e.neighbor?.contact_type?(S(),b(`div`,V,s(e.neighbor.contact_type),1)):p(``,!0),e.neighbor?.hw_model?(S(),b(`div`,H,s(e.neighbor.hw_model),1)):p(``,!0)])])]),n[4]||=y(`div`,{class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},[y(`div`,{class:`flex items-center gap-2 text-accent-red text-sm`},[y(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})]),y(`span`,null,`This action cannot be undone`)])],-1),y(`div`,{class:`flex gap-3`},[y(`button`,{onClick:a,class:`flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors`},` Cancel `),y(`button`,{onClick:i,class:`flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium`},` Delete `)])])])):p(``,!0)}}),W={class:`bg-gradient-to-r from-primary/20 to-accent-cyan/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4`},G={class:`flex items-center justify-between`},K={class:`flex items-center gap-3`},ee={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},te={class:`p-6`},q={key:0,class:`text-center py-8`},ne={key:1,class:`text-center py-8`},re={class:`text-content-secondary dark:text-content-muted text-sm`},ie={key:2,class:`space-y-4`},ae={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},oe={class:`flex items-center justify-between mb-2`},se={class:`flex items-baseline gap-2`},ce={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},le={class:`grid grid-cols-2 gap-3`},ue={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},de={class:`flex items-center gap-2 mb-2`},fe={class:`flex gap-0.5`},pe={class:`flex items-baseline gap-1`},me={class:`text-xl font-bold text-content-primary dark:text-content-primary`},he={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},ge={class:`flex items-baseline gap-1`},_e={class:`text-xl font-bold text-content-primary dark:text-content-primary`},ve={key:0,class:`flex items-start gap-3 bg-amber-500/10 border border-amber-500/30 rounded-[12px] p-3`},ye={class:`text-xs leading-relaxed`},be={class:`font-semibold text-amber-600 dark:text-amber-400 mb-0.5`},xe={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},Se={class:`relative`},Ce={class:`flex items-center gap-2 overflow-x-auto pb-2`},we={key:0,class:`relative flex items-center`},Te={key:0,class:`absolute left-1/2 -translate-x-1/2 animate-pulse`},Ee={class:`text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between`},De={key:0,class:`text-cyan-500 dark:text-primary animate-pulse`},Oe={class:`flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2`},ke=E(l({__name:`PingResultModal`,props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:[`close`],setup(e,{emit:n}){let i=e,a=n,c=T(),{getSignalQuality:l}=F(),d=C(0),_=C(!1),x=g(()=>{let e=c.stats?.config?.radio?.spreading_factor??7,t=c.stats?.config?.radio?.bandwidth??125,n=c.stats?.config?.radio?.coding_rate??5;return 2**e/t*(8+4.25*(n-4)+20)}),w=g(()=>{if(!i.result)return{color:`text-gray-400`,label:`Unknown`};let e=i.result.rtt_ms,t=x.value,n=i.result.path.length,r=2*t*n+500*n;return eManage room server identities and messages
No messages yet
Be the first to start the conversation
Manage room server identities and messages
No messages yet
Be the first to start the conversation
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Packet Rate (RX/TX PER HOUR)
Packet Rate (RX/TX PER HOUR)
In Progress
In Progress
Manage companion identities (TCP frame server)
Manage companion identities (TCP frame server)
Manage companion identities (TCP frame server)
Permit flooding
Block flooding
Permit flooding
Block flooding
API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.
Web frontend changes will take effect after restarting the pymc-repeater service.
How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.
Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)
Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)
Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.
Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.
This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.
Each sender has a token bucket. Every forwarded advert uses one token.
If a sender keeps hitting the limit, it is temporarily blocked.
Adaptive mode adjusts limits based on recent advert activity.
This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.
Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.
Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.
Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.
Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.
Activity (Last 24 Hours)
Activity (Last 24 Hours)
Sign in to access your dashboard
No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Logs-sxcWuUjs.js b/repeater/web/html/assets/Logs-sxcWuUjs.js deleted file mode 100644 index be7d21f..0000000 --- a/repeater/web/html/assets/Logs-sxcWuUjs.js +++ /dev/null @@ -1,5 +0,0 @@ -<<<<<<<< HEAD:repeater/web/html/assets/Logs-CVZ1ZqH8.js -import{E as e,S as t,dt as n,f as r,g as i,l as a,lt as o,o as s,p as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-DjLVJkR1.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=s(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=s(()=>Array.from(R.value).sort()),J=s(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({ERROR:`text-red-600 dark:text-red-400 bg-red-900/20`,WARNING:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,WARN:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,INFO:`text-blue-600 dark:text-blue-400 bg-blue-900/20`,DEBUG:`text-gray-400 bg-gray-900/20`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{ERROR:`bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50`,WARNING:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,WARN:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,INFO:`bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50`,DEBUG:`bg-gray-500/20 text-gray-400 border-gray-500/50`}[e]||`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.value,class:`flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50`},[(f(),d(`svg`,{class:o([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15`},null,-1)]],2)),c(` `+n(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Loggers `),u(`button`,{onClick:ne,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Loggers `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Levels `),u(`button`,{onClick:ie,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Levels `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10`])},n(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},n(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})])],-1),i[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,n(V.value),1),u(`button`,{onClick:G,class:`px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors`},` Try Again `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; -======== -import{E as e,S as t,dt as n,f as r,g as i,l as a,lt as o,o as s,p as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-DegLD39Y.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=s(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=s(()=>Array.from(R.value).sort()),J=s(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({ERROR:`text-red-600 dark:text-red-400 bg-red-900/20`,WARNING:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,WARN:`text-yellow-600 dark:text-yellow-400 bg-yellow-900/20`,INFO:`text-blue-600 dark:text-blue-400 bg-blue-900/20`,DEBUG:`text-gray-400 bg-gray-900/20`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{ERROR:`bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50`,WARNING:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,WARN:`bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50`,INFO:`bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50`,DEBUG:`bg-gray-500/20 text-gray-400 border-gray-500/50`}[e]||`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.value,class:`flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50`},[(f(),d(`svg`,{class:o([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15`},null,-1)]],2)),c(` `+n(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Loggers `),u(`button`,{onClick:ne,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Loggers `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,class:`px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors`},` All Levels `),u(`button`,{onClick:ie,class:`px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors`},` Clear Levels `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`bg-primary/20 text-primary border-primary/50`:`bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10`])},n(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:o([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},n(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})])],-1),i[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,n(V.value),1),u(`button`,{onClick:G,class:`px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors`},` Try Again `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`No logs match the current filter criteria.
`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+n(X(e.timestamp))+`] `,1),u(`span`,N,n(U(e.message)),1),u(`span`,{class:o([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},n(e.level),3),u(`span`,P,n(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; ->>>>>>>> origin/dev:repeater/web/html/assets/Logs-sxcWuUjs.js diff --git a/repeater/web/html/assets/MessageDialog-B-qWtO0z.js b/repeater/web/html/assets/MessageDialog-B-qWtO0z.js deleted file mode 100644 index 3391318..0000000 --- a/repeater/web/html/assets/MessageDialog-B-qWtO0z.js +++ /dev/null @@ -1,5 +0,0 @@ -<<<<<<<< HEAD:repeater/web/html/assets/MessageDialog-Cp4W1enq.js -import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; -======== -import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{h as s}from"./index-CmQtu2qv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; ->>>>>>>> origin/dev:repeater/web/html/assets/MessageDialog-B-qWtO0z.js diff --git a/repeater/web/html/assets/MessageDialog-Cp4W1enq.js b/repeater/web/html/assets/MessageDialog-Cp4W1enq.js new file mode 100644 index 0000000..97d2362 --- /dev/null +++ b/repeater/web/html/assets/MessageDialog-Cp4W1enq.js @@ -0,0 +1 @@ +import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-BAwKrJdF.js b/repeater/web/html/assets/Neighbors-BamkiPcU.js similarity index 69% rename from repeater/web/html/assets/Neighbors-BAwKrJdF.js rename to repeater/web/html/assets/Neighbors-BamkiPcU.js index f000218..ce6e8b5 100644 --- a/repeater/web/html/assets/Neighbors-BAwKrJdF.js +++ b/repeater/web/html/assets/Neighbors-BamkiPcU.js @@ -1,8 +1,4 @@ -<<<<<<<< HEAD:repeater/web/html/assets/Neighbors-BamkiPcU.js import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,E as r,S as i,b as a,c as o,dt as s,f as c,g as l,i as u,j as d,k as f,l as p,lt as m,m as h,o as g,p as _,r as v,s as y,u as b,ut as x,w as S,z as C}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as w}from"./api-DjLVJkR1.js";import{t as T}from"./system-CsY7_jKa.js";import{t as E}from"./_plugin-vue_export-helper-V-yks4gF.js";import{d as D,f as O,m as k,s as A,u as j}from"./index-cutq4vvY.js";import{t as M}from"./leaflet-src-BtX0-WJ4.js";/* empty css */import{n as N,t as P}from"./preferences-N3Pls1rF.js";import{t as F}from"./useSignalQuality-DlXA7j0p.js";var I={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6`},L={class:`flex items-center gap-3`},R={class:`flex-1 min-w-0`},z={class:`text-content-primary dark:text-content-primary font-medium truncate`},B={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},V={key:0,class:`text-white/50 text-xs`},H={key:1,class:`text-white/50 text-xs`},U=l({__name:`DeleteNeighborModal`,props:{show:{type:Boolean},neighbor:{}},emits:[`close`,`delete`],setup(e,{emit:t}){let n=e,r=t,i=()=>{n.neighbor&&(r(`delete`,n.neighbor.id),a())},a=()=>{r(`close`)},o=e=>{e.target===e.currentTarget&&a()};return(t,n)=>e.show&&e.neighbor?(S(),b(`div`,{key:0,onClick:o,class:`fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[y(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:n[0]||=k(()=>{},[`stop`])},[y(`div`,{class:`flex items-center gap-3 mb-6`},[n[2]||=y(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z`})],-1),n[3]||=y(`div`,null,[y(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Delete Neighbor `),y(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mt-1`},` Are you sure you want to delete this neighbor? `)],-1),y(`button`,{onClick:a,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[1]||=[y(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),y(`div`,I,[y(`div`,L,[y(`div`,R,[y(`div`,z,s(e.neighbor?.node_name||e.neighbor?.long_name||e.neighbor?.short_name||`Unknown`),1),y(`div`,B,` ID: `+s(e.neighbor?.node_num_hex||e.neighbor?.node_num||e.neighbor?.id||`N/A`),1),e.neighbor?.contact_type?(S(),b(`div`,V,s(e.neighbor.contact_type),1)):p(``,!0),e.neighbor?.hw_model?(S(),b(`div`,H,s(e.neighbor.hw_model),1)):p(``,!0)])])]),n[4]||=y(`div`,{class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},[y(`div`,{class:`flex items-center gap-2 text-accent-red text-sm`},[y(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[y(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})]),y(`span`,null,`This action cannot be undone`)])],-1),y(`div`,{class:`flex gap-3`},[y(`button`,{onClick:a,class:`flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors`},` Cancel `),y(`button`,{onClick:i,class:`flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium`},` Delete `)])])])):p(``,!0)}}),W={class:`bg-gradient-to-r from-primary/20 to-accent-cyan/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4`},G={class:`flex items-center justify-between`},K={class:`flex items-center gap-3`},ee={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},te={class:`p-6`},q={key:0,class:`text-center py-8`},ne={key:1,class:`text-center py-8`},re={class:`text-content-secondary dark:text-content-muted text-sm`},ie={key:2,class:`space-y-4`},ae={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},oe={class:`flex items-center justify-between mb-2`},se={class:`flex items-baseline gap-2`},ce={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},le={class:`grid grid-cols-2 gap-3`},ue={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},de={class:`flex items-center gap-2 mb-2`},fe={class:`flex gap-0.5`},pe={class:`flex items-baseline gap-1`},me={class:`text-xl font-bold text-content-primary dark:text-content-primary`},he={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},ge={class:`flex items-baseline gap-1`},_e={class:`text-xl font-bold text-content-primary dark:text-content-primary`},ve={key:0,class:`flex items-start gap-3 bg-amber-500/10 border border-amber-500/30 rounded-[12px] p-3`},ye={class:`text-xs leading-relaxed`},be={class:`font-semibold text-amber-600 dark:text-amber-400 mb-0.5`},xe={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},Se={class:`relative`},Ce={class:`flex items-center gap-2 overflow-x-auto pb-2`},we={key:0,class:`relative flex items-center`},Te={key:0,class:`absolute left-1/2 -translate-x-1/2 animate-pulse`},Ee={class:`text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between`},De={key:0,class:`text-cyan-500 dark:text-primary animate-pulse`},Oe={class:`flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2`},ke=E(l({__name:`PingResultModal`,props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:[`close`],setup(e,{emit:n}){let i=e,a=n,c=T(),{getSignalQuality:l}=F(),d=C(0),_=C(!1),x=g(()=>{let e=c.stats?.config?.radio?.spreading_factor??7,t=c.stats?.config?.radio?.bandwidth??125,n=c.stats?.config?.radio?.coding_rate??5;return 2**e/t*(8+4.25*(n-4)+20)}),w=g(()=>{if(!i.result)return{color:`text-gray-400`,label:`Unknown`};let e=i.result.rtt_ms,t=x.value,n=i.result.path.length,r=2*t*n+500*n;return eNo valid coordinates available
Configure base station location to view map
No mesh neighbors have been discovered in your area yet.
`,3),y(`button`,{onClick:oe,class:`mt-4 px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors`},` Refresh `)])):re.value.length===0&&q.value?(S(),b(`div`,hr,[t[21]||=c(`Try adjusting your filter criteria to see more results.
`,3),y(`button`,{onClick:te,class:`px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors`},` Clear Filters `)])):p(``,!0)],64)),h(U,{show:M.value,neighbor:W.value,onClose:he,onDelete:ge},null,8,[`show`,`neighbor`]),h(ke,{show:F.value,"node-name":z.value,result:L.value,error:R.value,loading:I.value,onClose:de},null,8,[`show`,`node-name`,`result`,`error`,`loading`]),h(gt,{"is-open":V.value,neighbor:H.value,"base-latitude":G.value,"base-longitude":K.value,onClose:me},null,8,[`is-open`,`neighbor`,`base-latitude`,`base-longitude`])]))}});export{gr as default}; \ No newline at end of file + `);h.value.set(o.pubkey,d);let f=d.getElement();f&&(f.style.opacity=`0`,f.style.transition=`opacity 0.5s ease-out`),s(o,e,t,i,r),setTimeout(()=>{f&&(f.style.opacity=`1`)},r+1e3),r+=100}})};if(w.value&&o.adverts.length>0)try{R(L(o.adverts));let n=Math.min(14,m.getZoom());m.setZoom(n),setTimeout(()=>{try{c()}catch(n){console.warn(`Error updating clusters:`,n),l(e,t)}},100),m.on(`moveend`,()=>{try{c()}catch(e){console.warn(`Error updating clusters on move:`,e)}}),m.on(`zoomend`,()=>{try{c()}catch(e){console.warn(`Error updating clusters on zoom:`,e)}})}catch(n){console.warn(`Error initializing clustering:`,n),l(e,t)}else l(e,t);setTimeout(()=>{m&&m.invalidateSize()},1e3)}catch(e){console.error(`Error initializing map:`,e)}};return t({highlightNode:e=>{let t=h.value.get(e);if(t){let e=t.getElement();if(e){let t=e.querySelector(`div`);t&&t.classList.add(`marker-highlight`)}}},unhighlightNode:e=>{let t=h.value.get(e);if(t){let e=t.getElement();if(e){let t=e.querySelector(`div`);t&&t.classList.remove(`marker-highlight`)}}},initializeOpenStreetMap:z}),f(()=>o.adverts,()=>{m&&k.value&&setTimeout(()=>{z()},100)},{immediate:!1}),i(()=>{O.observe(document.documentElement,{attributes:!0,attributeFilter:[`class`]}),k.value&&o.adverts.length>0&&setTimeout(()=>{z()},300)}),n(()=>{O.disconnect(),F()}),(t,n)=>(S(),b(`div`,Ft,[k.value?(S(),b(`div`,{key:1,ref_key:`mapContainer`,ref:d,class:`leaflet-map-container h-96 w-full glass-card backdrop-blur border border-black/6 dark:border-white/10 rounded-[12px] overflow-hidden shadow-sm dark:shadow-none`,style:{"min-height":`384px`,position:`relative`}},null,512)):(S(),b(`div`,It,[...n[0]||=[c(`No valid coordinates available
Configure base station location to view map
No mesh neighbors have been discovered in your area yet.
`,3),y(`button`,{onClick:oe,class:`mt-4 px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors`},` Refresh `)])):re.value.length===0&&q.value?(S(),b(`div`,hr,[t[21]||=c(`Try adjusting your filter criteria to see more results.
`,3),y(`button`,{onClick:te,class:`px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors`},` Clear Filters `)])):p(``,!0)],64)),h(U,{show:M.value,neighbor:W.value,onClose:he,onDelete:ge},null,8,[`show`,`neighbor`]),h(ke,{show:F.value,"node-name":z.value,result:L.value,error:R.value,loading:I.value,onClose:de},null,8,[`show`,`node-name`,`result`,`error`,`loading`]),h(gt,{"is-open":V.value,neighbor:H.value,"base-latitude":G.value,"base-longitude":K.value,onClose:me},null,8,[`is-open`,`neighbor`,`base-latitude`,`base-longitude`])]))}});export{gr as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/RFNoiseFloor-diGiNukU.js b/repeater/web/html/assets/RFNoiseFloor-diGiNukU.js deleted file mode 100644 index a874fa7..0000000 --- a/repeater/web/html/assets/RFNoiseFloor-diGiNukU.js +++ /dev/null @@ -1 +0,0 @@ -import{n as e}from"./index-CmQtu2qv.js";export{e as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/RoomServers-DbCgmJ6x.js b/repeater/web/html/assets/RoomServers-DbCgmJ6x.js deleted file mode 100644 index 9ea94ac..0000000 --- a/repeater/web/html/assets/RoomServers-DbCgmJ6x.js +++ /dev/null @@ -1,5 +0,0 @@ -<<<<<<<< HEAD:repeater/web/html/assets/RoomServers-i32N0iwv.js -import{E as e,S as t,dt as n,f as r,g as i,j as a,k as ee,l as o,lt as s,m as c,p as l,r as u,s as d,u as f,w as p,z as m}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t as h}from"./api-DjLVJkR1.js";import{d as g,m as _,p as v}from"./index-cutq4vvY.js";import{t as te}from"./ConfirmDialog-h2bJ_WKJ.js";import{t as ne}from"./MessageDialog-Cp4W1enq.js";import{n as re,t as ie}from"./preferences-N3Pls1rF.js";var ae={class:`p-6 space-y-6`},oe={class:`relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10`},se={class:`relative flex items-center justify-between`},ce={key:0,class:`grid grid-cols-1 md:grid-cols-3 gap-4`},le={class:`group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer`},ue={class:`relative flex items-center justify-between`},de={class:`text-3xl font-bold text-content-primary dark:text-content-primary mb-1`},fe={class:`group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer`},pe={class:`relative flex items-center justify-between`},me={class:`text-3xl font-bold text-primary mb-1`},he={class:`group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer`},ge={class:`relative flex items-center justify-between`},_e={key:0,class:`w-6 h-6 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ve={key:1,class:`w-6 h-6 text-accent-yellow`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ye={class:`glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},be={key:0,class:`flex items-center justify-center py-12`},xe={key:1,class:`flex items-center justify-center py-12`},Se={class:`text-center`},Ce={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},we={key:2,class:`space-y-4`},Te={class:`relative flex items-start justify-between`},Ee={class:`flex-1`},De={class:`flex items-center gap-3 mb-4`},Oe={class:`relative`},ke={key:0,class:`absolute inset-0 bg-accent-green/50 rounded-full animate-ping`},Ae={class:`text-xl font-bold text-content-primary dark:text-content-primary group-hover:text-primary transition-colors`},je={key:0,class:`text-content-muted dark:text-content-muted text-sm`},Me={class:`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3`},Ne={class:`text-content-primary dark:text-content-primary/90 ml-2`},Pe={class:`flex items-center gap-2`},y={key:0,class:`text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs`},Fe={key:1,class:`text-content-muted dark:text-content-muted ml-2 text-xs`},Ie=[`onClick`],Le={class:`text-content-primary dark:text-content-primary/90 ml-2`},Re={key:0},ze={class:`text-content-primary dark:text-content-primary/90 ml-2`},Be={key:0,class:`text-accent-green`},Ve={key:1,class:`text-content-muted dark:text-content-muted`},He={key:2,class:`text-primary`},Ue={key:0,class:`text-xs text-content-muted dark:text-content-muted font-mono`},We={class:`ml-4 flex flex-wrap gap-2`},Ge=[`onClick`,`disabled`,`title`],Ke=[`onClick`,`disabled`,`title`],qe=[`onClick`],Je=[`onClick`],Ye={key:3,class:`text-center py-12 text-content-secondary dark:text-content-muted`},Xe={key:1,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`},Ze={class:`bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto`},Qe={class:`space-y-4`},$e={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},et={key:0},tt={key:1,class:`text-content-secondary dark:text-content-muted text-sm`},nt={class:`grid grid-cols-2 gap-4`},rt={class:`grid grid-cols-2 gap-4`},it={key:2,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`},at={class:`bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto`},ot={class:`space-y-4`},st=[`value`],ct={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},lt={key:0},ut={key:1,class:`text-content-secondary dark:text-content-muted text-sm`},dt={class:`grid grid-cols-2 gap-4`},ft={class:`grid grid-cols-2 gap-4`},pt={key:0,class:`fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4`},mt={class:`bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 max-w-4xl w-full h-[85vh] flex flex-col shadow-2xl`},ht={class:`relative overflow-hidden rounded-[15px] mb-6 p-5 bg-white/50 dark:bg-white/5 border border-stroke-subtle dark:border-white/10`},gt={class:`relative flex items-center justify-between`},_t={class:`flex items-center gap-4`},vt={class:`text-content-secondary dark:text-content-muted text-sm flex items-center gap-2`},yt={class:`text-primary font-semibold`},bt={class:`flex items-center gap-2`},xt={class:`bg-primary/30 px-1.5 py-0.5 rounded-full text-[10px]`},St={class:`flex-1 overflow-y-auto mb-4 space-y-3`},Ct={key:0,class:`flex items-center justify-center py-12`},wt={key:1,class:`flex items-center justify-center py-12`},Tt={class:`text-center`},Et={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},Dt={key:2,class:`space-y-3`},Ot={class:`relative flex items-start justify-between gap-3`},kt={class:`flex-1 min-w-0`},At={class:`flex items-center gap-2 mb-3`},jt={class:`flex items-center gap-2 flex-wrap`},Mt={key:0,class:`text-primary text-sm font-bold`},Nt={key:1,class:`text-primary/80 text-xs font-mono bg-primary/10 px-2 py-1 rounded-md border border-primary/20`},Pt={key:2,class:`text-content-muted dark:text-content-muted text-xs`},Ft={class:`text-content-secondary dark:text-content-muted text-xs flex items-center gap-1`},It={key:3,class:`text-content-muted dark:text-content-muted/50 text-[10px] font-mono bg-background-mute dark:bg-white/5 px-1.5 py-0.5 rounded`},Lt={class:`text-content-primary dark:text-content-primary/90 text-sm leading-relaxed break-words whitespace-pre-wrap bg-gray-50 dark:bg-white/5 p-3 rounded-[10px] border border-stroke-subtle dark:border-white/5`},Rt=[`onClick`],zt={key:0,class:`text-center pt-4`},Bt={key:1,class:`text-center pt-4`},Vt={key:3,class:`flex items-center justify-center h-full`},Ht={class:`relative overflow-hidden rounded-[15px] border-t border-stroke-subtle dark:border-white/20 pt-4 mt-4`},Ut={class:`relative space-y-3`},Wt={class:`flex gap-3`},Gt={class:`flex-1 relative`},Kt=[`onKeydown`],qt=[`disabled`],Jt={key:1,class:`fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-[60] p-4`},Yt={class:`bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-3xl w-full max-h-[80vh] flex flex-col`},Xt={class:`flex items-center justify-between mb-4 pb-4 border-b border-stroke-subtle dark:border-white/10`},Zt={class:`text-content-secondary dark:text-content-primary/70 text-sm mt-1`},Qt={class:`text-primary`},b={class:`flex-1 overflow-y-auto space-y-3`},$t={key:0,class:`text-center py-12`},en={class:`space-y-2`},tn={class:`flex items-center justify-between`},nn={class:`flex items-center gap-2`},rn={class:`text-content-primary dark:text-content-primary font-semibold`},an={class:`flex items-center gap-2`},on={class:`text-content-secondary dark:text-content-muted text-xs`},sn=[`onClick`],cn={class:`space-y-1 text-xs`},ln={class:`flex items-center gap-2`},un={class:`text-primary font-mono bg-primary/10 px-2 py-0.5 rounded`},dn={class:`flex items-center gap-2`},fn={class:`text-primary font-mono bg-primary/10 px-2 py-0.5 rounded text-[10px] break-all`},pn={class:`flex items-center justify-between text-xs text-content-secondary dark:text-content-muted`},mn={class:`flex items-center gap-4`},hn={key:0},gn={key:1},_n={key:0},x=i({name:`RoomServersView`,__name:`RoomServers`,setup(i){let x=m(!1),S=m(null),C=m(null),w=m(!1),T=m(!1),E=m(null),D=m(!1),O=m(!1),k=m(new Set),A=m(!1),j=m(``),M=m(!1),N=m({message:``,variant:`success`}),P=m(!1),F=m(``),I=m(``),L=m([]),R=m(!1),z=m(null),B=m(``),V=m(ie(`roomServers_messagesLimit`,50)),H=m(0),U=m(!0);ee(V,e=>re(`roomServers_messagesLimit`,e));let W=m([]),G=m(!1),K=m({name:``,identity_key:``,type:`room_server`,settings:{node_name:``,latitude:0,longitude:0,admin_password:``,guest_password:``}});t(async()=>{await q()});async function q(){x.value=!0,S.value=null;try{let e=await h.getIdentities();e.success?C.value=e.data:S.value=e.error||`Failed to load identities`}catch(e){S.value=e instanceof Error?e.message:`Failed to load identities`}finally{x.value=!1}}async function vn(){try{let e=await h.createIdentity(K.value);e.success?(w.value=!1,Y(),await q(),J(e.message||`Identity created successfully!`,`success`)):J(`Failed to create identity: ${e.error}`,`error`)}catch(e){J(`Error creating identity: ${e}`,`error`)}}async function yn(){try{let e=await h.updateIdentity(E.value);e.success?(T.value=!1,E.value=null,await q(),J(e.message||`Identity updated successfully!`,`success`)):J(`Failed to update identity: ${e.error}`,`error`)}catch(e){J(`Error updating identity: ${e}`,`error`)}}function bn(e){j.value=e,A.value=!0}async function xn(){let e=j.value;A.value=!1;try{let t=await h.deleteIdentity(e);t.success?(await q(),J(t.message||`Identity deleted successfully!`,`success`)):J(`Failed to delete identity: ${t.error}`,`error`)}catch(e){J(`Error deleting identity: ${e}`,`error`)}finally{j.value=``}}function J(e,t){N.value={message:e,variant:t},M.value=!0}async function Sn(e){try{let t=await h.sendRoomServerAdvert(e);t.success?J(t.message||`Advert sent for '${e}'!`,`success`):J(`Failed to send advert: ${t.error}`,`error`)}catch(e){J(`Error sending advert: ${e}`,`error`)}}function Cn(e){E.value=JSON.parse(JSON.stringify(e)),E.value.settings||(E.value.settings={}),E.value.settings.admin_password||(E.value.settings.admin_password=``),E.value.settings.guest_password||(E.value.settings.guest_password=``),O.value=!1,T.value=!0}function Y(){K.value={name:``,identity_key:``,type:`room_server`,settings:{node_name:``,latitude:0,longitude:0,admin_password:``,guest_password:``}},D.value=!1}function X(){w.value=!1,T.value=!1,E.value=null,D.value=!1,O.value=!1,Y()}function wn(e){k.value.has(e)?k.value.delete(e):k.value.add(e)}async function Tn(e){F.value=e,P.value=!0,H.value=0,U.value=!0,I.value=C.value?.configured.find(t=>t.name===e)?.hash||``,await Z(),await Q(!0)}async function Z(){try{let e=await h.getACLClients({identity_hash:I.value,identity_name:F.value});e.success&&e.data&&(W.value=e.data.clients||[])}catch(e){console.error(`Failed to fetch ACL clients:`,e)}}async function Q(e=!1){e&&(H.value=0,L.value=[]),R.value=!0,z.value=null;try{let t=await h.getRoomMessages({room_name:F.value,limit:V.value,offset:H.value});if(t.success&&t.data){let n=t.data.messages||[];e?L.value=n:L.value=[...L.value,...n],U.value=n.length===V.value}else z.value=t.error||`Failed to load messages`}catch(e){z.value=e instanceof Error?e.message:`Failed to load messages`}finally{R.value=!1}}async function En(){H.value+=V.value,await Q(!1)}async function $(){if(B.value.trim())try{let e=await h.postRoomMessage({room_name:F.value,message:B.value,author_pubkey:`server`});e.success?(B.value=``,await Q(!0)):J(`Failed to send message: ${e.error}`,`error`)}catch(e){J(`Error sending message: ${e}`,`error`)}}async function Dn(e){if(confirm(`Are you sure you want to delete this message?`))try{let t=await h.deleteRoomMessage({room_name:F.value,message_id:e});t.success?(await Q(!0),J(`Message deleted successfully`,`success`)):J(`Failed to delete message: ${t.error}`,`error`)}catch(e){J(`Error deleting message: ${e}`,`error`)}}function On(){P.value=!1,F.value=``,I.value=``,L.value=[],B.value=``,z.value=null,W.value=[]}function kn(e){return e?new Date(e*1e3).toLocaleString():`Unknown`}async function An(e,t){if(confirm(`Are you sure you want to remove this client from the ACL?`))try{let n=await h.removeACLClient({public_key:e,identity_hash:t});n.success?(await Z(),J(`Client removed successfully`,`success`)):J(`Failed to remove client: ${n.error}`,`error`)}catch(e){J(`Error removing client: ${e}`,`error`)}}return(t,i)=>(p(),f(u,null,[d(`div`,ae,[d(`div`,oe,[i[26]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50`},null,-1),i[27]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse`},null,-1),d(`div`,se,[i[25]||=r(`Manage room server identities and messages
No messages yet
Be the first to start the conversation
Manage room server identities and messages
No messages yet
Be the first to start the conversation
Manage room server identities and messages
No messages yet
Be the first to start the conversation
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Welcome to your pyMC Repeater! Let's get you set up in just a few steps.
You'll configure:
Packet Rate (RX/TX PER HOUR)
Packet Rate (RX/TX PER HOUR)
Packet Rate (RX/TX PER HOUR)