Merge pull request #1 from agessaman/dev-companion-v2-cleanup-multibyte-paths

Enhance RepeaterHandler with multi-byte hash support and identity updates
This commit is contained in:
Adam Gessaman
2026-03-05 16:34:40 -08:00
committed by GitHub
38 changed files with 511 additions and 170 deletions
+43 -29
View File
@@ -19,7 +19,7 @@ from pymc_core.protocol.constants import (
ROUTE_TYPE_TRANSPORT_DIRECT,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils, PathUtils
from repeater.airtime import AirtimeManager
from repeater.data_acquisition import StorageCollector
@@ -36,11 +36,12 @@ class RepeaterHandler(BaseHandler):
return 0xFF # Special marker (not a real payload type)
def __init__(self, config: dict, dispatcher, local_hash: int, send_advert_func=None):
def __init__(self, config: dict, dispatcher, local_hash: int, *, local_hash_bytes=None, send_advert_func=None):
self.config = config
self.dispatcher = dispatcher
self.local_hash = local_hash
self.local_hash_bytes = local_hash_bytes or bytes([local_hash])
self.send_advert_func = send_advert_func
self.airtime_mgr = AirtimeManager(config)
self.seen_packets = OrderedDict()
@@ -132,7 +133,8 @@ class RepeaterHandler(BaseHandler):
tx_delay_ms = 0.0
drop_reason = None
original_path = list(packet.path) if packet.path else []
original_path_hashes = packet.get_path_hashes_hex()
path_hash_size = packet.get_path_hash_size()
# Process for forwarding (skip if in monitor mode or if this is a local transmission)
result = (
@@ -140,7 +142,7 @@ class RepeaterHandler(BaseHandler):
if (monitor_mode or local_transmission)
else self.process_packet(processed_packet, snr)
)
forwarded_path = None
forwarded_path_hashes = None
# For local transmissions, create a direct transmission result
if local_transmission and not monitor_mode:
@@ -149,15 +151,15 @@ class RepeaterHandler(BaseHandler):
# Calculate transmission delay for local packets
delay = self._calculate_tx_delay(packet, snr)
result = (packet, delay)
forwarded_path = list(packet.path) if packet.path else []
forwarded_path_hashes = packet.get_path_hashes_hex()
logger.debug(f"Local transmission: calculated delay {delay:.3f}s")
if result:
fwd_pkt, delay = result
tx_delay_ms = delay * 1000.0
# Capture the forwarded path (after modification)
forwarded_path = list(fwd_pkt.path) if fwd_pkt.path else []
forwarded_path_hashes = fwd_pkt.get_path_hashes_hex()
# Check duty-cycle before scheduling TX
airtime_ms = self.airtime_mgr.calculate_airtime(fwd_pkt.get_raw_length())
@@ -272,15 +274,14 @@ class RepeaterHandler(BaseHandler):
drop_reason = "Duplicate"
path_hash = None
display_path = (
original_path if original_path else (list(packet.path) if packet.path else [])
display_hashes = (
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
)
if display_path and len(display_path) > 0:
# Format path as array of uppercase hex bytes
path_bytes = [f"{b:02X}" for b in display_path[:8]] # First 8 bytes max
if len(display_path) > 8:
path_bytes.append("...")
path_hash = "[" + ", ".join(path_bytes) + "]"
if display_hashes:
display = display_hashes[:8]
if len(display_hashes) > 8:
display = list(display) + ["..."]
path_hash = "[" + ", ".join(display) + "]"
src_hash = None
dst_hash = None
@@ -326,10 +327,9 @@ class RepeaterHandler(BaseHandler):
"path_hash": path_hash,
"src_hash": src_hash,
"dst_hash": dst_hash,
"original_path": ([f"{b:02X}" for b in original_path] if original_path else None),
"forwarded_path": (
[f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None
),
"original_path": original_path_hashes or None,
"forwarded_path": forwarded_path_hashes,
"path_hash_size": path_hash_size,
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
"lbt_attempts": lbt_attempts if transmitted else 0,
"lbt_backoff_delays_ms": (
@@ -418,10 +418,11 @@ class RepeaterHandler(BaseHandler):
return "Global flood policy disabled"
if route_type == ROUTE_TYPE_DIRECT:
if not packet.path or len(packet.path) == 0:
hash_size = packet.get_path_hash_size()
if not packet.path or len(packet.path) < hash_size:
return "Direct: no path"
next_hop = packet.path[0]
if next_hop != self.local_hash:
next_hop = bytes(packet.path[:hash_size])
if next_hop != self.local_hash_bytes[:hash_size]:
return "Direct: not for us"
# Default reason
@@ -585,8 +586,17 @@ class RepeaterHandler(BaseHandler):
elif not isinstance(packet.path, bytearray):
packet.path = bytearray(packet.path)
packet.path.append(self.local_hash)
packet.path_len = len(packet.path)
hash_size = packet.get_path_hash_size()
hop_count = packet.get_path_hash_count()
# Check path won't exceed MAX_PATH_SIZE after append
if (hop_count + 1) * hash_size > MAX_PATH_SIZE:
packet.drop_reason = "Path would exceed MAX_PATH_SIZE"
return None
# Append hash_size bytes from our public key prefix
packet.path.extend(self.local_hash_bytes[:hash_size])
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count + 1)
self.mark_seen(packet)
@@ -594,13 +604,16 @@ class RepeaterHandler(BaseHandler):
def direct_forward(self, packet: Packet) -> Optional[Packet]:
hash_size = packet.get_path_hash_size()
hop_count = packet.get_path_hash_count()
# Check if we're the next hop
if not packet.path or len(packet.path) == 0:
if not packet.path or len(packet.path) < hash_size:
packet.drop_reason = "Direct: no path"
return None
next_hop = packet.path[0]
if next_hop != self.local_hash:
next_hop = bytes(packet.path[:hash_size])
if next_hop != self.local_hash_bytes[:hash_size]:
packet.drop_reason = "Direct: not for us"
return None
@@ -610,8 +623,9 @@ class RepeaterHandler(BaseHandler):
return None
original_path = list(packet.path)
packet.path = bytearray(packet.path[1:])
packet.path_len = len(packet.path)
# Remove first hash entry (hash_size bytes)
packet.path = bytearray(packet.path[hash_size:])
packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1)
self.mark_seen(packet)
+1
View File
@@ -51,6 +51,7 @@ class IdentityManager:
"name": name,
"type": id_type,
"address": identity.get_address_bytes().hex() if identity else "N/A",
"public_key": identity.get_public_key().hex() if identity else None,
}
)
return identities
+151 -1
View File
@@ -124,6 +124,7 @@ class RepeaterDaemon:
pubkey = local_identity.get_public_key()
self.local_hash = pubkey[0]
self.local_hash_bytes = bytes(pubkey[:3])
logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}")
local_hash_hex = f"0x{self.local_hash:02x}"
@@ -135,7 +136,9 @@ class RepeaterDaemon:
self.dispatcher._is_own_packet = lambda pkt: False
self.repeater_handler = RepeaterHandler(
self.config, self.dispatcher, self.local_hash, send_advert_func=self.send_advert
self.config, self.dispatcher, self.local_hash,
local_hash_bytes=self.local_hash_bytes,
send_advert_func=self.send_advert,
)
# Create router
@@ -508,6 +511,153 @@ class RepeaterDaemon:
except Exception as e:
logger.error(f"Failed to load companion '{name}': {e}", exc_info=True)
async def add_companion_from_config(self, comp_config: dict) -> None:
"""
Load a single companion from config and register it (hot-reload).
Creates RepeaterCompanionBridge, CompanionFrameServer, starts the server,
and registers with identity_manager. Raises on error.
"""
from pymc_core import LocalIdentity
from pymc_core.companion.models import Channel
from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge
from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET
name = comp_config.get("name")
identity_key = comp_config.get("identity_key")
settings = comp_config.get("settings") or {}
if not name or not identity_key:
raise ValueError("Companion config missing name or identity_key")
if isinstance(identity_key, str):
try:
identity_key_bytes = bytes.fromhex(identity_key)
except ValueError as e:
raise ValueError(f"Companion '{name}' identity_key invalid hex: {e}") from e
elif isinstance(identity_key, bytes):
identity_key_bytes = identity_key
else:
raise ValueError(f"Companion '{name}' identity_key has unknown type")
if len(identity_key_bytes) not in (32, 64):
raise ValueError(
f"Companion '{name}' identity_key must be 32 bytes (hex) or 64 bytes (MeshCore firmware key)"
)
# Already registered?
if name in self.identity_manager.named_identities:
raise ValueError(f"Companion '{name}' is already registered")
identity = LocalIdentity(seed=identity_key_bytes)
pubkey = identity.get_public_key()
companion_hash = pubkey[0]
companion_hash_str = f"0x{companion_hash:02x}"
if companion_hash in self.companion_bridges:
raise ValueError(f"Companion with hash 0x{companion_hash:02x} already loaded")
sqlite_handler = None
if self.repeater_handler and self.repeater_handler.storage:
sqlite_handler = self.repeater_handler.storage.sqlite_handler
radio_config = (
self.repeater_handler.radio_config
if self.repeater_handler
else self.config.get("radio", {})
)
node_name = settings.get("node_name", name)
tcp_port = settings.get("tcp_port", 5000)
bind_address = settings.get("bind_address", "0.0.0.0")
bridge = RepeaterCompanionBridge(
identity=identity,
packet_injector=self.router.inject_packet,
node_name=node_name,
radio_config=radio_config,
sqlite_handler=sqlite_handler,
companion_hash=companion_hash_str,
)
if sqlite_handler:
contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str)
if contact_rows:
records = []
for row in contact_rows:
d = dict(row)
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
channel_rows = sqlite_handler.companion_load_channels(companion_hash_str)
for row in channel_rows:
s = row.get("secret", b"")
if isinstance(s, bytes):
raw = s
elif isinstance(s, (bytearray, memoryview)):
raw = bytes(s)
elif s:
raw = bytes.fromhex(s if isinstance(s, str) else str(s))
else:
raw = b""
if len(raw) < 32:
raw = raw + b"\x00" * (32 - len(raw))
elif len(raw) > 32:
raw = raw[:32]
ch = Channel(name=row.get("name", ""), secret=raw)
bridge.channels.set(row.get("channel_idx", 0), ch)
for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str):
from pymc_core.companion.models import QueuedMessage
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
)
if bridge.get_channel(0) is None:
bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET)
self.companion_bridges[companion_hash] = bridge
frame_server = CompanionFrameServer(
bridge=bridge,
companion_hash=companion_hash_str,
port=tcp_port,
bind_address=bind_address,
sqlite_handler=sqlite_handler,
local_hash=self.local_hash,
stats_getter=self._get_companion_stats,
control_handler=(
self.discovery_helper.control_handler if self.discovery_helper else None
),
)
await frame_server.start()
self.companion_frame_servers.append(frame_server)
self.identity_manager.register_identity(
name=name,
identity=identity,
config=comp_config,
identity_type="companion",
)
logger.info(
f"Hot-reload: Loaded companion '{name}': hash=0x{companion_hash:02x}, "
f"port={tcp_port}, bind={bind_address}"
)
async def _on_raw_rx_for_companions(self, data: bytes, rssi: int, snr: float) -> None:
"""Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients."""
servers = getattr(self, "companion_frame_servers", [])
+228 -54
View File
@@ -1234,9 +1234,9 @@ class APIEndpoints:
self.config["radio"]["cad"]["min_threshold"] = min_val
config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml")
saved, err = self.config_manager.save_to_file()
saved = self.config_manager.save_to_file()
if not saved:
return self._error(err or "Failed to save configuration to file")
return self._error("Failed to save configuration to file")
logger.info(
f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%"
@@ -1766,14 +1766,14 @@ class APIEndpoints:
# Update the configuration file using ConfigManager
try:
saved, err = self.config_manager.save_to_file()
saved = self.config_manager.save_to_file()
if saved:
logger.info(
f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}"
)
else:
logger.error(f"Failed to save global flood policy to file: {err}")
return self._error(err or "Failed to save configuration to file")
logger.error("Failed to save global flood policy to file")
return self._error("Failed to save configuration to file")
except Exception as e:
logger.error(f"Failed to save global flood policy to file: {e}")
return self._error(f"Failed to save configuration to file: {e}")
@@ -1961,7 +1961,7 @@ class APIEndpoints:
identities_config = self.config.get("identities", {})
room_servers = identities_config.get("room_servers") or []
# Enhance with config data
# Enhance with config data (room servers)
configured = []
for room_config in room_servers:
name = room_config.get("name")
@@ -1988,12 +1988,46 @@ class APIEndpoints:
}
)
# Configured companions (same pattern as room servers)
companions = identities_config.get("companions") or []
configured_companions = []
for comp_config in companions:
name = comp_config.get("name")
identity_key = comp_config.get("identity_key", "")
settings = comp_config.get("settings", {})
matching = next(
(
r
for r in registered_identities
if r["name"] == f"companion:{name}"
),
None,
)
configured_companions.append(
{
"name": name,
"type": "companion",
"identity_key": (
identity_key[:16] + "..." if len(identity_key) > 16 else identity_key
),
"identity_key_length": len(identity_key),
"settings": settings,
"hash": matching["hash"] if matching else None,
"public_key": matching.get("public_key") if matching else None,
"registered": matching is not None,
}
)
return self._success(
{
"registered": registered_identities,
"configured": configured,
"configured_companions": configured_companions,
"total_registered": len(registered_identities),
"total_configured": len(configured),
"total_configured_companions": len(configured_companions),
}
)
@@ -2019,14 +2053,17 @@ class APIEndpoints:
identities_config = self.config.get("identities", {})
room_servers = identities_config.get("room_servers") or []
companions = identities_config.get("companions") or []
# Find the identity in config
# Find the identity in config (room servers first, then companions)
identity_config = next((r for r in room_servers if r.get("name") == name), None)
if identity_config is None:
identity_config = next((c for c in companions if c.get("name") == name), None)
if not identity_config:
return self._error(f"Identity '{name}' not found")
# Get runtime info if available
# Get runtime info if available (identity_manager uses name for both types)
if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"):
identity_manager = self.daemon_instance.identity_manager
runtime_info = identity_manager.get_identity_by_name(name)
@@ -2087,11 +2124,18 @@ class APIEndpoints:
if not name:
return self._error("Missing required field: name")
# Validate passwords are different if both provided
admin_pw = settings.get("admin_password")
guest_pw = settings.get("guest_password")
if admin_pw and guest_pw and admin_pw == guest_pw:
return self._error("admin_password and guest_password must be different")
# Validate identity type
if identity_type not in ["room_server", "companion"]:
return self._error(
f"Invalid identity type: {identity_type}. Only 'room_server' and 'companion' are supported."
)
# Room server: validate passwords are different if both provided
if identity_type == "room_server":
admin_pw = settings.get("admin_password")
guest_pw = settings.get("guest_password")
if admin_pw and guest_pw and admin_pw == guest_pw:
return self._error("admin_password and guest_password must be different")
# Auto-generate identity key if not provided
key_was_generated = False
@@ -2106,38 +2150,58 @@ class APIEndpoints:
logger.error(f"Failed to auto-generate identity key: {gen_error}")
return self._error(f"Failed to auto-generate identity key: {gen_error}")
# Validate identity type
if identity_type not in ["room_server"]:
return self._error(
f"Invalid identity type: {identity_type}. Only 'room_server' is supported."
)
# Check if identity already exists
identities_config = self.config.get("identities", {})
room_servers = identities_config.get("room_servers") or []
if any(r.get("name") == name for r in room_servers):
return self._error(f"Identity with name '{name}' already exists")
# Create new identity config
new_identity = {
"name": name,
"identity_key": identity_key,
"type": identity_type,
"settings": settings,
}
# Add to config
room_servers.append(new_identity)
if "identities" not in self.config:
self.config["identities"] = {}
self.config["identities"]["room_servers"] = room_servers
if identity_type == "companion":
# Companion: validate key length (32 or 64 bytes hex), normalize settings
if identity_key:
try:
key_bytes = bytes.fromhex(identity_key)
if len(key_bytes) not in (32, 64):
return self._error(
"Companion identity_key must be 32 or 64 bytes (64 or 128 hex chars)"
)
except ValueError:
return self._error("Companion identity_key must be a valid hex string")
companions = identities_config.get("companions") or []
if any(c.get("name") == name for c in companions):
return self._error(f"Companion with name '{name}' already exists")
comp_settings = {
"node_name": settings.get("node_name") or name,
"tcp_port": settings.get("tcp_port", 5000),
"bind_address": settings.get("bind_address", "0.0.0.0"),
}
new_identity = {
"name": name,
"identity_key": identity_key,
"type": identity_type,
"settings": comp_settings,
}
companions.append(new_identity)
self.config["identities"]["companions"] = companions
else:
# Room server
room_servers = identities_config.get("room_servers") or []
if any(r.get("name") == name for r in room_servers):
return self._error(f"Identity with name '{name}' already exists")
new_identity = {
"name": name,
"identity_key": identity_key,
"type": identity_type,
"settings": settings,
}
room_servers.append(new_identity)
self.config["identities"]["room_servers"] = room_servers
# Save to file
saved, err = self.config_manager.save_to_file()
saved = self.config_manager.save_to_file()
if not saved:
return self._error(err or "Failed to save configuration to file")
return self._error("Failed to save configuration to file")
logger.info(
f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}"
@@ -2145,7 +2209,7 @@ class APIEndpoints:
# Hot reload - register identity immediately
registration_success = False
if self.daemon_instance:
if identity_type == "room_server" and self.daemon_instance:
try:
from pymc_core import LocalIdentity
@@ -2188,11 +2252,35 @@ class APIEndpoints:
f"Failed to hot reload identity {name}: {reg_error}", exc_info=True
)
message = (
f"Identity '{name}' created successfully and activated immediately!"
if registration_success
else f"Identity '{name}' created successfully. Restart required to activate."
)
elif identity_type == "companion" and self.daemon_instance and self.event_loop:
try:
import asyncio
future = asyncio.run_coroutine_threadsafe(
self.daemon_instance.add_companion_from_config(new_identity),
self.event_loop,
)
future.result(timeout=15)
registration_success = True
logger.info(f"Hot reload: Companion '{name}' activated immediately")
except Exception as comp_error:
logger.warning(
f"Hot reload companion '{name}' failed: {comp_error}. Restart required to activate.",
exc_info=True,
)
if identity_type == "companion":
message = (
f"Companion '{name}' created successfully and activated immediately!"
if registration_success
else f"Companion '{name}' created successfully. Restart required to activate."
)
else:
message = (
f"Identity '{name}' created successfully and activated immediately!"
if registration_success
else f"Identity '{name}' created successfully. Restart required to activate."
)
if key_was_generated:
message += " Identity key was auto-generated."
@@ -2242,10 +2330,63 @@ class APIEndpoints:
if not name:
return self._error("Missing required field: name")
identities_config = self.config.get("identities", {})
room_servers = identities_config.get("room_servers") or []
identity_type = data.get("type", "room_server")
if identity_type not in ["room_server", "companion"]:
return self._error(
f"Invalid identity type: {identity_type}. Only 'room_server' and 'companion' are supported."
)
# Find the identity
identities_config = self.config.get("identities", {})
if identity_type == "companion":
companions = identities_config.get("companions") or []
identity_index = next(
(i for i, c in enumerate(companions) if c.get("name") == name), None
)
if identity_index is None:
return self._error(f"Companion '{name}' not found")
identity = companions[identity_index]
if "new_name" in data:
new_name = data["new_name"]
if any(
c.get("name") == new_name
for i, c in enumerate(companions)
if i != identity_index
):
return self._error(f"Companion with name '{new_name}' already exists")
identity["name"] = new_name
if "identity_key" in data and data["identity_key"]:
new_key = data["identity_key"]
if "..." not in new_key:
try:
key_bytes = bytes.fromhex(new_key)
if len(key_bytes) in (32, 64):
identity["identity_key"] = new_key
logger.info(f"Updated identity_key for companion '{name}'")
except ValueError:
pass
if "settings" in data:
if "settings" not in identity:
identity["settings"] = {}
# Only allow companion settings
for k, v in data["settings"].items():
if k in ("node_name", "tcp_port", "bind_address"):
identity["settings"][k] = v
companions[identity_index] = identity
self.config["identities"]["companions"] = companions
saved = self.config_manager.save_to_file()
if not saved:
return self._error("Failed to save configuration to file")
logger.info(f"Updated companion: {name}")
message = f"Companion '{name}' updated successfully. Restart required to apply changes."
return self._success(identity, message=message)
# Room server path
room_servers = identities_config.get("room_servers") or []
identity_index = next(
(i for i, r in enumerate(room_servers) if r.get("name") == name), None
)
@@ -2298,9 +2439,9 @@ class APIEndpoints:
room_servers[identity_index] = identity
self.config["identities"]["room_servers"] = room_servers
saved, err = self.config_manager.save_to_file()
saved = self.config_manager.save_to_file()
if not saved:
return self._error(err or "Failed to save configuration to file")
return self._error("Failed to save configuration to file")
logger.info(f"Updated identity: {name}")
@@ -2380,9 +2521,9 @@ class APIEndpoints:
@cherrypy.expose
@cherrypy.tools.json_out()
def delete_identity(self, name=None):
def delete_identity(self, name=None, type=None):
"""
DELETE /api/delete_identity?name=<name> - Delete an identity
DELETE /api/delete_identity?name=<name>&type=<room_server|companion> - Delete an identity
"""
# Enable CORS for this endpoint only if configured
self._set_cors_headers()
@@ -2399,7 +2540,40 @@ class APIEndpoints:
if not name:
return self._error("Missing name parameter")
identity_type = (type or "room_server").lower()
if identity_type not in ["room_server", "companion"]:
return self._error(
f"Invalid type: {type}. Use 'room_server' or 'companion'."
)
identities_config = self.config.get("identities", {})
if identity_type == "companion":
companions = identities_config.get("companions") or []
initial_count = len(companions)
companions = [c for c in companions if c.get("name") != name]
if len(companions) == initial_count:
return self._error(f"Companion '{name}' not found")
self.config["identities"]["companions"] = companions
saved = self.config_manager.save_to_file()
if not saved:
return self._error("Failed to save configuration to file")
logger.info(f"Deleted companion: {name}")
unregister_success = False
if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"):
identity_manager = self.daemon_instance.identity_manager
if name in identity_manager.named_identities:
del identity_manager.named_identities[name]
logger.info(f"Removed companion {name} from named_identities")
unregister_success = True
message = (
f"Companion '{name}' deleted successfully and deactivated immediately!"
if unregister_success
else f"Companion '{name}' deleted successfully. Restart required to fully remove."
)
return self._success({"name": name}, message=message)
# Room server path
room_servers = identities_config.get("room_servers") or []
# Find and remove the identity
@@ -2412,9 +2586,9 @@ class APIEndpoints:
# Update config
self.config["identities"]["room_servers"] = room_servers
saved, err = self.config_manager.save_to_file()
saved = self.config_manager.save_to_file()
if not saved:
return self._error(err or "Failed to save configuration to file")
return self._error("Failed to save configuration to file")
logger.info(f"Deleted identity: {name}")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-sHch0610.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={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"},x={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(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("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)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("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)]))):(l(),n("svg",C,e[7]||(e[7]=[t("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),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("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"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-DyUIpN7m.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={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"},x={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(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("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)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("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)]))):(l(),n("svg",C,e[7]||(e[7]=[t("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),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("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"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{a as e,b as r,i as o,p as n}from"./index-sHch0610.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<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">pyMC 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 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><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{d as default};
import{a as e,b as r,i as o,p as n}from"./index-DyUIpN7m.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<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">pyMC 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 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><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{d 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 @@
import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-DyUIpN7m.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},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"},u={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(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[r("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",b[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("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),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",u[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.plotly-chart[data-v-8daccd7e]{background:transparent!important}
@@ -1 +0,0 @@
.plotly-chart[data-v-c51a7a30]{background:transparent!important}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-sHch0610.js";/**
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-DyUIpN7m.js";/**
* Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
* @license MIT
*
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
+22 -22
View File
@@ -233,7 +233,7 @@ float cookTorranceSpecular(
float G1 = (2.0 * NdotH * VdotN) / VdotH;
float G2 = (2.0 * NdotH * LdotN) / LdotH;
float G = min(1.0, min(G1, G2));
//Distribution term
float D = beckmannDistribution(NdotH, roughness);
@@ -245,7 +245,7 @@ float cookTorranceSpecular(
}
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -393,7 +393,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -511,7 +511,7 @@ float cookTorranceSpecular(
float G1 = (2.0 * NdotH * VdotN) / VdotH;
float G2 = (2.0 * NdotH * LdotN) / LdotH;
float G = min(1.0, min(G1, G2));
//Distribution term
float D = beckmannDistribution(NdotH, roughness);
@@ -525,7 +525,7 @@ float cookTorranceSpecular(
//#pragma glslify: beckmann = require(glsl-specular-beckmann) // used in gl-surface3d
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -604,7 +604,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -639,7 +639,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -713,7 +713,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -746,7 +746,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -861,7 +861,7 @@ float beckmannSpecular(
}
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -963,7 +963,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1011,7 +1011,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1068,7 +1068,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1126,7 +1126,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1186,7 +1186,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1222,7 +1222,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1526,7 +1526,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1673,7 +1673,7 @@ float cookTorranceSpecular(
float G1 = (2.0 * NdotH * VdotN) / VdotH;
float G2 = (2.0 * NdotH * LdotN) / LdotH;
float G = min(1.0, min(G1, G2));
//Distribution term
float D = beckmannDistribution(NdotH, roughness);
@@ -1685,7 +1685,7 @@ float cookTorranceSpecular(
}
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1796,7 +1796,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1871,7 +1871,7 @@ void main() {
#define GLSLIFY 1
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1957,7 +1957,7 @@ vec4 packFloat(float v) {
}
bool outOfRange(float a, float b, float p) {
return ((p > max(a, b)) ||
return ((p > max(a, b)) ||
(p < min(a, b)));
}
@@ -1 +1 @@
import{M as x,c as s}from"./index-sHch0610.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
import{M as x,c as s}from"./index-DyUIpN7m.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-sHch0610.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dk6Oh8NN.css">
<script type="module" crossorigin src="/assets/index-DyUIpN7m.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D-3p9FIW.css">
</head>
<body>
<div id="app"></div>