23 Commits

Author SHA1 Message Date
Rightup 43a112ce7c feat: add HTTP server configuration and control commands 2026-07-07 20:38:22 +01:00
Lloyd e52637535d Merge pull request #337 from Littleaton/radio-fix-comma
Radio fix comma
2026-07-06 16:12:40 +01:00
Littleaton 0e26ed3cb3 fied extra comma 2026-07-06 09:06:55 -06:00
Rightup 1a2bf9e4fd feat:update ui with repeater discovery 2026-07-04 23:48:32 +01:00
Rightup 8b3894d2d5 fix: remove duplicate import statements in test files 2026-07-04 23:41:51 +01:00
Rightup daec7f0ebc feat: implement neighbor discovery session management and API endpoints 2026-07-04 23:41:33 +01:00
Lloyd a5c85dd9d9 Merge pull request #326 from Littleaton/dev
updated working zebra duo config radio 0 / 1 / Rak6421 pi hat
2026-07-04 22:27:33 +01:00
Lloyd ff43d85b85 Merge pull request #333 from mdspann/issue305
Add timezone to MQTT packet data
2026-07-04 22:19:49 +01:00
Rightup 229d71459f fix: improve upsert_client_sync to prevent over write. 2026-07-04 18:41:55 +01:00
mike 047ad19fd3 Add timezone to MQTT packet data 2026-07-03 15:07:26 -07:00
Rightup 05449987c1 feat: update packet detail screen to lookup repeater names 2026-07-03 21:21:20 +01:00
Rightup ae6ac1bd05 update packet table desktop/mobile with dupe groups 2026-07-02 21:47:51 +01:00
Rightup ae68112377 - Refactored ACL authentication flow with shared helpers for replay detection and session updates.
- Changed blank-password login behavior to create/manage guest sessions (when read-only is enabled), including replay protection and session timestamp tracking.
- Preserved and centralized `sync_since` handling during successful auth/session updates.
- Updated login handling to return explicit `(success, permissions)` and reset room-server sync guard state in SQLite on successful room-server login.
- Updated identity/banner output in mesh CLI from `pyMC_*` to `openHop_*` and adjusted default version fallback.
- Extended path handling to support encoded path-length semantics via `PathUtils`, with legacy fallback support for older/malformed packets.
- Added bundled ACK extraction from PATH payload extras and callback-based ACK propagation.
- Hardened room-server push logic: max-failure early skip, corrected expected ACK CRC calculation, path encoding compatibility, persisted sync/last-activity fields, and passed expected CRC + timeout into packet injection.
- Enhanced packet router ACK flow with dispatcher ACK registration helper, support for multipart ACK wrapper handling, configurable ACK wait timeout/CRC in injection, and ensured PATH helper processing always runs.
- Updated daemon wiring to pass ACK callback into `PathHelper`,
- make dispatcher dedupe configurable (prevent dbl deduping), and only register duplicate logging hook when dedupe is enabled.
- Expanded tests to cover guest blank-password replay tracking, updated room-server injector ACK expectations, and new duplicate-logging-hook gating behavior.
2026-07-02 16:32:32 +01:00
Rightup 34747d2610 feat: add packet retrieval by ID endpoint and corresponding database methods 2026-07-02 16:18:54 +01:00
Littleaton b84479de0c fixed rak6421 config. 2026-06-30 14:11:55 -06:00
Littleaton baa89e836c updated working zebra duo config radio 0 / 1 2026-06-30 13:37:38 -06:00
Lloyd 823308aa3f fix: update drop reason handling in PacketRouter and RepeaterHandler 2026-06-29 15:48:19 +01:00
Lloyd a964062bf6 Merge branch 'pr-323' into dev 2026-06-29 15:22:16 +01:00
Lloyd 9be74efeb0 Merge remote-tracking branch 'origin/feat-std-key-formats' into dev 2026-06-29 15:21:05 +01:00
Lloyd 38efb05eeb fix: expose effective drop reason to PacketRouter 2026-06-29 15:18:39 +01:00
Rightup a53df3d76f feat: implement MeshCore-compatible key generation for all identity keys with guards for 0x00 and 0xff prefixs 2026-06-28 22:57:14 +01:00
theshaun 172c8cc016 Initialise MQTT handler only when at least one broker is configured to save some lil cpu and logging 2026-06-28 18:01:49 +10:00
Lloyd 60357f5808 Merge pull request #320 from openhop-dev/dev 2026-06-26 23:07:51 +01:00
119 changed files with 1898 additions and 263 deletions
+22
View File
@@ -611,6 +611,28 @@ logging:
# Log format # Log format
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# HTTP server configuration (web UI + API)
http:
# Enable/disable the embedded HTTP server at runtime.
# Changes are applied live via ConfigManager (no daemon restart required).
enabled: true
# Listen address and port for the web UI/API server.
host: "0.0.0.0"
port: 8000
# CherryPy worker/thread tuning.
# thread_pool is the initial worker count (minimum enforced by server: 2).
thread_pool: 8
# thread_pool_max is the max worker count (always >= thread_pool).
thread_pool_max: 16
# Socket tuning.
# socket_timeout closes idle sockets so worker threads can be reused (min: 15 seconds).
socket_timeout: 65
# socket_queue_size controls pending TCP connection backlog (min: 10).
socket_queue_size: 100
# Web interface configuration # Web interface configuration
web: web:
# Enable Cross-Origin Resource Sharing (CORS) headers # Enable Cross-Origin Resource Sharing (CORS) headers
+2 -2
View File
@@ -29,7 +29,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [ dependencies = [
"openhop_core[hardware]", "openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@feature/publish-workflow-message-handling",
"pyyaml>=6.0.0", "pyyaml>=6.0.0",
"cherrypy>=18.0.0", "cherrypy>=18.0.0",
"paho-mqtt>=1.6.0", "paho-mqtt>=1.6.0",
@@ -45,7 +45,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
# SX1262/SPI support (Linux only; required for Raspberry Pi HATs) # SX1262/SPI support (Linux only; required for Raspberry Pi HATs)
hardware = [ hardware = [
"openhop_core[hardware]", "openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@feature/publish-workflow-message-handling",
] ]
# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool) # RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool)
rrd = [ rrd = [
+39 -19
View File
@@ -133,35 +133,39 @@
"zebra-duo-hat-r0": { "zebra-duo-hat-r0": {
"name": "ZebraHatDuo-R0-1W", "name": "ZebraHatDuo-R0-1W",
"bus_id": 0, "bus_id": 0,
"busy_pin": 23,
"cs_id": 0, "cs_id": 0,
"cs_pin": -1, "cs_pin": -1,
"reset_pin": 18, "dio3_tcxo_voltage": 1.8,
"busy_pin": 23, "en_pin": -1,
"irq_pin": 24, "irq_pin": 18,
"txen_pin": -1, "is_waveshare": false,
"reset_pin": 24,
"rxen_pin": -1, "rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1, "rxled_pin": -1,
"tx_power": 18, "txen_pin": -1,
"use_dio3_tcxo": true, "txled_pin": -1,
"use_dio2_rf": true, "use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32 "preamble_length": 32
}, },
"zebra-duo-hat-r1": { "zebra-duo-hat-r1": {
"name": "ZebraHatDuo-R1-1W", "name": "ZebraHatDuo-R1-1W",
"bus_id": 0, "bus_id": 0,
"busy_pin": 27,
"cs_id": 1, "cs_id": 1,
"cs_pin": -1, "cs_pin": -1,
"reset_pin": 17, "dio3_tcxo_voltage": 1.8,
"busy_pin": 27, "en_pin": -1,
"irq_pin": 22, "irq_pin": 22,
"txen_pin": -1, "is_waveshare": false,
"reset_pin": 17,
"rxen_pin": -1, "rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1, "rxled_pin": -1,
"tx_power": 18, "txen_pin": -1,
"use_dio3_tcxo": true, "txled_pin": -1,
"use_dio2_rf": true, "use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32 "preamble_length": 32
}, },
"femtofox-1W-SX": { "femtofox-1W-SX": {
@@ -312,9 +316,7 @@
"use_dio2_rf": true, "use_dio2_rf": true,
"use_dio3_tcxo": true, "use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8, "dio3_tcxo_voltage": 1.8,
"preamble_length": 32, "preamble_length": 32
"use_gpiod_backend": true,
"gpio_chip": 1
}, },
"rak6421-13300x-slot2": { "rak6421-13300x-slot2": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2", "name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
@@ -333,9 +335,27 @@
"use_dio2_rf": true, "use_dio2_rf": true,
"use_dio3_tcxo": true, "use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8, "dio3_tcxo_voltage": 1.8,
"preamble_length": 32, "preamble_length": 32
"use_gpiod_backend": true, },
"gpio_chip": 1 "rak6421-13300x-slot2": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
"description": "Use this radio settings preset if you have modded your pi hat to work with dual radios see https://discord.com/channels/1495203904898728149/1512614022913200159 .",
"bus_id": 0,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 27,
"busy_pin": 19,
"irq_pin": 18,
"txen_pin": -1,
"rxen_pin": -1,
"en_pins": [26, 23],
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32
}, },
"ultrapeaterzero-e22": { "ultrapeaterzero-e22": {
"name": "Zindello Industries UltraPeaterZero E22", "name": "Zindello Industries UltraPeaterZero E22",
+12 -2
View File
@@ -230,6 +230,13 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if "mesh" not in config: if "mesh" not in config:
config["mesh"] = {} config["mesh"] = {}
if "http" not in config:
config["http"] = {
"enabled": True,
"host": "0.0.0.0",
"port": 8000,
}
if "glass" not in config: if "glass" not in config:
config["glass"] = { config["glass"] = {
"enabled": False, "enabled": False,
@@ -422,8 +429,11 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
except Exception as e: except Exception as e:
logger.warning(f"Failed to load identity key: {e}") logger.warning(f"Failed to load identity key: {e}")
# Generate new random key # Generate new key via MeshCore-compatible keygen; store 32-byte private scalar.
key = os.urandom(32) from repeater.keygen import generate_meshcore_keypair
_, private_key = generate_meshcore_keypair()
key = private_key[:32]
# Save it # Save it
try: try:
+52 -2
View File
@@ -1,7 +1,8 @@
import logging import logging
import os import os
from typing import Any, Dict, List, Optional
import yaml import yaml
from typing import Optional, Dict, Any, List
logger = logging.getLogger("ConfigManager") logger = logging.getLogger("ConfigManager")
@@ -145,6 +146,52 @@ class ConfigManager:
logger.error(f"Failed to apply live radio config: {e}", exc_info=True) logger.error(f"Failed to apply live radio config: {e}", exc_info=True)
return False return False
@staticmethod
def _parse_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _apply_live_http_config(self) -> bool:
if not self.daemon:
logger.warning("Daemon not available for HTTP live update")
return False
http_server = getattr(self.daemon, "http_server", None)
if http_server is None:
# Early in daemon lifecycle, there is nothing to control yet.
logger.info("HTTP server not initialized yet; skipping live HTTP update")
return True
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
enabled = self._parse_bool(http_cfg.get("enabled", True), default=True)
host = str(http_cfg.get("host", "0.0.0.0") or "0.0.0.0")
try:
port = int(http_cfg.get("port", 8000))
except (TypeError, ValueError):
logger.warning("Invalid http.port=%r, falling back to 8000", http_cfg.get("port"))
port = 8000
# Keep runtime server settings aligned with config before start/restart.
http_server.host = host
http_server.port = port
from repeater.service_utils import start_http_server, stop_http_server
if enabled:
success, message = start_http_server(self.daemon)
else:
success, message = stop_http_server(self.daemon)
if success:
logger.info("Applied live HTTP config: %s", message)
else:
logger.warning("Failed live HTTP config apply: %s", message)
return success
def save_to_file(self) -> bool: def save_to_file(self) -> bool:
""" """
Save current config to YAML file. Save current config to YAML file.
@@ -193,7 +240,7 @@ class ConfigManager:
# Default sections to update if not specified # Default sections to update if not specified
if sections is None: if sections is None:
sections = ["repeater", "delays", "radio", "acl", "identities", "glass"] sections = ["repeater", "delays", "radio", "acl", "identities", "glass", "http"]
# Update each section # Update each section
for section in sections: for section in sections:
@@ -247,6 +294,9 @@ class ConfigManager:
if "radio" in sections: if "radio" in sections:
live_update_ok = self._apply_live_radio_config() and live_update_ok live_update_ok = self._apply_live_radio_config() and live_update_ok
if "http" in sections:
live_update_ok = self._apply_live_http_config() and live_update_ok
return live_update_ok return live_update_ok
except Exception as e: except Exception as e:
+47 -14
View File
@@ -671,7 +671,7 @@ class SQLiteHandler:
except Exception: except Exception:
fwd_path_val = str(fwd_path) fwd_path_val = str(fwd_path)
conn.execute( cursor = conn.execute(
""" """
INSERT INTO packets ( INSERT INTO packets (
timestamp, type, route, length, rssi, snr, score, timestamp, type, route, length, rssi, snr, score,
@@ -714,6 +714,7 @@ class SQLiteHandler:
), ),
) )
self._invalidate_hot_caches() self._invalidate_hot_caches()
return cursor.lastrowid
except Exception as e: except Exception as e:
logger.error(f"Failed to store packet in SQLite: {e}") logger.error(f"Failed to store packet in SQLite: {e}")
@@ -992,6 +993,7 @@ class SQLiteHandler:
packets = conn.execute( packets = conn.execute(
""" """
SELECT SELECT
id,
timestamp, type, route, length, rssi, snr, score, timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length, transport_codes, payload, payload_length,
@@ -1044,6 +1046,7 @@ class SQLiteHandler:
base_query = """ base_query = """
SELECT SELECT
id,
timestamp, type, route, length, rssi, snr, score, timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length, transport_codes, payload, payload_length,
@@ -1176,6 +1179,7 @@ class SQLiteHandler:
packet = conn.execute( packet = conn.execute(
""" """
SELECT SELECT
id,
timestamp, type, route, length, rssi, snr, score, timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
header, transport_codes, payload, payload_length, header, transport_codes, payload, payload_length,
@@ -1193,6 +1197,32 @@ class SQLiteHandler:
logger.error(f"Failed to get packet by hash: {e}") logger.error(f"Failed to get packet by hash: {e}")
return None return None
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
packet = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
header, transport_codes, payload, payload_length,
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
FROM packets
WHERE id = ?
""",
(packet_id,),
).fetchone()
return dict(packet) if packet else None
except Exception as e:
logger.error(f"Failed to get packet by id: {e}")
return None
def get_packet_type_stats(self, hours: int = 24) -> dict: def get_packet_type_stats(self, hours: int = 24) -> dict:
try: try:
now = time.time() now = time.time()
@@ -2139,29 +2169,32 @@ class SQLiteHandler:
return [] return []
def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool: def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool:
"""Insert or update client sync state using single upsert operation.""" """Insert or update client sync state without clobbering unspecified fields."""
try: try:
with self._connect() as conn: with self._connect() as conn:
now = time.time() now = time.time()
kwargs["updated_at"] = now update_fields = dict(kwargs)
update_fields["updated_at"] = now
# Set defaults for insert path # INSERT must satisfy NOT NULL columns (last_activity), while
kwargs.setdefault("sync_since", 0) # ON CONFLICT updates should only touch supplied fields.
kwargs.setdefault("pending_ack_crc", 0) insert_fields = dict(update_fields)
kwargs.setdefault("push_post_timestamp", 0) if insert_fields.get("last_activity") is None:
kwargs.setdefault("ack_timeout_time", 0) insert_fields["last_activity"] = now
kwargs.setdefault("push_failures", 0)
kwargs.setdefault("last_activity", now)
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys()) columns = ["room_hash", "client_pubkey"] + list(insert_fields.keys())
placeholders = ["?"] * len(columns) placeholders = ["?"] * len(columns)
values = [room_hash, client_pubkey] + list(kwargs.values()) values = [room_hash, client_pubkey] + list(insert_fields.values())
# Use INSERT OR REPLACE for single atomic upsert # Update only supplied columns on conflict so partial updates don't
# reset counters/state such as push_failures.
update_set = ", ".join(f"{col}=excluded.{col}" for col in update_fields.keys())
conn.execute( conn.execute(
f""" f"""
INSERT OR REPLACE INTO room_client_sync ({", ".join(columns)}) INSERT INTO room_client_sync ({", ".join(columns)})
VALUES ({", ".join(placeholders)}) VALUES ({", ".join(placeholders)})
ON CONFLICT(room_hash, client_pubkey)
DO UPDATE SET {update_set}
""", """,
values, values,
) )
+16 -5
View File
@@ -39,11 +39,15 @@ class StorageCollector:
self.sqlite_handler = SQLiteHandler(self.storage_dir) self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir) self.rrd_handler = RRDToolHandler(self.storage_dir)
# Initialize MQTT handler if configured # Initialize MQTT handler only when at least one broker is configured
self.mqtt_handler = None self.mqtt_handler = None
if ( mqtt_brokers_config = config.get("mqtt_brokers", {}) or {}
config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {}) letsmesh_config = config.get("letsmesh", {}) or {}
) and local_identity: mqtt_config = config.get("mqtt", {}) or {}
has_brokers_configured = (
bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config)
)
if has_brokers_configured and local_identity:
try: try:
# Pass local_identity directly (supports both standard and firmware keys) # Pass local_identity directly (supports both standard and firmware keys)
self.mqtt_handler = MeshCoreToMqttPusher( self.mqtt_handler = MeshCoreToMqttPusher(
@@ -58,6 +62,8 @@ class StorageCollector:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize MQTT handler: {e}") logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None self.mqtt_handler = None
else:
logger.info("MQTT handler disabled - no brokers configured")
# Initialize hardware stats collector # Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector from .hardware_stats import HardwareStatsCollector
@@ -209,7 +215,9 @@ class StorageCollector:
def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool): def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool):
"""Store, aggregate, update metrics, and publish one packet (writer thread).""" """Store, aggregate, update metrics, and publish one packet (writer thread)."""
self.sqlite_handler.store_packet(packet_record) packet_id = self.sqlite_handler.store_packet(packet_record)
if packet_id is not None:
packet_record["id"] = packet_id
cumulative_counts = self.sqlite_handler.get_cumulative_counts() cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts) self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self._publish_packet_sync(packet_record, skip_mqtt) self._publish_packet_sync(packet_record, skip_mqtt)
@@ -416,6 +424,9 @@ class StorageCollector:
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]: def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_hash(packet_hash) return self.sqlite_handler.get_packet_by_hash(packet_hash)
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_id(packet_id)
def get_rrd_data( def get_rrd_data(
self, self,
start_time: Optional[int] = None, start_time: Optional[int] = None,
+2 -2
View File
@@ -1,7 +1,7 @@
"""Storage utility classes and functions for data acquisition.""" """Storage utility classes and functions for data acquisition."""
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from datetime import datetime from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -57,7 +57,7 @@ class PacketRecord:
# Extract timestamp and format date/time # Extract timestamp and format date/time
timestamp = packet_record.get("timestamp", 0) timestamp = packet_record.get("timestamp", 0)
dt = datetime.fromtimestamp(timestamp) dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# Format route type (1=Flood->F, 2=Direct->D, etc) # Format route type (1=Flood->F, 2=Direct->D, etc)
route_map = {1: "F", 2: "D"} route_map = {1: "F", 2: "D"}
+30 -2
View File
@@ -24,6 +24,7 @@ _connected_clients = set()
PING_INTERVAL = 30 # seconds PING_INTERVAL = 30 # seconds
_heartbeat_thread = None _heartbeat_thread = None
_heartbeat_running = False _heartbeat_running = False
_websocket_plugin = None
class PacketWebSocket(WebSocket): class PacketWebSocket(WebSocket):
@@ -152,9 +153,20 @@ def _heartbeat_loop():
def init_websocket(): def init_websocket():
"""Initialize WebSocket plugin and start heartbeat""" """Initialize WebSocket plugin and start heartbeat"""
global _heartbeat_thread, _heartbeat_running global _heartbeat_thread, _heartbeat_running, _websocket_plugin
WebSocketPlugin(cherrypy.engine).subscribe() # Re-initialize plugin safely across CherryPy stop/start cycles.
# ws4py's manager thread cannot be started twice, so always tear down
# any previously subscribed plugin instance before creating a new one.
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe during init failed: {e}")
_websocket_plugin = None
_websocket_plugin = WebSocketPlugin(cherrypy.engine)
_websocket_plugin.subscribe()
cherrypy.tools.websocket = WebSocketTool() cherrypy.tools.websocket = WebSocketTool()
# Start heartbeat thread # Start heartbeat thread
@@ -165,3 +177,19 @@ def init_websocket():
logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat") logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat")
else: else:
logger.info("WebSocket initialized") logger.info("WebSocket initialized")
def shutdown_websocket():
"""Stop websocket heartbeat and unsubscribe plugin for clean restart."""
global _heartbeat_running, _heartbeat_thread, _websocket_plugin
_heartbeat_running = False
_heartbeat_thread = None
_connected_clients.clear()
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe failed: {e}")
_websocket_plugin = None
+8
View File
@@ -400,6 +400,14 @@ class RepeaterHandler(BaseHandler):
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.direct_dup_count += 1 self.direct_dup_count += 1
# Expose effective drop reason to PacketRouter.
# Some Packet implementations are slot-based and cannot accept dynamic attrs.
metadata["_repeater_drop_reason"] = drop_reason
try:
setattr(packet, "_repeater_drop_reason", drop_reason)
except (AttributeError, TypeError):
pass
display_hashes = ( display_hashes = (
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex() original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
) )
+41 -19
View File
@@ -48,6 +48,30 @@ class ACL:
self.allow_read_only = allow_read_only self.allow_read_only = allow_read_only
self.clients: Dict[bytes, ClientInfo] = {} self.clients: Dict[bytes, ClientInfo] = {}
def _is_replay(self, client: ClientInfo, timestamp: int) -> bool:
if timestamp <= client.last_timestamp:
logger.warning(
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
)
return True
return False
def _touch_client_session(
self,
client: ClientInfo,
shared_secret: bytes,
timestamp: int,
sync_since: int = None,
) -> None:
now = int(time.time())
client.last_timestamp = timestamp
client.last_activity = now
client.last_login_success = now
client.shared_secret = shared_secret
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
def authenticate_client( def authenticate_client(
self, self,
client_identity: Identity, client_identity: Identity,
@@ -106,13 +130,23 @@ class ACL:
if not password: if not password:
client = self.clients.get(pub_key) client = self.clients.get(pub_key)
if client is None: if client is None:
if self.allow_read_only: if not self.allow_read_only:
logger.info("Blank password, allowing read-only guest access")
return True, PERM_ACL_GUEST
else:
logger.info("Blank password, sender not in ACL and read-only disabled") logger.info("Blank password, sender not in ACL and read-only disabled")
return False, 0 return False, 0
logger.info(f"ACL-based login for {pub_key[:6].hex()}...") if len(self.clients) >= self.max_clients:
logger.warning("ACL full, cannot add client")
return False, 0
client = ClientInfo(client_identity, PERM_ACL_GUEST)
self.clients[pub_key] = client
logger.info("Blank password, allowing read-only guest access")
else:
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
if self._is_replay(client, timestamp):
return False, 0
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
client.permissions |= PERM_ACL_GUEST
return True, client.permissions return True, client.permissions
permissions = 0 permissions = 0
@@ -140,23 +174,11 @@ class ACL:
self.clients[pub_key] = client self.clients[pub_key] = client
logger.info(f"Added new client {pub_key[:6].hex()}...") logger.info(f"Added new client {pub_key[:6].hex()}...")
if timestamp <= client.last_timestamp: if self._is_replay(client, timestamp):
logger.warning(
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
)
return False, 0 return False, 0
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
client.last_timestamp = timestamp
client.last_activity = int(time.time())
client.last_login_success = int(time.time())
client.permissions &= ~PERM_ACL_ROLE_MASK client.permissions &= ~PERM_ACL_ROLE_MASK
client.permissions |= permissions client.permissions |= permissions
client.shared_secret = shared_secret
# Store sync_since for room server clients
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}") logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}")
return True, client.permissions return True, client.permissions
+259
View File
@@ -8,6 +8,10 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio import asyncio
import logging import logging
import secrets import secrets
import threading
import time
import uuid
from typing import Any, Callable, Optional
from openhop_core.node.handlers.control import ControlHandler from openhop_core.node.handlers.control import ControlHandler
@@ -22,6 +26,15 @@ logger = logging.getLogger("DiscoveryHelper")
# 60s (firmware pending_discover_until = futureMillis(60000)). # 60s (firmware pending_discover_until = futureMillis(60000)).
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000 DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 10.0
DISCOVERY_EVENT_BACKLOG_LIMIT = 512
NODE_TYPE_NAMES = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}
class DiscoveryHelper: class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater.""" """Helper class for processing discovery requests in the repeater."""
@@ -60,11 +73,257 @@ class DiscoveryHelper:
debug_log_fn=debug_log_fn, debug_log_fn=debug_log_fn,
) )
self._pending_tasks = set() self._pending_tasks = set()
self._sessions: dict[str, dict[str, Any]] = {}
self._sessions_lock = threading.Lock()
# Set up the request callback # Set up the request callback
self.control_handler.set_request_callback(self._on_discovery_request) self.control_handler.set_request_callback(self._on_discovery_request)
logger.debug("Discovery handler initialized") logger.debug("Discovery handler initialized")
def create_session(
self,
*,
timeout: float = DEFAULT_DISCOVERY_TIMEOUT_SECONDS,
filter_mask: int,
since: int = 0,
prefix_only: bool = False,
result_enricher: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Create a new discovery session and return its public metadata."""
session_id = uuid.uuid4().hex
tag = secrets.randbits(32)
created_at = time.time()
session = {
"session_id": session_id,
"tag": tag,
"timeout": max(1.0, float(timeout)),
"filter_mask": int(filter_mask) & 0xFF,
"since": max(0, int(since)),
"prefix_only": bool(prefix_only),
"created_at": created_at,
"started_at": None,
"completed_at": None,
"status": "created",
"results": {},
"events": [],
"next_event_id": 1,
"error": None,
"result_enricher": result_enricher,
}
with self._sessions_lock:
self._sessions[session_id] = session
return self.get_session_snapshot(session_id) or {}
def get_session_snapshot(self, session_id: str) -> Optional[dict[str, Any]]:
"""Return a public snapshot for a discovery session."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
return self._public_session_snapshot(session)
def get_events_since(self, session_id: str, last_event_id: int = 0) -> Optional[dict[str, Any]]:
"""Return all session events newer than last_event_id."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
events = [event for event in session["events"] if event["id"] > last_event_id]
return {
"events": events,
"status": session["status"],
"completed": session["status"] in {"completed", "timed_out", "error", "cancelled"},
"latest_event_id": session["next_event_id"] - 1,
}
async def execute_session(self, session_id: str) -> None:
"""Send a discovery request and stream responses into the session."""
session = self._get_session(session_id)
if not session:
raise ValueError(f"Unknown discovery session: {session_id}")
if session["status"] != "created":
return
session["started_at"] = time.time()
session["status"] = "running"
self._emit_event(
session_id,
"started",
{
"session_id": session_id,
"tag": session["tag"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"started_at": session["started_at"],
},
)
try:
from openhop_core.protocol.packet_builder import PacketBuilder
packet = PacketBuilder.create_discovery_request(
tag=session["tag"],
filter_mask=session["filter_mask"],
since=session["since"],
prefix_only=session["prefix_only"],
)
def _response_callback(response_data: dict[str, Any]) -> None:
self._record_response(session_id, response_data)
self.control_handler.set_response_callback(session["tag"], _response_callback)
if not self.packet_injector:
raise RuntimeError("No packet injector available")
success = await self.packet_injector(packet, wait_for_ack=False)
if not success:
raise RuntimeError("Failed to send discovery request")
logger.info(
"Discovery request sent for session %s tag 0x%08X filter=0x%02X",
session_id,
session["tag"],
session["filter_mask"],
)
await asyncio.sleep(session["timeout"])
self._finish_session(session_id, "completed")
except asyncio.CancelledError:
self._finish_session(session_id, "cancelled")
raise
except Exception as e:
logger.error("Discovery session %s failed: %s", session_id, e, exc_info=True)
self._finish_session(session_id, "error", error=str(e))
finally:
self.control_handler.clear_response_callback(session["tag"])
def start_session_task(self, session_id: str) -> None:
"""Schedule a discovery session on the current event loop."""
task = asyncio.create_task(self.execute_session(session_id))
self._track_task(task)
def cleanup_sessions(self, max_age_seconds: int = 120) -> None:
"""Remove old completed sessions to keep memory bounded."""
cutoff = time.time() - max_age_seconds
with self._sessions_lock:
stale_ids = [
session_id
for session_id, session in self._sessions.items()
if session["status"] in {"completed", "timed_out", "error", "cancelled"}
and (session.get("completed_at") or session.get("created_at", 0)) < cutoff
]
for session_id in stale_ids:
self._sessions.pop(session_id, None)
def _get_session(self, session_id: str) -> Optional[dict[str, Any]]:
with self._sessions_lock:
return self._sessions.get(session_id)
def _record_response(self, session_id: str, response_data: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] != "running":
return
result = dict(response_data)
result["node_type_name"] = NODE_TYPE_NAMES.get(
result.get("node_type"), f"Unknown({result.get('node_type', 0)})"
)
result["discovered_at"] = time.time()
enricher = session.get("result_enricher")
if enricher:
try:
result = enricher(result)
except Exception as e:
logger.debug("Discovery result enrichment failed: %s", e)
result_key = str(result.get("pub_key") or "")
if not result_key:
return
existing = session["results"].get(result_key)
session["results"][result_key] = result
payload = {
"session_id": session_id,
"tag": session["tag"],
"result": result,
"is_update": existing is not None,
"count": len(session["results"]),
}
self._append_event_unlocked(session, "discovery_result", payload)
def _finish_session(self, session_id: str, status: str, error: Optional[str] = None) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] in {"completed", "timed_out", "error", "cancelled"}:
return
session["completed_at"] = time.time()
session["status"] = status
session["error"] = error
payload = {
"session_id": session_id,
"tag": session["tag"],
"status": status,
"error": error,
"count": len(session["results"]),
"duration_ms": round(
(
(session["completed_at"] or session["created_at"])
- (session["started_at"] or session["created_at"])
)
* 1000,
2,
),
"completed_at": session["completed_at"],
"results": list(session["results"].values()),
}
event_type = "error" if status == "error" else "completed"
self._append_event_unlocked(session, event_type, payload)
def _emit_event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return
self._append_event_unlocked(session, event_type, payload)
def _append_event_unlocked(
self, session: dict[str, Any], event_type: str, payload: dict[str, Any]
) -> None:
event_id = session["next_event_id"]
session["next_event_id"] += 1
session["events"].append(
{
"id": event_id,
"event": event_type,
"data": payload,
}
)
if len(session["events"]) > DISCOVERY_EVENT_BACKLOG_LIMIT:
session["events"] = session["events"][-DISCOVERY_EVENT_BACKLOG_LIMIT:]
def _public_session_snapshot(self, session: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": session["session_id"],
"tag": session["tag"],
"status": session["status"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"created_at": session["created_at"],
"started_at": session["started_at"],
"completed_at": session["completed_at"],
"count": len(session["results"]),
"error": session["error"],
}
def _track_task(self, task: asyncio.Task) -> None: def _track_task(self, task: asyncio.Task) -> None:
self._pending_tasks.add(task) self._pending_tasks.add(task)
+21 -1
View File
@@ -122,7 +122,7 @@ class LoginHelper:
def auth_callback_with_context( def auth_callback_with_context(
client_identity, shared_secret, password, timestamp, sync_since=None client_identity, shared_secret, password, timestamp, sync_since=None
): ):
return identity_acl.authenticate_client( success, permissions = identity_acl.authenticate_client(
client_identity=client_identity, client_identity=client_identity,
shared_secret=shared_secret, shared_secret=shared_secret,
password=password, password=password,
@@ -132,6 +132,26 @@ class LoginHelper:
target_identity_name=name, target_identity_name=name,
target_identity_config=config, target_identity_config=config,
) )
if success and identity_type == "room_server" and self.sqlite_handler is not None:
try:
sync_kwargs = {}
if sync_since is not None:
sync_kwargs["sync_since"] = sync_since
self.sqlite_handler.upsert_client_sync(
room_hash=f"0x{hash_byte:02X}",
client_pubkey=client_identity.get_public_key().hex(),
pending_ack_crc=0,
push_post_timestamp=0,
ack_timeout_time=0,
push_failures=0,
last_activity=time.time(),
**sync_kwargs,
)
except Exception as e:
logger.warning(
f"Failed to reset room sync guard state after login for hash=0x{hash_byte:02X}: {e}"
)
return success, permissions
handler = LoginServerHandler( handler = LoginServerHandler(
local_identity=identity, local_identity=identity,
+31 -3
View File
@@ -2,7 +2,6 @@ import logging
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,6 +80,10 @@ class MeshCLI:
return self._cmd_clock(command) return self._cmd_clock(command)
elif command.startswith("time "): elif command.startswith("time "):
return self._cmd_time(command) return self._cmd_time(command)
elif command == "http start":
return self._cmd_http_start()
elif command == "http stop":
return self._cmd_http_stop()
elif command == "start ota": elif command == "start ota":
return "Error: OTA not supported in Python repeater" return "Error: OTA not supported in Python repeater"
elif command.startswith("password "): elif command.startswith("password "):
@@ -156,6 +159,8 @@ class MeshCLI:
" advert Send self advertisement", " advert Send self advertisement",
" clock Show current UTC time", " clock Show current UTC time",
" clock sync Sync clock (no-op, uses system time)", " clock sync Sync clock (no-op, uses system time)",
" http start Start the HTTP server",
" http stop Stop the HTTP server",
" ver Show version info", " ver Show version info",
" password <pw> Change admin password", " password <pw> Change admin password",
" clear stats Clear statistics", " clear stats Clear statistics",
@@ -226,6 +231,9 @@ class MeshCLI:
"reboot": "Restart the repeater service via systemd.", "reboot": "Restart the repeater service via systemd.",
"advert": "Trigger a self-advertisement flood packet.", "advert": "Trigger a self-advertisement flood packet.",
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
"http": "http start|stop - Control the HTTP server.",
"http start": "Start the HTTP server.",
"http stop": "Stop the HTTP server.",
"ver": "Show repeater version and identity type.", "ver": "Show repeater version and identity type.",
"password": "password <new_password> \u2014 Change the admin password.", "password": "password <new_password> \u2014 Change the admin password.",
"tempradio": ( "tempradio": (
@@ -296,6 +304,26 @@ class MeshCLI:
"""Set time - not supported in Python (use system time).""" """Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)" return "Error: Time setting not supported (system time is used)"
def _cmd_http_start(self) -> str:
"""Start HTTP server."""
from repeater.service_utils import start_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = start_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_http_stop(self) -> str:
"""Stop HTTP server."""
from repeater.service_utils import stop_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = stop_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_password(self, command: str) -> str: def _cmd_password(self, command: str) -> str:
"""Change admin password.""" """Change admin password."""
new_password = command[9:].strip() new_password = command[9:].strip()
@@ -329,8 +357,8 @@ class MeshCLI:
def _cmd_version(self) -> str: def _cmd_version(self) -> str:
"""Get version information.""" """Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater" role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get("version", "1.0.0") version = self.config.get("version", "13")
return f"pyMC_{role} v{version}" return f"openHop_{role} v{version}"
# ==================== Get Commands ==================== # ==================== Get Commands ====================
+50 -8
View File
@@ -1,3 +1,4 @@
import asyncio
import logging import logging
import time import time
@@ -5,14 +6,31 @@ logger = logging.getLogger("PathHelper")
class PathHelper: class PathHelper:
def __init__(self, acl_dict=None, log_fn=None): def __init__(self, acl_dict=None, log_fn=None, ack_received_callback=None):
self.acl_dict = acl_dict or {} self.acl_dict = acl_dict or {}
self.log_fn = log_fn or logger.info self.log_fn = log_fn or logger.info
self.ack_received_callback = ack_received_callback
async def _register_ack_crc(self, ack_crc: int) -> None:
"""Propagate an ACK CRC to the configured callback."""
if ack_crc is None:
return
callback = self.ack_received_callback
if callback is None:
return
try:
result = callback(ack_crc)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.debug(f"ACK callback failed for CRC {ack_crc:08X}: {e}")
async def process_path_packet(self, packet): async def process_path_packet(self, packet):
from openhop_core.protocol.constants import PAYLOAD_TYPE_ACK
from openhop_core.protocol.crypto import CryptoUtils from openhop_core.protocol.crypto import CryptoUtils
from openhop_core.protocol.packet_utils import PathUtils
try: try:
if len(packet.payload) < 2: if len(packet.payload) < 2:
@@ -60,30 +78,54 @@ class PathHelper:
return False return False
# Parse decrypted PATH data # Parse decrypted PATH data
# Format: path_len(1) + path[path_len] + extra_type(1) + extra[...] # Format: path_len(1) + path[path_byte_len] + extra_type(1) + extra[...]
if len(decrypted) < 1: if len(decrypted) < 1:
logger.debug("Decrypted PATH data too short") logger.debug("Decrypted PATH data too short")
return False return False
path_len_byte = decrypted[0]
if PathUtils.is_valid_path_len(path_len_byte):
path_byte_len = PathUtils.get_path_byte_len(path_len_byte)
path_hops = PathUtils.get_path_hash_count(path_len_byte)
else:
# Legacy fallback for malformed/old packets: treat first byte as raw path bytes.
path_byte_len = path_len_byte
path_hops = path_byte_len
path_len = decrypted[0] if len(decrypted) < 1 + path_byte_len:
if len(decrypted) < 1 + path_len:
logger.debug( logger.debug(
f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}" f"PATH data truncated: need {1 + path_byte_len} bytes, got {len(decrypted)}"
) )
return False return False
path_data = decrypted[1 : 1 + path_len] path_data = decrypted[1 : 1 + path_byte_len]
# Update client's out_path (same as C++ memcpy) # Update client's out_path (same as C++ memcpy)
client.out_path = bytearray(path_data) client.out_path = bytearray(path_data)
client.out_path_len = path_len client.out_path_len = (
path_len_byte if PathUtils.is_valid_path_len(path_len_byte) else path_byte_len
)
client.last_activity = int(time.time()) client.last_activity = int(time.time())
logger.info( logger.info(
f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: " f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: "
f"path_len={path_len}, path={[hex(b) for b in path_data]}" f"path_len_byte=0x{path_len_byte:02X}, hops={path_hops}, "
f"path={[hex(b) for b in path_data]}"
) )
# Handle bundled ACK in PATH extra section.
ack_crc = None
extra_start = 1 + path_byte_len
if len(decrypted) > extra_start:
extra_type = decrypted[extra_start] & 0x0F
extra_payload = decrypted[extra_start + 1 :]
if extra_type == PAYLOAD_TYPE_ACK and len(extra_payload) >= 4:
ack_crc = int.from_bytes(extra_payload[:4], "little")
logger.info(
f"PATH bundled ACK extracted for client 0x{src_hash:02X}: CRC={ack_crc:08X}"
)
if ack_crc is not None:
await self._register_ack_crc(ack_crc)
# Don't mark as do_not_retransmit - let it forward normally # Don't mark as do_not_retransmit - let it forward normally
return False return False
+39 -11
View File
@@ -6,6 +6,7 @@ from typing import Dict
from openhop_core.protocol import CryptoUtils, PacketBuilder from openhop_core.protocol import CryptoUtils, PacketBuilder
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
from openhop_core.protocol.packet_utils import PathUtils
logger = logging.getLogger("RoomServer") logger = logging.getLogger("RoomServer")
@@ -328,6 +329,12 @@ class RoomServer:
if sync_state: if sync_state:
failures = sync_state.get("push_failures", 0) failures = sync_state.get("push_failures", 0)
if failures >= MAX_PUSH_FAILURES:
logger.debug(
f"Room '{self.room_name}': Client 0x{client_info.id.get_public_key()[0]:02X} "
f"at max failures ({failures}), skipping push"
)
return False
if failures > 0: if failures > 0:
# Apply exponential backoff # Apply exponential backoff
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1) backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
@@ -356,11 +363,9 @@ class RoomServer:
plaintext = ( plaintext = (
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
) )
# Calculate expected ACK (MeshCore signed text):
# Calculate expected ACK (same algorithm as openhop_core) # sha256(timestamp + flags + author_prefix + text + recipient_pubkey)[:4]
attempt = 0 ack_hash = CryptoUtils.sha256(plaintext + client_info.id.get_public_key())[:4]
pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes)
ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4]
expected_ack_crc = int.from_bytes(ack_hash, "little") expected_ack_crc = int.from_bytes(ack_hash, "little")
# Determine routing based on stored out_path # Determine routing based on stored out_path
@@ -378,29 +383,52 @@ class RoomServer:
# Add stored path for direct routing # Add stored path for direct routing
if route_type == "direct" and len(client_info.out_path) > 0: if route_type == "direct" and len(client_info.out_path) > 0:
packet.path = bytearray(client_info.out_path[: client_info.out_path_len]) if PathUtils.is_valid_path_len(client_info.out_path_len):
packet.path_len = client_info.out_path_len path_byte_len = PathUtils.get_path_byte_len(client_info.out_path_len)
packet.path = bytearray(client_info.out_path[:path_byte_len])
packet.path_len = client_info.out_path_len
else:
# Legacy fallback: treat stored path as 1-byte-hop path and clamp to
# valid encoded range (0-63 hops).
legacy_hops = min(len(client_info.out_path), 63)
packet.path = bytearray(client_info.out_path[:legacy_hops])
packet.path_len = legacy_hops
# Calculate ACK timeout # Calculate ACK timeout
if route_type == "flood": if route_type == "flood":
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0 ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
else: else:
path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0 if PathUtils.is_valid_path_len(client_info.out_path_len):
path_len = PathUtils.get_path_hash_count(client_info.out_path_len)
else:
path_len = min(len(client_info.out_path), 63)
ack_timeout = ( ack_timeout = (
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1) PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
) / 1000.0 ) / 1000.0
# Update client sync state with pending ACK # Update client sync state with pending ACK
current_sync_since = (
sync_state.get("sync_since", 0)
if sync_state
else getattr(client_info, "sync_since", 0)
)
self.db.upsert_client_sync( self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}", room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_info.id.get_public_key().hex(), client_pubkey=client_info.id.get_public_key().hex(),
sync_since=current_sync_since,
pending_ack_crc=expected_ack_crc, pending_ack_crc=expected_ack_crc,
push_post_timestamp=post["post_timestamp"], push_post_timestamp=post["post_timestamp"],
ack_timeout_time=time.time() + ack_timeout, ack_timeout_time=time.time() + ack_timeout,
last_activity=time.time(),
) )
# Send packet (dispatcher will track ACK automatically) # Send packet (dispatcher will track ACK automatically)
# This blocks for the entire transmission duration (0.5-9 seconds) # This blocks for the entire transmission duration (0.5-9 seconds)
success = await self.packet_injector(packet, wait_for_ack=True) success = await self.packet_injector(
packet,
wait_for_ack=True,
expected_crc=expected_ack_crc,
ack_timeout_s=ack_timeout,
)
# SAFETY: Release transmission lock AFTER send completes # SAFETY: Release transmission lock AFTER send completes
self.global_limiter.release() self.global_limiter.release()
@@ -460,7 +488,7 @@ class RoomServer:
pending_ack_crc=0, pending_ack_crc=0,
) )
if failures >= 3: if failures >= MAX_PUSH_FAILURES:
logger.warning( logger.warning(
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} " f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
f"has {failures} consecutive failures" f"has {failures} consecutive failures"
@@ -625,7 +653,7 @@ class RoomServer:
) )
continue continue
if push_failures >= 3: if push_failures >= MAX_PUSH_FAILURES:
logger.debug( logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)" f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
) )
+1
View File
@@ -457,6 +457,7 @@ class TextHelper:
"advert", "advert",
"clock", "clock",
"time ", "time ",
"http ",
"password ", "password ",
"clear ", "clear ",
"ver", "ver",
+27 -14
View File
@@ -14,6 +14,11 @@ from typing import Optional, Tuple
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
def _has_disallowed_public_prefix(public_key: bytes) -> bool:
"""Return True when public key starts with a forbidden prefix byte."""
return bool(public_key) and public_key[0] in (0x00, 0xFF)
def generate_meshcore_keypair() -> Tuple[bytes, bytes]: def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
"""Generate a MeshCore-compatible Ed25519 keypair. """Generate a MeshCore-compatible Ed25519 keypair.
@@ -21,25 +26,30 @@ def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
(public_key, private_key) as raw bytes. (public_key, private_key) as raw bytes.
public_key is 32 bytes, private_key is 64 bytes. public_key is 32 bytes, private_key is 64 bytes.
""" """
# 1. Random 32-byte seed while True:
seed = secrets.token_bytes(32) # 1. Random 32-byte seed
seed = secrets.token_bytes(32)
# 2. SHA-512 hash # 2. SHA-512 hash
digest = hashlib.sha512(seed).digest() digest = hashlib.sha512(seed).digest()
# 3. Ed25519 scalar clamping on first 32 bytes # 3. Ed25519 scalar clamping on first 32 bytes
clamped = bytearray(digest[:32]) clamped = bytearray(digest[:32])
clamped[0] &= 248 # Clear bottom 3 bits clamped[0] &= 248 # Clear bottom 3 bits
clamped[31] &= 63 # Clear top 2 bits clamped[31] &= 63 # Clear top 2 bits
clamped[31] |= 64 # Set bit 6 clamped[31] |= 64 # Set bit 6
# 4. Derive public key # 4. Derive public key
public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped)) public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped))
# 5. Private key = [clamped_scalar][sha512_upper_half] # Disallow keys with 0x00 / 0xFF leading byte.
private_key = bytes(clamped) + digest[32:64] if _has_disallowed_public_prefix(public_key):
continue
return public_key, private_key # 5. Private key = [clamped_scalar][sha512_upper_half]
private_key = bytes(clamped) + digest[32:64]
return public_key, private_key
def generate_vanity_key( def generate_vanity_key(
@@ -57,6 +67,9 @@ def generate_vanity_key(
""" """
target = prefix.upper() target = prefix.upper()
if target.startswith("00") or target.startswith("FF"):
return None
for attempt in range(1, max_iterations + 1): for attempt in range(1, max_iterations + 1):
pub, priv = generate_meshcore_keypair() pub, priv = generate_meshcore_keypair()
if pub.hex().upper().startswith(target): if pub.hex().upper().startswith(target):
+39 -10
View File
@@ -178,8 +178,12 @@ class RepeaterDaemon:
from openhop_core import LocalIdentity from openhop_core import LocalIdentity
from openhop_core.node.dispatcher import Dispatcher from openhop_core.node.dispatcher import Dispatcher
self.dispatcher = Dispatcher(self.radio) dedupe_enabled = bool(
self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False)
)
self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled)
logger.info("Dispatcher initialized") logger.info("Dispatcher initialized")
logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled)
# Initialize Identity Manager for additional identities (e.g., room servers) # Initialize Identity Manager for additional identities (e.g., room servers)
self.identity_manager = IdentityManager(self.config) self.identity_manager = IdentityManager(self.config)
@@ -373,6 +377,11 @@ class RepeaterDaemon:
self.path_helper = PathHelper( self.path_helper = PathHelper(
acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs
log_fn=logger.info, log_fn=logger.info,
ack_received_callback=(
self.dispatcher._register_ack_received
if self.dispatcher and hasattr(self.dispatcher, "_register_ack_received")
else None
),
) )
logger.info("PATH packet processing helper initialized") logger.info("PATH packet processing helper initialized")
@@ -403,9 +412,7 @@ class RepeaterDaemon:
n, n,
) )
# Subscribe to parsed packets (pre-dedup) so duplicate path variants self._register_duplicate_logging_hook(dedupe_enabled)
# still appear in the web UI even though the Dispatcher blocks them.
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
# When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv) # When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)
self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions
@@ -909,6 +916,14 @@ class RepeaterDaemon:
except Exception as e: except Exception as e:
logger.debug("Push RX raw to companion: %s", e) logger.debug("Push RX raw to companion: %s", e)
def _register_duplicate_logging_hook(self, dedupe_enabled: bool) -> None:
"""Register pre-dedup duplicate logging only when dispatcher dedupe is active."""
if not self.dispatcher or not dedupe_enabled:
return
# When dispatcher dedupe is disabled, duplicates still flow through
# router -> repeater_handler and are already recorded there.
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None: def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None:
"""Record duplicate packets for UI visibility. """Record duplicate packets for UI visibility.
@@ -1378,8 +1393,19 @@ class RepeaterDaemon:
await self.initialize() await self.initialize()
# Start HTTP stats server # Start HTTP stats server
http_port = self.config.get("http", {}).get("port", 8000) http_config = self.config.get("http", {})
http_host = self.config.get("http", {}).get("host", "0.0.0.0") # nosec B104 http_port = http_config.get("port", 8000)
http_host = http_config.get("host", "0.0.0.0") # nosec B104
http_enabled_raw = http_config.get("enabled", True)
if isinstance(http_enabled_raw, str):
http_enabled = http_enabled_raw.strip().lower() in (
"1",
"true",
"yes",
"on",
)
else:
http_enabled = bool(http_enabled_raw)
node_name = self.config.get("repeater", {}).get("node_name", "Repeater") node_name = self.config.get("repeater", {}).get("node_name", "Repeater")
@@ -1408,10 +1434,13 @@ class RepeaterDaemon:
config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"), config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"),
) )
try: if http_enabled:
self.http_server.start() try:
except Exception as e: self.http_server.start()
logger.error(f"Failed to start HTTP server: {e}") except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
else:
logger.info("HTTP server startup skipped (http.enabled=false)")
# Run dispatcher (handles RX/TX via openhop_core) # Run dispatcher (handles RX/TX via openhop_core)
try: try:
+54 -7
View File
@@ -8,6 +8,7 @@ from openhop_core.node.handlers.control import ControlHandler
from openhop_core.node.handlers.group_text import GroupTextHandler from openhop_core.node.handlers.group_text import GroupTextHandler
from openhop_core.node.handlers.login_response import LoginResponseHandler from openhop_core.node.handlers.login_response import LoginResponseHandler
from openhop_core.node.handlers.login_server import LoginServerHandler from openhop_core.node.handlers.login_server import LoginServerHandler
from openhop_core.node.handlers.multipart import MultipartAckHandler
from openhop_core.node.handlers.path import PathHandler from openhop_core.node.handlers.path import PathHandler
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
@@ -261,6 +262,16 @@ class PacketRouter:
except Exception as e: except Exception as e:
logger.debug("Record for UI failed: %s", e) logger.debug("Record for UI failed: %s", e)
async def _register_ack_with_dispatcher(self, ack_crc: int, context: str) -> None:
"""Best-effort ACK CRC registration with the dispatcher waiter path."""
dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher is None or not hasattr(dispatcher, "_register_ack_received"):
return
try:
await dispatcher._register_ack_received(ack_crc)
except Exception as e:
logger.debug("Dispatcher %s registration error: %s", context, e)
async def enqueue(self, packet): async def enqueue(self, packet):
"""Add packet to router queue.""" """Add packet to router queue."""
if self.queue.full(): if self.queue.full():
@@ -271,7 +282,14 @@ class PacketRouter:
pass pass
await self.queue.put(packet) await self.queue.put(packet)
async def inject_packet(self, packet, wait_for_ack: bool = False, origin_hash=None): async def inject_packet(
self,
packet,
wait_for_ack: bool = False,
expected_crc=None,
origin_hash=None,
ack_timeout_s: float = 5.0,
):
try: try:
metadata = { metadata = {
"rssi": getattr(packet, "rssi", 0), "rssi": getattr(packet, "rssi", 0),
@@ -327,11 +345,20 @@ class PacketRouter:
dispatcher = getattr(self.daemon, "dispatcher", None) dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher and hasattr(dispatcher, "wait_for_ack"): if dispatcher and hasattr(dispatcher, "wait_for_ack"):
try: try:
expected_crc = packet.get_crc() wait_crc = (
ack_ok = await dispatcher.wait_for_ack(expected_crc, timeout=5.0) expected_crc if expected_crc is not None else packet.get_crc()
)
wait_timeout = (
float(ack_timeout_s)
if isinstance(ack_timeout_s, (int, float)) and ack_timeout_s > 0
else 5.0
)
ack_ok = await dispatcher.wait_for_ack(wait_crc, timeout=wait_timeout)
if not ack_ok: if not ack_ok:
logger.warning( logger.warning(
"Injected packet ACK timeout (crc=%08X)", expected_crc "Injected packet ACK timeout (crc=%08X, timeout=%.1fs)",
wait_crc,
wait_timeout,
) )
return False return False
except Exception as e: except Exception as e:
@@ -463,6 +490,10 @@ class PacketRouter:
self._record_for_ui(packet, metadata) self._record_for_ui(packet, metadata)
elif payload_type == AckHandler.payload_type(): elif payload_type == AckHandler.payload_type():
# Ensure ACK CRC reaches dispatcher waiter path even when only router fallback is active.
if len(getattr(packet, "payload", b"")) >= 4:
ack_crc = int.from_bytes(packet.payload[:4], "little")
await self._register_ack_with_dispatcher(ack_crc, "ACK")
# ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed. # ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed.
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
companion_bridges = self._companion_bridges_for_packet(packet, metadata) companion_bridges = self._companion_bridges_for_packet(packet, metadata)
@@ -472,6 +503,15 @@ class PacketRouter:
except Exception as e: except Exception as e:
logger.debug(f"Companion bridge ACK error: {e}") logger.debug(f"Companion bridge ACK error: {e}")
elif payload_type == MultipartAckHandler.payload_type():
# MULTIPART ACK wrapper: low nibble of first byte is embedded payload type.
if (
len(getattr(packet, "payload", b"")) >= 5
and (packet.payload[0] & 0x0F) == AckHandler.payload_type()
):
ack_crc = int.from_bytes(packet.payload[1:5], "little")
await self._register_ack_with_dispatcher(ack_crc, "multi-ACK")
elif payload_type == TextMessageHandler.payload_type(): elif payload_type == TextMessageHandler.payload_type():
dest_hash = packet.payload[0] if packet.payload else None dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata) companion_bridges = self._companion_bridges_for_packet(packet, metadata)
@@ -486,6 +526,13 @@ class PacketRouter:
self._record_for_ui(packet, metadata) self._record_for_ui(packet, metadata)
elif payload_type == PathHandler.payload_type(): elif payload_type == PathHandler.payload_type():
# Always let PathHelper inspect/decrypt PATH first so out_path and bundled ACK state
# are updated even when companion routing fan-out also happens for this packet.
if self.daemon.path_helper:
try:
await self.daemon.path_helper.process_path_packet(packet)
except Exception as e:
logger.debug(f"Path helper processing error: {e}")
dest_hash = packet.payload[0] if packet.payload else None dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata) companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges: if dest_hash is not None and dest_hash in companion_bridges:
@@ -506,8 +553,6 @@ class PacketRouter:
len(companion_bridges), len(companion_bridges),
) )
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
elif self.daemon.path_helper:
await self.daemon.path_helper.process_path_packet(packet)
elif payload_type == LoginResponseHandler.payload_type(): elif payload_type == LoginResponseHandler.payload_type():
# PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted. # PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted.
@@ -620,7 +665,9 @@ class PacketRouter:
if self.daemon.repeater_handler and not processed_by_injection: if self.daemon.repeater_handler and not processed_by_injection:
sent = await self.daemon.repeater_handler(packet, metadata) sent = await self.daemon.repeater_handler(packet, metadata)
if sent is False: if sent is False:
drop_reason = getattr(packet, "_repeater_drop_reason", None) drop_reason = metadata.get("_repeater_drop_reason")
if not isinstance(drop_reason, str):
drop_reason = getattr(packet, "_repeater_drop_reason", None)
if not isinstance(drop_reason, str): if not isinstance(drop_reason, str):
drop_reason = _drop_reason_from_recent_packets( drop_reason = _drop_reason_from_recent_packets(
self.daemon.repeater_handler, packet self.daemon.repeater_handler, packet
+54
View File
@@ -188,3 +188,57 @@ def restart_service() -> Tuple[bool, str]:
except Exception as e: except Exception as e:
logger.error(f"Error executing sudo restart: {e}") logger.error(f"Error executing sudo restart: {e}")
return False, f"Restart command failed: {str(e)}" return False, f"Restart command failed: {str(e)}"
def _is_cherrypy_engine_running() -> Optional[bool]:
"""Return CherryPy engine running state when available."""
try:
import cherrypy
from cherrypy.process import wspbus
state = cherrypy.engine.state
return state in (wspbus.states.STARTING, wspbus.states.STARTED)
except Exception:
return None
def stop_http_server(daemon_instance) -> Tuple[bool, str]:
"""Stop the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is False:
return True, "HTTP server already stopped"
try:
http_server.stop()
return True, "HTTP server stopped"
except Exception as exc:
logger.error(f"Failed to stop HTTP server: {exc}", exc_info=True)
return False, f"Failed to stop HTTP server: {exc}"
def start_http_server(daemon_instance) -> Tuple[bool, str]:
"""Start the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is True:
return True, "HTTP server already running"
try:
http_server.start()
return True, "HTTP server started"
except Exception as exc:
logger.error(f"Failed to start HTTP server: {exc}", exc_info=True)
return False, f"Failed to start HTTP server: {exc}"
+311 -3
View File
@@ -122,6 +122,9 @@ POLICY_GROUP_KINDS = {
# GET /api/unscoped_flood_policy - Get unscoped flood policy # GET /api/unscoped_flood_policy - Get unscoped flood policy
# POST /api/unscoped_flood_policy - Update unscoped flood policy # POST /api/unscoped_flood_policy - Update unscoped flood policy
# POST /api/ping_neighbor - Ping a neighbor node # POST /api/ping_neighbor - Ping a neighbor node
# POST /api/discover_neighbors_start - Start a live repeater discovery session
# GET /api/discover_neighbors_stream?session_id=X - Stream repeater discovery results over SSE
# POST /api/add_discovered_neighbor - Persist a discovered node into the neighbors/adverts table
# Identity Management # Identity Management
# GET /api/identities - List all identities # GET /api/identities - List all identities
@@ -303,6 +306,81 @@ class APIEndpoints:
end_time = int(time.time()) end_time = int(time.time())
return end_time - (hours * 3600), end_time return end_time - (hours * 3600), end_time
def _encode_sse_event(
self, payload, event_name: Optional[str] = None, event_id: Optional[int] = None
) -> str:
lines = []
if event_name:
lines.append(f"event: {event_name}")
if event_id is not None:
lines.append(f"id: {event_id}")
lines.append(f"data: {json.dumps(payload, default=str)}")
return "\n".join(lines) + "\n\n"
@staticmethod
def _normalize_discovery_node_name(value) -> Optional[str]:
"""Normalize placeholder discovery names to None.
Some peers explicitly advertise "Unknown"-style placeholder names.
Treat those as missing so callers can fall back to hash/prefix labels.
"""
text = str(value or "").strip()
if not text:
return None
normalized = text.lower()
if normalized in {"unknown", "unknown node"}:
return None
return text
def _enrich_discovery_result(self, result: dict) -> dict:
enriched = dict(result)
enriched["node_name"] = self._normalize_discovery_node_name(enriched.get("node_name"))
pub_key = str(enriched.get("pub_key") or "").lower()
if not pub_key:
return enriched
try:
enriched["node_hash"] = self._fmt_hash(bytes.fromhex(pub_key))
except ValueError:
enriched["node_hash"] = None
try:
storage = self._get_storage()
except Exception:
storage = None
if not storage:
enriched["known_neighbor"] = False
return enriched
neighbor_info = {}
try:
if hasattr(storage, "get_neighbors"):
neighbor_info = storage.get_neighbors().get(pub_key, {}) or {}
except Exception as exc:
logger.debug("Discovery enrichment could not load neighbors: %s", exc)
if not neighbor_info:
try:
node_name = storage.get_node_name_by_pubkey(pub_key)
except Exception:
node_name = None
node_name = self._normalize_discovery_node_name(node_name)
enriched["known_neighbor"] = bool(node_name)
if node_name:
enriched["node_name"] = node_name
return enriched
enriched["known_neighbor"] = True
enriched["node_name"] = self._normalize_discovery_node_name(neighbor_info.get("node_name"))
enriched["contact_type"] = neighbor_info.get("contact_type")
enriched["zero_hop"] = neighbor_info.get("zero_hop")
enriched["last_seen"] = neighbor_info.get("last_seen")
enriched["advert_count"] = neighbor_info.get("advert_count")
return enriched
def _process_counter_data(self, data_points, timestamps_ms): def _process_counter_data(self, data_points, timestamps_ms):
rates = [] rates = []
prev_value = None prev_value = None
@@ -2962,6 +3040,18 @@ class APIEndpoints:
logger.error(f"Error getting packet by hash: {e}") logger.error(f"Error getting packet by hash: {e}")
return self._error(e) return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def packet_by_id(self, packet_id=None):
try:
if packet_id is None:
return self._error("packet_id parameter required")
packet = self._get_storage().get_packet_by_id(int(packet_id))
return self._success(packet) if packet else self._error("Packet not found")
except Exception as e:
logger.error(f"Error getting packet by id: {e}")
return self._error(e)
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
def rrd_data(self): def rrd_data(self):
@@ -4032,6 +4122,222 @@ class APIEndpoints:
logger.error(f"Error pinging neighbor: {e}", exc_info=True) logger.error(f"Error pinging neighbor: {e}", exc_info=True)
return self._error(str(e)) return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def discover_neighbors_start(self):
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
timeout = float(data.get("timeout", 5))
filter_mask = int(data.get("filter_mask", 1 << 2))
since = int(data.get("since", 0))
prefix_only = bool(data.get("prefix_only", False))
if timeout < 1 or timeout > 60:
return self._error("timeout must be between 1 and 60 seconds")
if filter_mask < 0 or filter_mask > 0xFF:
return self._error("filter_mask must be between 0x00 and 0xFF")
if since < 0:
return self._error("since must be non-negative")
if self.event_loop is None:
return self._error("Event loop not available")
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
if not discovery_helper:
return self._error("Discovery helper not available")
discovery_helper.cleanup_sessions()
session = discovery_helper.create_session(
timeout=timeout,
filter_mask=filter_mask,
since=since,
prefix_only=prefix_only,
result_enricher=self._enrich_discovery_result,
)
self.event_loop.call_soon_threadsafe(
discovery_helper.start_session_task, session["session_id"]
)
return self._success(
session,
message="Discovery session started",
)
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error("Error starting discovery session: %s", e, exc_info=True)
return self._error(str(e))
@cherrypy.expose
def discover_neighbors_stream(self, session_id=None, last_event_id: Optional[str] = None):
self._set_cors_headers()
cherrypy.response.headers["Content-Type"] = "text/event-stream"
cherrypy.response.headers["Cache-Control"] = "no-cache"
cherrypy.response.headers["Connection"] = "keep-alive"
cherrypy.response.headers["X-Accel-Buffering"] = "no"
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
try:
cursor = int(last_event_id) if last_event_id is not None else 0
except (TypeError, ValueError):
cursor = 0
def generate():
if not session_id:
yield self._encode_sse_event(
{"type": "error", "error": "Missing session_id"},
event_name="error",
)
return
if not discovery_helper:
yield self._encode_sse_event(
{"type": "error", "error": "Discovery helper not available"},
event_name="error",
)
return
snapshot = discovery_helper.get_session_snapshot(session_id)
if not snapshot:
yield self._encode_sse_event(
{"type": "error", "error": f"Unknown discovery session: {session_id}"},
event_name="error",
)
return
yield self._encode_sse_event(
{
"type": "connected",
"session": snapshot,
},
event_name="connected",
)
current_cursor = cursor
try:
while True:
event_state = discovery_helper.get_events_since(session_id, current_cursor)
if event_state is None:
yield self._encode_sse_event(
{
"type": "error",
"error": f"Unknown discovery session: {session_id}",
},
event_name="error",
)
return
events = event_state.get("events", [])
if events:
for event in events:
current_cursor = max(current_cursor, int(event.get("id", 0)))
yield self._encode_sse_event(
event.get("data", {}),
event_name=event.get("event"),
event_id=event.get("id"),
)
if event_state.get("completed"):
return
else:
if event_state.get("completed"):
return
yield self._encode_sse_event(
{"type": "keepalive", "session_id": session_id},
event_name="keepalive",
event_id=current_cursor if current_cursor > 0 else None,
)
time.sleep(0.5)
except GeneratorExit:
logger.debug("Discovery SSE stream closed by client for session %s", session_id)
except Exception as exc:
logger.error("Discovery SSE stream error: %s", exc, exc_info=True)
yield self._encode_sse_event(
{"type": "error", "error": str(exc), "session_id": session_id},
event_name="error",
event_id=current_cursor if current_cursor > 0 else None,
)
return generate()
discover_neighbors_stream._cp_config = {"response.stream": True}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
@require_auth
def add_discovered_neighbor(self):
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
pub_key = str(data.get("pub_key") or "").strip().lower()
node_name = self._normalize_discovery_node_name(data.get("node_name"))
node_type = int(data.get("node_type", 0))
rssi = data.get("rssi")
snr = data.get("response_snr", data.get("snr"))
if not pub_key:
return self._error("pub_key is required")
if not re.fullmatch(r"[0-9a-f]{16}|[0-9a-f]{64}", pub_key):
return self._error("pub_key must be 8-byte or 32-byte hex")
contact_type = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}.get(node_type, "Unknown")
advert_record = {
"timestamp": time.time(),
"pubkey": pub_key,
"node_name": node_name,
"is_repeater": node_type == 2,
"route_type": 2,
"contact_type": contact_type,
"latitude": None,
"longitude": None,
"rssi": int(rssi) if rssi is not None else None,
"snr": float(snr) if snr is not None else None,
"is_new_neighbor": True,
"zero_hop": True,
}
storage = self._get_storage()
storage.record_advert(advert_record)
enriched = self._enrich_discovery_result(
{
"pub_key": pub_key,
"node_name": node_name,
"node_type": node_type,
"node_type_name": contact_type,
"rssi": advert_record["rssi"],
"response_snr": advert_record["snr"],
}
)
return self._success(enriched, message="Neighbor added to adverts database")
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error("Error adding discovered neighbor: %s", e, exc_info=True)
return self._error(str(e))
# ========== Identity Management Endpoints ========== # ========== Identity Management Endpoints ==========
@cherrypy.expose @cherrypy.expose
@@ -4256,9 +4562,11 @@ class APIEndpoints:
key_was_generated = False key_was_generated = False
if not identity_key: if not identity_key:
try: try:
# Generate a new random 32-byte key (same method as config.py) # Use MeshCore-compatible keygen and store 32-byte private scalar hex.
random_key = os.urandom(32) from repeater.keygen import generate_meshcore_keypair
identity_key = random_key.hex()
_, private_key = generate_meshcore_keypair()
identity_key = private_key[:32].hex()
key_was_generated = True key_was_generated = True
logger.info(f"Auto-generated identity key for '{name}': {identity_key[:16]}...") logger.info(f"Auto-generated identity key for '{name}': {identity_key[:16]}...")
except Exception as gen_error: except Exception as gen_error:
+20
View File
@@ -39,6 +39,26 @@ def require_auth(func):
else: else:
logger.warning("Invalid or expired JWT token") logger.warning("Invalid or expired JWT token")
request_params = getattr(cherrypy.request, "params", None)
if request_params is None:
request_params = {}
query_token = request_params.get("token")
if query_token:
payload = jwt_handler.verify_jwt(query_token)
if payload:
cherrypy.request.user = {
"username": payload["sub"],
"client_id": payload["client_id"],
"auth_type": "jwt_query",
}
if hasattr(cherrypy.request, "params") and "token" in cherrypy.request.params:
del cherrypy.request.params["token"]
return func(*args, **kwargs)
else:
logger.warning("Invalid or expired JWT query token")
# Try API token authentication # Try API token authentication
api_key = cherrypy.request.headers.get("X-API-Key", "") api_key = cherrypy.request.headers.get("X-API-Key", "")
if api_key: if api_key:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.glass-card[data-v-4267adba]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)} .glass-card[data-v-f8312427]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.ml-0[data-v-1b1421f8]{margin-left:0}.ml-4[data-v-1b1421f8]{margin-left:1rem}.ml-8[data-v-1b1421f8]{margin-left:2rem}.ml-12[data-v-1b1421f8]{margin-left:3rem}.ml-16[data-v-1b1421f8]{margin-left:4rem}.ml-20[data-v-1b1421f8]{margin-left:5rem}.ml-24[data-v-1b1421f8]{margin-left:6rem}.ml-28[data-v-1b1421f8]{margin-left:7rem}.ml-32[data-v-1b1421f8]{margin-left:8rem}.dropdown-enter-active[data-v-45cb296d],.dropdown-leave-active[data-v-45cb296d]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-45cb296d],.dropdown-leave-to[data-v-45cb296d]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-00e540ed],.expand-leave-active[data-v-00e540ed]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-00e540ed],.expand-leave-to[data-v-00e540ed]{opacity:0;max-height:0}.expand-enter-to[data-v-00e540ed],.expand-leave-from[data-v-00e540ed]{opacity:1;max-height:2000px}
@@ -0,0 +1 @@
.ml-0[data-v-164d2832]{margin-left:0}.ml-4[data-v-164d2832]{margin-left:1rem}.ml-8[data-v-164d2832]{margin-left:2rem}.ml-12[data-v-164d2832]{margin-left:3rem}.ml-16[data-v-164d2832]{margin-left:4rem}.ml-20[data-v-164d2832]{margin-left:5rem}.ml-24[data-v-164d2832]{margin-left:6rem}.ml-28[data-v-164d2832]{margin-left:7rem}.ml-32[data-v-164d2832]{margin-left:8rem}.dropdown-enter-active[data-v-e0c27f45],.dropdown-leave-active[data-v-e0c27f45]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-e0c27f45],.dropdown-leave-to[data-v-e0c27f45]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,warning:`bg-accent-amber/opacity-light dark:bg-accent-amber/opacity-medium border-accent-amber/opacity-medium text-accent-amber`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},C={danger:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,warning:`bg-accent-amber/opacity-light hover:bg-accent-amber/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`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),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/opacity-subtle hover:bg-stroke-subtle dark:hover:bg-white/opacity-light text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/opacity-light`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},C={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`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-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`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),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.globe-stage[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-9debe317]:active{cursor:grabbing}.globe-stage canvas[data-v-9debe317]{width:100%;height:330px;display:block}.globe-tooltip[data-v-9debe317]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-9debe317]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-9debe317]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-9debe317]{color:var(--color-text-muted)}.tooltip-value[data-v-9debe317]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-9debe317]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-9debe317]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-9debe317]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-9debe317]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-9debe317]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-9debe317]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-9debe317]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-9debe317]{opacity:.35}.page-tabs[data-v-9debe317]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-9debe317]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-9debe317]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-9debe317]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-9debe317]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-9debe317]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-9debe317]{transform:rotate(180deg)}.accordion-body[data-v-9debe317]{padding:0 20px 16px} .globe-stage[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-481d86b0]:active{cursor:grabbing}.globe-stage canvas[data-v-481d86b0]{width:100%;height:330px;display:block}.globe-tooltip[data-v-481d86b0]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-481d86b0]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-481d86b0]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-481d86b0]{color:var(--color-text-muted)}.tooltip-value[data-v-481d86b0]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-481d86b0]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-481d86b0]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-481d86b0]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-481d86b0]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-481d86b0]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-481d86b0]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-481d86b0]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-481d86b0]{opacity:.35}.page-tabs[data-v-481d86b0]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-481d86b0]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-481d86b0]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-481d86b0]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-481d86b0]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-481d86b0]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-481d86b0]{transform:rotate(180deg)}.accordion-body[data-v-481d86b0]{padding:0 20px 16px}
@@ -0,0 +1 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/opacity-light rounded-[15px] p-8"><h1 class="text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/openhop-dev/openhope-repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/opacity-medium hover:bg-primary/opacity-medium border border-primary/opacity-heavy text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
@@ -1 +0,0 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.bg-gradient-light[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-87cdf9e4]:focus{background:var(--color-surface)}.dark .input-glass[data-v-87cdf9e4]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-87cdf9e4]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-87cdf9e4]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-87cdf9e4]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-87cdf9e4]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-87cdf9e4]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-87cdf9e4]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-87cdf9e4{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-87cdf9e4{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-87cdf9e4]{animation:8s ease-in-out infinite pulse-slow-87cdf9e4}.animate-pulse-slower[data-v-87cdf9e4]{animation:10s ease-in-out infinite pulse-slower-87cdf9e4}.animate-pulse-slowest[data-v-87cdf9e4]{animation:12s ease-in-out infinite pulse-slowest-87cdf9e4}@keyframes shake-87cdf9e4{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-87cdf9e4]{animation:.5s ease-in-out shake-87cdf9e4}.form-group[data-v-87cdf9e4]{position:relative}.form-group:hover label[data-v-87cdf9e4]{color:var(--color-accent-cyan);transition:color .3s} .bg-gradient-light[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-eec727a3]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-eec727a3]:focus{background:var(--color-surface)}.dark .input-glass[data-v-eec727a3]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-eec727a3]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-eec727a3]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-eec727a3]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-eec727a3]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-eec727a3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-eec727a3]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-eec727a3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-eec727a3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-eec727a3]{animation:8s ease-in-out infinite pulse-slow-eec727a3}.animate-pulse-slower[data-v-eec727a3]{animation:10s ease-in-out infinite pulse-slower-eec727a3}.animate-pulse-slowest[data-v-eec727a3]{animation:12s ease-in-out infinite pulse-slowest-eec727a3}@keyframes shake-eec727a3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-eec727a3]{animation:.5s ease-in-out shake-eec727a3}.form-group[data-v-eec727a3]{position:relative}.form-group:hover label[data-v-eec727a3]{color:var(--color-accent-cyan);transition:color .3s}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-accent-green/opacity-light dark:bg-accent-green/opacity-medium border-accent-green/opacity-heavy dark:border-accent-green/opacity-medium text-accent-green`,error:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},x={success:`bg-accent-green/opacity-light hover:bg-accent-green/opacity-light`,error:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[s(`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),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={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`},x={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(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[s(`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),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.modal-enter-active[data-v-e95d3629]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-e95d3629]{transition:all .2s ease-in}.modal-enter-from[data-v-e95d3629]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-e95d3629]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-e95d3629]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-e95d3629]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.modal-enter-active[data-v-1921baee]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-1921baee]{transition:all .2s ease-in}.modal-enter-from[data-v-1921baee]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-1921baee]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-1921baee]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-1921baee]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.fade-enter-active[data-v-1709062a],.fade-leave-active[data-v-1709062a]{transition:opacity .2s}.fade-enter-from[data-v-1709062a],.fade-leave-to[data-v-1709062a]{opacity:0}
@@ -1 +0,0 @@
.fade-enter-active[data-v-2abc6745],.fade-leave-active[data-v-2abc6745]{transition:opacity .2s}.fade-enter-from[data-v-2abc6745],.fade-leave-to[data-v-2abc6745]{opacity:0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-BwYDm56e.js";import{t as d}from"./index-Cijj_ZXo.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading dark:text-white`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading dark:text-white`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading dark:text-white`},w={key:0,class:`text-sm`},T={class:`ml-2 text-red-600 dark:text-red-300`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/10`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading dark:text-white`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading dark:text-white`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/10 px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/10 p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300`:`bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/5 dark:bg-white/5`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/10`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
@@ -0,0 +1 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-BfRyusGC.js";import{t as d}from"./index-C_venKRo.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading`},w={key:0,class:`text-sm`},T={class:`ml-2 text-accent-red`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/opacity-light px-3 py-2 text-sm hover:bg-black/opacity-light dark:hover:bg-white/opacity-light`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-accent-green/opacity-light text-accent-green dark:bg-accent-green/opacity-medium dark:text-accent-green`:`bg-accent-red/opacity-light text-accent-red dark:bg-accent-red/opacity-medium dark:text-accent-red`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/opacity-light dark:bg-white/opacity-subtle`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/opacity-light`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.glass-card[data-v-5bdf00ba]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-5bdf00ba]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-5bdf00ba],.modal-leave-active[data-v-5bdf00ba]{transition:opacity .3s}.modal-enter-from[data-v-5bdf00ba],.modal-leave-to[data-v-5bdf00ba]{opacity:0}.modal-enter-active .glass-card[data-v-5bdf00ba],.modal-leave-active .glass-card[data-v-5bdf00ba]{transition:transform .3s}.modal-enter-from .glass-card[data-v-5bdf00ba],.modal-leave-to .glass-card[data-v-5bdf00ba]{transform:scale(.9)}.slide-enter-active[data-v-5bdf00ba],.slide-leave-active[data-v-5bdf00ba]{transition:all .3s}.slide-enter-from[data-v-5bdf00ba],.slide-leave-to[data-v-5bdf00ba]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-5bdf00ba{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-5bdf00ba{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-5bdf00ba{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-5bdf00ba]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-5bdf00ba}.animate-pulse-slower[data-v-5bdf00ba]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-5bdf00ba}.animate-pulse-slowest[data-v-5bdf00ba]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-5bdf00ba}
@@ -1 +0,0 @@
.glass-card[data-v-ecd1d451]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-ecd1d451]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-ecd1d451],.modal-leave-active[data-v-ecd1d451]{transition:opacity .3s}.modal-enter-from[data-v-ecd1d451],.modal-leave-to[data-v-ecd1d451]{opacity:0}.modal-enter-active .glass-card[data-v-ecd1d451],.modal-leave-active .glass-card[data-v-ecd1d451]{transition:transform .3s}.modal-enter-from .glass-card[data-v-ecd1d451],.modal-leave-to .glass-card[data-v-ecd1d451]{transform:scale(.9)}.slide-enter-active[data-v-ecd1d451],.slide-leave-active[data-v-ecd1d451]{transition:all .3s}.slide-enter-from[data-v-ecd1d451],.slide-leave-to[data-v-ecd1d451]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-ecd1d451{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-ecd1d451{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-ecd1d451{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-ecd1d451]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-ecd1d451}.animate-pulse-slower[data-v-ecd1d451]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-ecd1d451}.animate-pulse-slowest[data-v-ecd1d451]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-ecd1d451}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-BwYDm56e.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=5;function p(e,t){return e-t}function m(e){return l[e]??l[d]}function h(e,t){let n=t+f;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,bgColor:`bg-accent-red`,snr:e,quality:n===0?`None`:`Poor`}}if(e<n){let n=(e-t)/f<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,bgColor:n===2?`bg-orange-600 dark:bg-orange-400`:`bg-yellow-600 dark:bg-yellow-400`,snr:e,quality:`Fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,bgColor:`bg-accent-green`,snr:e,quality:r===5?`Excellent`:`Good`}}function g(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>m(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,bgColor:`bg-gray-400 dark:bg-gray-500`,snr:-999,quality:`None`};let n=p(e,t.value);return h(Math.max(-30,Math.min(20,n)),r.value)},noiseFloor:t,spreadingFactor:n,minSNR:r}}var _={class:`flex items-end gap-0.5`},v=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,_,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{g as n,v as t};
@@ -0,0 +1 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-BfRyusGC.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=4,p=5;function m(e,t){return e-t}function h(e){return l[e]??l[d]}function g(e,t){if(e<t)return{bars:0,color:`text-accent-red`,bgColor:`bg-accent-red`,snr:e,quality:`None`};let n=Math.min(p,Math.floor((e-t)/f)+1);return{bars:n,snr:e,...{1:{color:`text-accent-red`,bgColor:`bg-accent-red`,quality:`Poor`},2:{color:`text-accent-orange`,bgColor:`bg-accent-orange`,quality:`Poor`},3:{color:`text-accent-amber`,bgColor:`bg-accent-amber`,quality:`Fair`},4:{color:`text-accent-green-light`,bgColor:`bg-accent-green-light`,quality:`Good`},5:{color:`text-accent-green`,bgColor:`bg-accent-green`,quality:`Excellent`}}[n]}}function _(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>h(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`};let n=m(e,t.value);return g(Math.max(-30,Math.min(20,n)),r.value)},getSignalQualityFromSNR:e=>e===null||!Number.isFinite(e)?{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`}:g(Math.max(-30,Math.min(20,e)),r.value),noiseFloor:t,spreadingFactor:n,minSNR:r}}var v={class:`flex items-end gap-0.5`},y=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,v,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{_ as n,y as t};
@@ -1 +1 @@
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-Cijj_ZXo.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t}; import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-C_venKRo.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.chart-updating[data-v-e3234fe9]{animation:.8s ease-in-out subtle-pulse-e3234fe9}@keyframes subtle-pulse-e3234fe9{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-e3234fe9]{transition:all .3s}.process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-e3234fe9],.process-row-leave-active[data-v-e3234fe9]{transition:all .4s}.process-row-enter-from[data-v-e3234fe9]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-e3234fe9]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-e3234fe9]{transition:transform .4s}.cpu-value[data-v-e3234fe9],.memory-value[data-v-e3234fe9]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-e3234fe9]:hover,.memory-value[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-e3234fe9{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-e3234fe9]{animation:.6s ease-out value-update-e3234fe9}
@@ -0,0 +1 @@
.chart-updating[data-v-66d09830]{animation:.8s ease-in-out subtle-pulse-66d09830}@keyframes subtle-pulse-66d09830{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-66d09830]{transition:all .3s}.process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-66d09830],.process-row-leave-active[data-v-66d09830]{transition:all .4s}.process-row-enter-from[data-v-66d09830]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-66d09830]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-66d09830]{transition:transform .4s}.cpu-value[data-v-66d09830],.memory-value[data-v-66d09830]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-66d09830]:hover,.memory-value[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-66d09830{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-66d09830]{animation:.6s ease-out value-update-66d09830}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./dataService-5Ok9aIVh.js";export{e as useDataService};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{t as e}from"./dataService-Cr4DtwcP.js";export{e as useDataService};
@@ -1 +0,0 @@
function e(e){return e==null?`N/A`:`${e} dBm`}function t(e){return e==null?`N/A`:`${e.toFixed(1)} dB`}var n={0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`};function r(e){return e==null?`Unknown`:n[e]??`Unknown`}function i(e){switch(e){case 2:return{text:`Direct`,bgColor:`bg-green-100 dark:bg-green-500/20`,borderColor:`border-green-500 dark:border-green-400/30`,textColor:`text-green-600 dark:text-green-400`};case 3:return{text:`Transport Direct`,bgColor:`bg-green-100 dark:bg-green-600/20`,borderColor:`border-green-600/40 dark:border-green-500/30`,textColor:`text-green-700 dark:text-green-500`};case 1:return{text:`Flood`,bgColor:`bg-yellow-100 dark:bg-yellow-500/20`,borderColor:`border-yellow-500 dark:border-yellow-400/30`,textColor:`text-yellow-600 dark:text-yellow-400`};case 0:return{text:`Transport Flood`,bgColor:`bg-orange-100 dark:bg-orange-500/20`,borderColor:`border-orange-500 dark:border-orange-400/30`,textColor:`text-orange-600 dark:text-orange-400`};default:return{text:`Unknown`,bgColor:`bg-gray-500/20`,borderColor:`border-gray-400/30`,textColor:`text-gray-400`}}}function a(e){return new Date(e*1e3).toLocaleString()}function o(e){return e?new Date(e*1e3).toLocaleDateString(void 0,{month:`short`,day:`numeric`,year:`numeric`}):``}function s(e){if(!e)return`Never`;let t=new Date().getTime()-e.getTime(),n=Math.floor(t/(1e3*60)),r=Math.floor(t/(1e3*60*60)),i=Math.floor(t/(1e3*60*60*24)),a=Math.floor(i/365);return n<60?`${n}m ago`:r<24?`${r}h ago`:i<365?`${i}d ago`:`${a}y ago`}function c(e){if(e===0)return`0 B`;let t=[`B`,`KB`,`MB`,`GB`],n=Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1),r=e/1024**n;return`${r<10?r.toFixed(1):Math.round(r)} ${t[n]}`}function l(e){return`${e.toFixed(1)} km`}function u(e){return`${e.slice(0,4)}...${e.slice(-4)}`}function d(e){return e?e.length<=16?e:`${e.slice(0,8)}...${e.slice(-8)}`:`No key`}export{e as a,s as c,d,u as i,a as l,o as n,r as o,l as r,t as s,c as t,i as u};
@@ -0,0 +1 @@
function e(e){return e==null?`N/A`:`${e} dBm`}function t(e){return e==null?`N/A`:`${e.toFixed(1)} dB`}var n={0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`};function r(e){return e==null?`Unknown`:n[e]??`Unknown`}function i(e){switch(e){case 2:return{text:`Direct`,bgColor:`bg-accent-green/opacity-light`,borderColor:`border-accent-green/opacity-medium`,textColor:`text-accent-green`};case 3:return{text:`Transport Direct`,bgColor:`bg-accent-green/opacity-medium`,borderColor:`border-accent-green/opacity-medium`,textColor:`text-accent-green`};case 1:return{text:`Flood`,bgColor:`bg-accent-amber/opacity-medium`,borderColor:`border-accent-amber/opacity-medium`,textColor:`text-accent-amber`};case 0:return{text:`Transport Flood`,bgColor:`bg-accent-orange/opacity-medium`,borderColor:`border-accent-orange/opacity-medium`,textColor:`text-accent-orange`};default:return{text:`Unknown`,bgColor:`bg-background-mute`,borderColor:`border-stroke-subtle`,textColor:`text-content-muted`}}}function a(e){return new Date(e*1e3).toLocaleString()}function o(e){return e?new Date(e*1e3).toLocaleDateString(void 0,{month:`short`,day:`numeric`,year:`numeric`}):``}function s(e){if(!e)return`Never`;let t=new Date().getTime()-e.getTime(),n=Math.floor(t/(1e3*60)),r=Math.floor(t/(1e3*60*60)),i=Math.floor(t/(1e3*60*60*24)),a=Math.floor(i/365);return n<60?`${n}m ago`:r<24?`${r}h ago`:i<365?`${i}d ago`:`${a}y ago`}function c(e){if(e===0)return`0 B`;let t=[`B`,`KB`,`MB`,`GB`],n=Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1),r=e/1024**n;return`${r<10?r.toFixed(1):Math.round(r)} ${t[n]}`}function l(e){return`${e.toFixed(1)} km`}function u(e){return`${e.slice(0,4)}...${e.slice(-4)}`}function d(e){return e?e.length<=16?e:`${e.slice(0,8)}...${e.slice(-8)}`:`No key`}export{e as a,s as c,d,u as i,a as l,o as n,r as o,l as r,t as s,c as t,i as u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./packets-DVGync2A.js";export{e as usePacketStore};

Some files were not shown because too many files have changed in this diff Show More