API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.
Tokens are only shown once at creation. Store them securely.
From b4b44831b0e75736fc651883dd305451d00ba8e6 Mon Sep 17 00:00:00 2001
From: yellowcooln <12516003+yellowcooln@users.noreply.github.com>
Date: Wed, 24 Jun 2026 15:17:41 -0400
Subject: [PATCH 1/5] feat: integrate openHop Glass inform policy sync
---
repeater/data_acquisition/glass_handler.py | 332 ++++++++++++++++++++-
tests/test_glass_handler.py | 216 +++++++++++++-
2 files changed, 545 insertions(+), 3 deletions(-)
diff --git a/repeater/data_acquisition/glass_handler.py b/repeater/data_acquisition/glass_handler.py
index e80ebd6..96e319e 100644
--- a/repeater/data_acquisition/glass_handler.py
+++ b/repeater/data_acquisition/glass_handler.py
@@ -3,6 +3,7 @@ import hashlib
import json
import logging
import os
+import re
import ssl
import time
from datetime import datetime, timezone
@@ -12,6 +13,7 @@ from urllib import error, request
from urllib.parse import urlparse
import psutil
+import yaml
try:
import paho.mqtt.client as mqtt
@@ -19,6 +21,7 @@ except ImportError:
mqtt = None
from repeater import __version__
+from repeater.policy_engine import PolicyEngine, SUPPORTED_ACTIONS, default_policy_engine_config
from repeater.service_utils import restart_service
logger = logging.getLogger("GlassHandler")
@@ -289,7 +292,7 @@ class GlassHandler:
settings_snapshot = self._build_settings_snapshot()
location = self._extract_location_from_settings(settings_snapshot)
- return {
+ payload = {
"type": "inform",
"version": 1,
"node_name": node_name,
@@ -321,6 +324,28 @@ class GlassHandler:
"settings": settings_snapshot,
"command_results": command_results,
}
+ sensors_summary = self._collect_sensor_summary()
+ if sensors_summary is not None:
+ payload["sensors"] = sensors_summary
+ return payload
+
+ def _collect_sensor_summary(self) -> Optional[Dict[str, Any]]:
+ sensor_manager = getattr(self.daemon_instance, "sensor_manager", None)
+ if sensor_manager is None:
+ return None
+ try:
+ summary = sensor_manager.get_summary()
+ return summary if isinstance(summary, dict) else None
+ except Exception as exc:
+ logger.debug("Failed collecting sensor summary for Glass inform: %s", exc)
+ return {
+ "enabled": False,
+ "configured": 0,
+ "loaded": 0,
+ "running": False,
+ "readings": [],
+ "error": str(exc),
+ }
def _build_settings_snapshot(self) -> Dict[str, Any]:
normalized = self._normalize_for_hash(self.config)
@@ -594,6 +619,10 @@ class GlassHandler:
success, message, details = self._apply_transport_keys_sync(params)
return success, message, details
+ if action == "policy_sync":
+ success, message, details = self._apply_policy_sync(params)
+ return success, message, details
+
if action == "set_radio":
radio_values = params.get("radio", params)
if not isinstance(radio_values, dict):
@@ -717,6 +746,307 @@ class GlassHandler:
details["payload_hash"] = payload_hash
return True, f"Applied transport key sync ({details['applied_nodes']} nodes)", details
+ def _apply_policy_sync(
+ self,
+ params: Dict[str, Any],
+ ) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
+ if not isinstance(params, dict):
+ return False, "policy_sync params must be an object", None
+ incoming_policy = params.get("policy")
+ if not isinstance(incoming_policy, dict):
+ return False, "policy_sync payload must include a policy object", None
+
+ mode = str(params.get("mode", "replace") or "replace").lower().strip()
+ if mode not in ("replace", "patch"):
+ return False, f"Unsupported policy_sync mode: {mode}", None
+ validate_only = bool(params.get("validate_only", False))
+
+ existing_doc, _ = self._load_policy_document()
+ existing_policy = self._normalize_policy_engine(existing_doc.get("policy_engine", {}))
+ if mode == "patch":
+ policy_engine_cfg = dict(existing_policy)
+ self._deep_merge(policy_engine_cfg, incoming_policy)
+ else:
+ policy_engine_cfg = incoming_policy
+
+ groups_cfg = params.get("groups", existing_doc.get("groups", {}))
+ doc_to_apply = {
+ "policy_engine": self._normalize_policy_engine(policy_engine_cfg),
+ "groups": self._normalize_policy_groups(groups_cfg),
+ }
+ doc_to_apply = self._sync_policy_engine_objects_from_groups(doc_to_apply)
+
+ try:
+ self._validate_policy_engine(doc_to_apply.get("policy_engine", {}))
+ PolicyEngine(doc_to_apply.get("policy_engine", {}))
+ except Exception as exc:
+ return False, f"Invalid policy: {exc}", None
+
+ details = self._policy_sync_details(doc_to_apply, mode=mode, validate_only=validate_only)
+ if validate_only:
+ return True, "Policy validated", details
+
+ try:
+ self._write_policy_document(doc_to_apply)
+ self._apply_policy_runtime(doc_to_apply.get("policy_engine", {}))
+ except Exception as exc:
+ return False, f"Policy sync failed: {exc}", None
+ return True, "Policy synchronized", details
+
+ def _policy_sync_details(
+ self,
+ doc: Dict[str, Any],
+ *,
+ mode: str,
+ validate_only: bool,
+ ) -> Dict[str, Any]:
+ policy_engine_cfg = doc.get("policy_engine", {}) if isinstance(doc, dict) else {}
+ rules = policy_engine_cfg.get("rules", []) if isinstance(policy_engine_cfg, dict) else []
+ return {
+ "policy_file": self._get_policy_file_path(),
+ "mode": mode,
+ "validate_only": validate_only,
+ "rule_count": len(rules) if isinstance(rules, list) else 0,
+ "enabled": bool(policy_engine_cfg.get("enabled", False))
+ if isinstance(policy_engine_cfg, dict)
+ else False,
+ "default_action": str(policy_engine_cfg.get("default_action", "allow"))
+ if isinstance(policy_engine_cfg, dict)
+ else "allow",
+ }
+
+ def _get_policy_file_path(self) -> str:
+ policy_cfg = self.config.get("policy", {}) if isinstance(self.config, dict) else {}
+ policy_file = policy_cfg.get("policy_file", "policy.yaml")
+ if os.path.isabs(str(policy_file)):
+ return str(policy_file)
+ config_path = getattr(self.config_manager, "config_path", None) or self.config.get(
+ "config_path", "/etc/pymc_repeater/config.yaml"
+ )
+ config_dir = os.path.dirname(os.path.abspath(str(config_path)))
+ return os.path.abspath(os.path.join(config_dir, str(policy_file)))
+
+ @staticmethod
+ def _default_policy_document() -> Dict[str, Any]:
+ return {
+ "policy_engine": default_policy_engine_config(),
+ "groups": {"channel_hashes": [], "pubkeys": []},
+ }
+
+ def _load_policy_document(self) -> Tuple[Dict[str, Any], bool]:
+ path = self._get_policy_file_path()
+ if not os.path.exists(path):
+ return self._default_policy_document(), False
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ data = yaml.safe_load(f) or {}
+ if not isinstance(data, dict):
+ return self._default_policy_document(), False
+ if "policy_engine" not in data:
+ return {
+ "policy_engine": data,
+ "groups": self._default_policy_document()["groups"],
+ }, True
+ if not isinstance(data.get("policy_engine"), dict):
+ return self._default_policy_document(), False
+ if not isinstance(data.get("groups"), dict):
+ data["groups"] = self._default_policy_document()["groups"]
+ return data, True
+ except Exception as exc:
+ logger.error("Failed to load policy file %s: %s", path, exc)
+ return self._default_policy_document(), False
+
+ def _write_policy_document(self, doc: Dict[str, Any]) -> None:
+ policy_path = self._get_policy_file_path()
+ os.makedirs(os.path.dirname(policy_path), exist_ok=True)
+ with open(policy_path, "w", encoding="utf-8") as f:
+ yaml.safe_dump(
+ doc,
+ f,
+ default_flow_style=False,
+ sort_keys=False,
+ allow_unicode=True,
+ width=1000000,
+ )
+
+ @staticmethod
+ def _normalize_policy_engine(engine_cfg: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(engine_cfg, dict):
+ engine_cfg = {}
+ return {
+ "enabled": bool(engine_cfg.get("enabled", False)),
+ "default_action": str(engine_cfg.get("default_action", "allow")),
+ "rules": engine_cfg.get("rules") if isinstance(engine_cfg.get("rules"), list) else [],
+ "objects": (
+ engine_cfg.get("objects") if isinstance(engine_cfg.get("objects"), dict) else {}
+ ),
+ }
+
+ def _apply_policy_runtime(self, policy_engine_cfg: Dict[str, Any]) -> None:
+ self.config["policy_engine"] = policy_engine_cfg
+ self.config["policy_file_path"] = self._get_policy_file_path()
+ repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
+ if repeater_handler is not None:
+ repeater_handler.policy_engine = PolicyEngine.from_runtime_config(self.config)
+
+ def _sync_policy_engine_objects_from_groups(self, doc: Dict[str, Any]) -> Dict[str, Any]:
+ policy_engine_cfg = self._normalize_policy_engine(doc.get("policy_engine", {}))
+ groups_cfg = self._normalize_policy_groups(doc.get("groups", {}))
+ objects = policy_engine_cfg.get("objects", {})
+ if not isinstance(objects, dict):
+ objects = {}
+ objects.update(self._policy_objects_from_groups(groups_cfg))
+ policy_engine_cfg["objects"] = objects
+ doc["policy_engine"] = policy_engine_cfg
+ doc["groups"] = groups_cfg
+ return doc
+
+ def _normalize_policy_groups(self, groups_cfg: Dict[str, Any]) -> Dict[str, Any]:
+ normalized = {"channel_hashes": [], "pubkeys": []}
+ if not isinstance(groups_cfg, dict):
+ return normalized
+ for kind in ("channel_hashes", "pubkeys"):
+ source_groups = groups_cfg.get(kind)
+ if not isinstance(source_groups, list):
+ continue
+ seen_group_ids = set()
+ for idx, group in enumerate(source_groups):
+ if not isinstance(group, dict):
+ continue
+ group_id = self._slugify_policy_id(
+ group.get("id") or group.get("name") or group.get("friendly_name"),
+ f"{kind}_{idx + 1}",
+ )
+ if group_id in seen_group_ids:
+ continue
+ seen_group_ids.add(group_id)
+ entries = []
+ seen_entry_ids = set()
+ for ent_idx, entry in enumerate(group.get("entries") or []):
+ if not isinstance(entry, dict):
+ continue
+ try:
+ entry_value = self._normalize_policy_entry_value(kind, entry.get("value"))
+ except Exception as exc:
+ logger.warning(
+ "Skipping invalid policy entry at index %d: %s", ent_idx, exc
+ )
+ continue
+ entry_id = self._slugify_policy_id(
+ entry.get("id")
+ or entry.get("name")
+ or entry.get("friendly_name")
+ or entry_value,
+ f"entry_{ent_idx + 1}",
+ )
+ if entry_id in seen_entry_ids:
+ continue
+ seen_entry_ids.add(entry_id)
+ entries.append(
+ {
+ "id": entry_id,
+ "friendly_name": str(
+ entry.get("friendly_name") or entry.get("name") or entry_id
+ ),
+ "value": entry_value,
+ }
+ )
+ normalized[kind].append(
+ {
+ "id": group_id,
+ "friendly_name": str(
+ group.get("friendly_name") or group.get("name") or group_id
+ ),
+ "description": str(group.get("description") or ""),
+ "entries": entries,
+ }
+ )
+ return normalized
+
+ @staticmethod
+ def _slugify_policy_id(value: str, fallback: str) -> str:
+ text = str(value or "").strip().lower()
+ text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
+ return text or fallback
+
+ def _normalize_policy_entry_value(self, kind: str, value: Any) -> str:
+ if kind == "pubkeys":
+ return self._normalize_pubkey_value(value)
+ if kind == "channel_hashes":
+ return self._normalize_channel_hash_value(value)
+ raise ValueError(f"Unsupported group kind: {kind}")
+
+ @staticmethod
+ def _normalize_pubkey_value(value: Any) -> str:
+ if value is None:
+ raise ValueError("pubkey value is required")
+ raw = value.hex() if isinstance(value, bytes) else str(value).strip().lower()
+ if raw.startswith("0x"):
+ raw = raw[2:]
+ raw = raw.replace(" ", "")
+ if not raw:
+ raise ValueError("pubkey value is required")
+ if not re.fullmatch(r"[0-9a-f]+", raw):
+ raise ValueError("pubkey must be hex")
+ if len(raw) % 2 != 0:
+ raise ValueError("pubkey hex length must be even")
+ return f"0x{raw}"
+
+ @staticmethod
+ def _normalize_channel_hash_value(value: Any) -> str:
+ if value is None:
+ raise ValueError("channel hash value is required")
+ if isinstance(value, int):
+ parsed = value
+ else:
+ raw = str(value).strip()
+ if not raw:
+ raise ValueError("channel hash value is required")
+ normalized_hex = raw[2:] if raw.lower().startswith("0x") else raw
+ if len(normalized_hex) in (32, 64) and re.fullmatch(r"[0-9a-fA-F]+", normalized_hex):
+ return f"0x{normalized_hex.upper()}"
+ if raw.lower().startswith("0x"):
+ parsed = int(raw, 16)
+ elif re.fullmatch(r"[0-9]+", raw):
+ parsed = int(raw, 10)
+ else:
+ parsed = int(raw, 16)
+ if parsed < 0:
+ raise ValueError("channel hash must be non-negative")
+ if parsed > 0xFF:
+ raise ValueError("channel hash must be one byte (0x00-0xFF)")
+ return f"0x{parsed:02X}"
+
+ def _policy_objects_from_groups(self, groups_cfg: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
+ channel_hash_groups = {}
+ pubkey_groups = {}
+ for group in groups_cfg.get("channel_hashes", []):
+ channel_hash_groups[group["id"]] = [
+ entry["value"] for entry in group.get("entries", [])
+ ]
+ for group in groups_cfg.get("pubkeys", []):
+ pubkey_groups[group["id"]] = [entry["value"] for entry in group.get("entries", [])]
+ return {"channel_hash_groups": channel_hash_groups, "pubkey_groups": pubkey_groups}
+
+ @staticmethod
+ def _validate_policy_engine(policy_engine_cfg: Dict[str, Any]) -> None:
+ default_action = str(policy_engine_cfg.get("default_action", "allow"))
+ if default_action not in SUPPORTED_ACTIONS:
+ raise ValueError(f"Unsupported default_action: {default_action}")
+ rules = policy_engine_cfg.get("rules", [])
+ if not isinstance(rules, list):
+ raise ValueError("rules must be a list")
+ for idx, rule in enumerate(rules):
+ if not isinstance(rule, dict):
+ raise ValueError(f"rule {idx} must be an object")
+ then_block = rule.get("then", {})
+ action = then_block.get("action") if isinstance(then_block, dict) else then_block
+ if action is None:
+ action = rule.get("action", "allow")
+ action = str(action or "allow")
+ if action not in SUPPORTED_ACTIONS:
+ raise ValueError(f"Unsupported rule action at index {idx}: {action}")
+
def _apply_cert_renewal(self, response: Dict[str, Any]) -> Tuple[bool, str]:
client_cert = response.get("client_cert")
client_key = response.get("client_key")
diff --git a/tests/test_glass_handler.py b/tests/test_glass_handler.py
index d99e56c..3d81075 100644
--- a/tests/test_glass_handler.py
+++ b/tests/test_glass_handler.py
@@ -4,6 +4,7 @@ import json
import time
from pathlib import Path
import pytest
+import yaml
_MODULE_PATH = (
Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
@@ -22,7 +23,8 @@ class _DummyIdentity:
class _DummyConfigManager:
- def __init__(self):
+ def __init__(self, config_path="/tmp/config.yaml"):
+ self.config_path = config_path
self.calls = []
def update_and_save(self, updates, live_update=True, live_update_sections=None):
@@ -47,7 +49,10 @@ class _DummyConfigManager:
class _DummyDaemon:
def __init__(self):
self.local_identity = _DummyIdentity()
- self.repeater_handler = type("RH", (), {"start_time": time.time() - 60})()
+ self.repeater_handler = type(
+ "RH", (), {"start_time": time.time() - 60, "policy_engine": None}
+ )()
+ self.sensor_manager = None
@staticmethod
def get_stats():
@@ -69,6 +74,32 @@ class _DummyDaemon:
return True
+class _DummySensorManager:
+ def __init__(self, summary=None, error=None):
+ self.summary = summary or {
+ "enabled": True,
+ "poll_interval_seconds": 30.0,
+ "configured": 1,
+ "loaded": 1,
+ "running": True,
+ "readings": [
+ {
+ "name": "ups-main",
+ "type": "waveshare_ups_d",
+ "ok": True,
+ "timestamp": "2026-06-20T12:00:00+00:00",
+ "data": {"battery_percent": 87.5, "current_ma": 120.0},
+ }
+ ],
+ }
+ self.error = error
+
+ def get_summary(self):
+ if self.error:
+ raise self.error
+ return self.summary
+
+
class _DummyMqttClient:
def __init__(self):
self.published = []
@@ -208,6 +239,32 @@ def test_build_inform_payload_contains_expected_fields():
assert payload["command_results"][0]["command_id"] == "cmd-1"
+def test_build_inform_payload_includes_sensors_when_manager_exists():
+ config = _make_config()
+ daemon = _DummyDaemon()
+ daemon.sensor_manager = _DummySensorManager()
+ manager = _DummyConfigManager()
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ payload = asyncio.run(handler._build_inform_payload())
+
+ assert payload["sensors"]["enabled"] is True
+ assert payload["sensors"]["readings"][0]["data"]["battery_percent"] == 87.5
+
+
+def test_build_inform_payload_reports_sensor_summary_error():
+ config = _make_config()
+ daemon = _DummyDaemon()
+ daemon.sensor_manager = _DummySensorManager(error=RuntimeError("i2c unavailable"))
+ manager = _DummyConfigManager()
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ payload = asyncio.run(handler._build_inform_payload())
+
+ assert payload["sensors"]["running"] is False
+ assert "i2c unavailable" in payload["sensors"]["error"]
+
+
def test_execute_set_mode_command_updates_config():
config = _make_config()
daemon = _DummyDaemon()
@@ -224,6 +281,161 @@ def test_execute_set_mode_command_updates_config():
assert manager.calls[-1]["updates"]["repeater"]["mode"] == "monitor"
+def test_execute_policy_sync_validate_only_does_not_write_or_apply_runtime(tmp_path):
+ config = _make_config()
+ cfg_path = tmp_path / "config.yaml"
+ cfg_path.write_text("repeater: {node_name: test}\n", encoding="utf-8")
+ daemon = _DummyDaemon()
+ manager = _DummyConfigManager(config_path=str(cfg_path))
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ ok, message, details = asyncio.run(
+ handler._execute_command_action(
+ "policy_sync",
+ {
+ "policy": {
+ "enabled": True,
+ "default_action": "allow",
+ "rules": [{"id": 1, "if": {"all": []}, "then": {"action": "drop"}}],
+ },
+ "validate_only": True,
+ },
+ )
+ )
+
+ assert ok is True
+ assert message == "Policy validated"
+ assert details["validate_only"] is True
+ assert details["rule_count"] == 1
+ assert not (tmp_path / "policy.yaml").exists()
+ assert "policy_engine" not in config
+ assert daemon.repeater_handler.policy_engine is None
+
+
+def test_execute_policy_sync_replace_writes_wrapper_preserves_groups_and_applies_runtime(tmp_path):
+ config = _make_config()
+ cfg_path = tmp_path / "config.yaml"
+ cfg_path.write_text("repeater: {node_name: test}\n", encoding="utf-8")
+ policy_path = tmp_path / "policy.yaml"
+ policy_path.write_text(
+ yaml.safe_dump(
+ {
+ "policy_engine": {"enabled": False, "default_action": "allow", "rules": []},
+ "groups": {
+ "channel_hashes": [
+ {
+ "id": "ops_channels",
+ "friendly_name": "Ops Channels",
+ "entries": [{"id": "ops", "value": "0x12"}],
+ }
+ ],
+ "pubkeys": [],
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ daemon = _DummyDaemon()
+ manager = _DummyConfigManager(config_path=str(cfg_path))
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ ok, message, details = asyncio.run(
+ handler._execute_command_action(
+ "policy_sync",
+ {
+ "policy": {
+ "enabled": True,
+ "default_action": "allow",
+ "rules": [
+ {
+ "id": 7,
+ "if": {
+ "all": [
+ {
+ "field": "channel_hash",
+ "op": "in",
+ "value": "@channel_hash_groups.ops_channels",
+ }
+ ]
+ },
+ "then": {"action": "drop"},
+ }
+ ],
+ },
+ "mode": "replace",
+ },
+ )
+ )
+
+ assert ok is True
+ assert message == "Policy synchronized"
+ assert details["enabled"] is True
+ loaded = yaml.safe_load(policy_path.read_text(encoding="utf-8"))
+ assert loaded["policy_engine"]["enabled"] is True
+ assert loaded["groups"]["channel_hashes"][0]["id"] == "ops_channels"
+ assert loaded["policy_engine"]["objects"]["channel_hash_groups"]["ops_channels"] == ["0x12"]
+ assert config["policy_engine"]["enabled"] is True
+ assert daemon.repeater_handler.policy_engine is not None
+
+
+def test_execute_policy_sync_patch_preserves_unspecified_policy_fields(tmp_path):
+ config = _make_config()
+ cfg_path = tmp_path / "config.yaml"
+ cfg_path.write_text("repeater: {node_name: test}\n", encoding="utf-8")
+ policy_path = tmp_path / "policy.yaml"
+ policy_path.write_text(
+ yaml.safe_dump(
+ {
+ "policy_engine": {
+ "enabled": False,
+ "default_action": "drop",
+ "rules": [{"id": "keep", "if": {"all": []}, "then": {"action": "allow"}}],
+ "objects": {"custom": {"values": ["a"]}},
+ },
+ "groups": {"channel_hashes": [], "pubkeys": []},
+ }
+ ),
+ encoding="utf-8",
+ )
+ daemon = _DummyDaemon()
+ manager = _DummyConfigManager(config_path=str(cfg_path))
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ ok, message, _details = asyncio.run(
+ handler._execute_command_action(
+ "policy_sync",
+ {"policy": {"enabled": True}, "mode": "patch"},
+ )
+ )
+
+ assert ok is True
+ assert message == "Policy synchronized"
+ loaded = yaml.safe_load(policy_path.read_text(encoding="utf-8"))
+ assert loaded["policy_engine"]["enabled"] is True
+ assert loaded["policy_engine"]["default_action"] == "drop"
+ assert loaded["policy_engine"]["rules"][0]["id"] == "keep"
+ assert loaded["policy_engine"]["objects"]["custom"] == {"values": ["a"]}
+
+
+def test_execute_policy_sync_rejects_unsupported_mode(tmp_path):
+ config = _make_config()
+ cfg_path = tmp_path / "config.yaml"
+ cfg_path.write_text("repeater: {node_name: test}\n", encoding="utf-8")
+ daemon = _DummyDaemon()
+ manager = _DummyConfigManager(config_path=str(cfg_path))
+ handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
+
+ ok, message, details = asyncio.run(
+ handler._execute_command_action(
+ "policy_sync", {"policy": {"enabled": True}, "mode": "merge"}
+ )
+ )
+
+ assert ok is False
+ assert "Unsupported policy_sync mode" in message
+ assert details is None
+
+
def test_handle_command_response_queues_result():
config = _make_config()
daemon = _DummyDaemon()
From 44a2d2f72f98796a1dd19328cff2df30c6914a7d Mon Sep 17 00:00:00 2001
From: yellowcooln <12516003+yellowcooln@users.noreply.github.com>
Date: Wed, 24 Jun 2026 16:23:50 -0400
Subject: [PATCH 2/5] feat: add system info to hardware stats sensor
---
repeater/data_acquisition/hardware_stats.py | 29 ++++++++++++++++++++-
tests/test_sensors.py | 17 ++++++++++++
2 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/repeater/data_acquisition/hardware_stats.py b/repeater/data_acquisition/hardware_stats.py
index 8ab18f0..5740e50 100644
--- a/repeater/data_acquisition/hardware_stats.py
+++ b/repeater/data_acquisition/hardware_stats.py
@@ -12,6 +12,7 @@ except ImportError:
psutil = None
import logging
+import platform
import time
logger = logging.getLogger("HardwareStats")
@@ -57,6 +58,7 @@ class HardwareStatsCollector:
# System boot time
boot_time = psutil.boot_time()
system_uptime = now - boot_time
+ system_info = self._get_system_info()
# Temperature (if available)
temperatures = {}
@@ -96,7 +98,13 @@ class HardwareStatsCollector:
"packets_sent": net_io.packets_sent,
"packets_recv": net_io.packets_recv,
},
- "system": {"uptime": system_uptime, "boot_time": boot_time},
+ "system": {
+ "uptime": system_uptime,
+ "boot_time": boot_time,
+ "os": system_info["os"],
+ "kernel": system_info["kernel"],
+ "arch": system_info["arch"],
+ },
}
# Add temperatures if available
@@ -109,6 +117,25 @@ class HardwareStatsCollector:
logger.error(f"Error collecting hardware stats: {e}")
return {"error": str(e)}
+ @staticmethod
+ def _get_system_info(os_release_path="/etc/os-release"):
+ os_name = None
+ try:
+ with open(os_release_path, "r", encoding="utf-8") as f:
+ for line in f:
+ key, sep, value = line.partition("=")
+ if sep and key == "PRETTY_NAME":
+ os_name = value.strip().strip('"')
+ break
+ except OSError:
+ os_name = None
+
+ return {
+ "os": os_name or platform.system(),
+ "kernel": platform.release(),
+ "arch": platform.machine(),
+ }
+
def get_processes_summary(self, limit=10):
"""
Get top processes by CPU and memory usage.
diff --git a/tests/test_sensors.py b/tests/test_sensors.py
index 288677c..162bc9b 100644
--- a/tests/test_sensors.py
+++ b/tests/test_sensors.py
@@ -160,6 +160,23 @@ def test_hardware_stats_sensor_reads_from_collector(monkeypatch):
assert reading["data"] == {"cpu": {"usage_percent": 42.0}}
+def test_hardware_stats_collector_reads_os_kernel_and_arch(tmp_path, monkeypatch):
+ import repeater.data_acquisition.hardware_stats as hardware_stats_module
+
+ os_release = tmp_path / "os-release"
+ os_release.write_text('NAME="Debian GNU/Linux"\nPRETTY_NAME="Debian GNU/Linux 12"\n')
+ monkeypatch.setattr(hardware_stats_module.platform, "release", lambda: "6.8.0-test")
+ monkeypatch.setattr(hardware_stats_module.platform, "machine", lambda: "aarch64")
+
+ info = hardware_stats_module.HardwareStatsCollector._get_system_info(str(os_release))
+
+ assert info == {
+ "os": "Debian GNU/Linux 12",
+ "kernel": "6.8.0-test",
+ "arch": "aarch64",
+ }
+
+
def test_pymc_modem_sensor_reads_modem_stats(monkeypatch):
class _Response:
status = 200
From 71f4e4779db3d11408aed31d9aadf0a077e6b389 Mon Sep 17 00:00:00 2001
From: yellowcooln <12516003+yellowcooln@users.noreply.github.com>
Date: Wed, 24 Jun 2026 16:39:59 -0400
Subject: [PATCH 3/5] Bundle policy objects UI
---
...ration-0bWa9j41.js => CADCalibration-D0y4hdGc.js} | 2 +-
.../{ChartCard-Cb2qelXJ.js => ChartCard-BFYmFrZd.js} | 2 +-
...Companions-MRhz2x9w.js => Companions-CK_oCMwj.js} | 2 +-
repeater/web/html/assets/Configuration-ByF1Gi3t.js | 2 ++
repeater/web/html/assets/Configuration-G_igNSAo.js | 2 --
.../{Dashboard-BBmihs_W.js => Dashboard-D5k548o9.js} | 2 +-
...ostics-D6MlGneM.js => GPSDiagnostics-B57CStll.js} | 2 +-
...Picker-DihBA-Pb.js => LocationPicker-6R_Llv2h.js} | 2 +-
.../assets/{Login-D0k9_p5q.js => Login-VYu7fcrY.js} | 2 +-
.../assets/{Logs-azEX-k2o.js => Logs-Cg-tlHO8.js} | 2 +-
.../{Neighbors-Bo65hv30.js => Neighbors-BoW_Xjfp.js} | 2 +-
...al-BlVcpmGq.js => PacketDetailsModal-CxGQ2gz6.js} | 2 +-
...n-CtxvC3aj.js => RfHealthCorrelation-BefEiMAo.js} | 2 +-
...omServers-ulqmEoq0.js => RoomServers-D5WdDzjm.js} | 2 +-
.../{Sensors-DrPhjgSk.js => Sensors-CWPmnXpN.js} | 2 +-
.../{Sessions-S4Ts4USO.js => Sessions-Cko_VpiU.js} | 2 +-
.../assets/{Setup-DrebTokX.js => Setup-BkiGpGbD.js} | 2 +-
...SignalBars-CRFh_h3f.js => SignalBars-BykVI_Jg.js} | 2 +-
.../{Sparkline-Dvj_QJyT.js => Sparkline-B8LmSgoA.js} | 2 +-
...Statistics-whPbwjoX.js => Statistics-Dh9H9ZUe.js} | 2 +-
...stemStats-CRdWz9AA.js => SystemStats-B0BgSYux.js} | 2 +-
.../{Terminal--2VzD9Cp.js => Terminal-nw-0TJpG.js} | 2 +-
...al-DJj923Jw.js => TxPowerNoticeModal-BHmUbYRR.js} | 2 +-
.../html/assets/{api-DSaJA91r.js => api-Cb9li59f.js} | 4 ++--
...J.js => chartjs-adapter-date-fns.esm-1KMkkQou.js} | 2 +-
...taService-5Ok9aIVh.js => dataService-BBLNTLgH.js} | 2 +-
repeater/web/html/assets/dataService-BwIIJiGc.js | 1 -
repeater/web/html/assets/dataService-Cm43d8WW.js | 1 +
.../assets/{index-Cijj_ZXo.js => index-P_qB5Fk2.js} | 2 +-
.../{packets-DVGync2A.js => packets-B42-YOck.js} | 2 +-
repeater/web/html/assets/packets-CFDC6oP3.js | 1 -
repeater/web/html/assets/packets-DnEUjs3H.js | 1 +
repeater/web/html/assets/system-BEdZk0eF.js | 1 -
repeater/web/html/assets/system-C5XT5m9h.js | 1 +
.../{system-BwYDm56e.js => system-Dl0PnaAL.js} | 2 +-
repeater/web/html/assets/websocket-CUOCzToo.js | 1 +
.../{websocket-Do9cZLld.js => websocket-D_l667Rk.js} | 2 +-
repeater/web/html/assets/websocket-rOC_zSfg.js | 1 -
repeater/web/html/index.html | 12 ++++++------
39 files changed, 41 insertions(+), 41 deletions(-)
rename repeater/web/html/assets/{CADCalibration-0bWa9j41.js => CADCalibration-D0y4hdGc.js} (98%)
rename repeater/web/html/assets/{ChartCard-Cb2qelXJ.js => ChartCard-BFYmFrZd.js} (99%)
rename repeater/web/html/assets/{Companions-MRhz2x9w.js => Companions-CK_oCMwj.js} (99%)
create mode 100644 repeater/web/html/assets/Configuration-ByF1Gi3t.js
delete mode 100644 repeater/web/html/assets/Configuration-G_igNSAo.js
rename repeater/web/html/assets/{Dashboard-BBmihs_W.js => Dashboard-D5k548o9.js} (99%)
rename repeater/web/html/assets/{GPSDiagnostics-D6MlGneM.js => GPSDiagnostics-B57CStll.js} (99%)
rename repeater/web/html/assets/{LocationPicker-DihBA-Pb.js => LocationPicker-6R_Llv2h.js} (97%)
rename repeater/web/html/assets/{Login-D0k9_p5q.js => Login-VYu7fcrY.js} (98%)
rename repeater/web/html/assets/{Logs-azEX-k2o.js => Logs-Cg-tlHO8.js} (99%)
rename repeater/web/html/assets/{Neighbors-Bo65hv30.js => Neighbors-BoW_Xjfp.js} (99%)
rename repeater/web/html/assets/{PacketDetailsModal-BlVcpmGq.js => PacketDetailsModal-CxGQ2gz6.js} (99%)
rename repeater/web/html/assets/{RfHealthCorrelation-CtxvC3aj.js => RfHealthCorrelation-BefEiMAo.js} (99%)
rename repeater/web/html/assets/{RoomServers-ulqmEoq0.js => RoomServers-D5WdDzjm.js} (99%)
rename repeater/web/html/assets/{Sensors-DrPhjgSk.js => Sensors-CWPmnXpN.js} (96%)
rename repeater/web/html/assets/{Sessions-S4Ts4USO.js => Sessions-Cko_VpiU.js} (99%)
rename repeater/web/html/assets/{Setup-DrebTokX.js => Setup-BkiGpGbD.js} (99%)
rename repeater/web/html/assets/{SignalBars-CRFh_h3f.js => SignalBars-BykVI_Jg.js} (93%)
rename repeater/web/html/assets/{Sparkline-Dvj_QJyT.js => Sparkline-B8LmSgoA.js} (97%)
rename repeater/web/html/assets/{Statistics-whPbwjoX.js => Statistics-Dh9H9ZUe.js} (98%)
rename repeater/web/html/assets/{SystemStats-CRdWz9AA.js => SystemStats-B0BgSYux.js} (97%)
rename repeater/web/html/assets/{Terminal--2VzD9Cp.js => Terminal-nw-0TJpG.js} (99%)
rename repeater/web/html/assets/{TxPowerNoticeModal-DJj923Jw.js => TxPowerNoticeModal-BHmUbYRR.js} (98%)
rename repeater/web/html/assets/{api-DSaJA91r.js => api-Cb9li59f.js} (97%)
rename repeater/web/html/assets/{chartjs-adapter-date-fns.esm-Bq5A--VJ.js => chartjs-adapter-date-fns.esm-1KMkkQou.js} (99%)
rename repeater/web/html/assets/{dataService-5Ok9aIVh.js => dataService-BBLNTLgH.js} (95%)
delete mode 100644 repeater/web/html/assets/dataService-BwIIJiGc.js
create mode 100644 repeater/web/html/assets/dataService-Cm43d8WW.js
rename repeater/web/html/assets/{index-Cijj_ZXo.js => index-P_qB5Fk2.js} (99%)
rename repeater/web/html/assets/{packets-DVGync2A.js => packets-B42-YOck.js} (99%)
delete mode 100644 repeater/web/html/assets/packets-CFDC6oP3.js
create mode 100644 repeater/web/html/assets/packets-DnEUjs3H.js
delete mode 100644 repeater/web/html/assets/system-BEdZk0eF.js
create mode 100644 repeater/web/html/assets/system-C5XT5m9h.js
rename repeater/web/html/assets/{system-BwYDm56e.js => system-Dl0PnaAL.js} (96%)
create mode 100644 repeater/web/html/assets/websocket-CUOCzToo.js
rename repeater/web/html/assets/{websocket-Do9cZLld.js => websocket-D_l667Rk.js} (93%)
delete mode 100644 repeater/web/html/assets/websocket-rOC_zSfg.js
diff --git a/repeater/web/html/assets/CADCalibration-0bWa9j41.js b/repeater/web/html/assets/CADCalibration-D0y4hdGc.js
similarity index 98%
rename from repeater/web/html/assets/CADCalibration-0bWa9j41.js
rename to repeater/web/html/assets/CADCalibration-D0y4hdGc.js
index 278cafb..1c4f456 100644
--- a/repeater/web/html/assets/CADCalibration-0bWa9j41.js
+++ b/repeater/web/html/assets/CADCalibration-D0y4hdGc.js
@@ -1 +1 @@
-import{r as e}from"./chunk-DECur_0Z.js";import{C as t,T as n,U as r,_t as i,f as a,gt as o,h as s,l as c,m as l,o as u,p as d,r as f,s as p,u as m,w as ee}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l as h,t as g}from"./api-DSaJA91r.js";import{t as _}from"./system-BwYDm56e.js";import{d as v,r as y}from"./index-Cijj_ZXo.js";import{t as b}from"./plotly.min-BmxIBpZZ.js";var x=e(b(),1),te={class:`p-6 space-y-6`},ne={class:`glass-card rounded-[15px] p-6`},re={class:`flex justify-center`},ie={class:`flex gap-4`},ae=[`disabled`],oe=[`disabled`],se={class:`glass-card rounded-[15px] p-6 space-y-4`},ce={class:`text-content-primary dark:text-content-primary`},le={class:`flex items-center justify-between gap-4 px-4 bg-primary/10 border border-primary/30 rounded-lg h-[52px] overflow-hidden`},ue={key:0,class:`text-content-primary dark:text-primary text-sm`},de={key:1,class:`text-content-muted dark:text-content-muted text-sm italic`},fe={key:2,class:`text-content-primary dark:text-content-primary text-sm`},pe={key:3,class:`text-content-secondary dark:text-content-muted text-sm`},me={class:`space-y-2`},he={class:`w-full bg-white/10 rounded-full h-2`},ge={class:`text-content-secondary dark:text-content-muted text-sm`},S={class:`grid grid-cols-2 md:grid-cols-4 gap-4`},C={class:`glass-card rounded-[15px] p-4 text-center`},w={class:`text-2xl font-bold text-primary`},T={class:`glass-card rounded-[15px] p-4 text-center`},E={class:`text-2xl font-bold text-primary`},D={class:`glass-card rounded-[15px] p-4 text-center`},O={class:`text-2xl font-bold text-primary`},k={class:`glass-card rounded-[15px] p-4 text-center`},A={class:`text-2xl font-bold text-primary`},j=v(s({name:`CADCalibrationView`,__name:`CADCalibration`,setup(e){let s=_(),v=u(()=>document.documentElement.classList.contains(`dark`)),b=(e,t)=>typeof window>`u`?t:window.getComputedStyle(document.documentElement).getPropertyValue(e).trim()||t,j=(e,t)=>{let n=e.trim(),r=Math.max(0,Math.min(1,t)),i=n.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);if(i){let e=i[1],t=e.length===3?e.split(``).map(e=>e+e).join(``):e,n=Number.parseInt(t,16);return`rgba(${n>>16&255}, ${n>>8&255}, ${n&255}, ${r})`}let a=n.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);if(a){let[,e,t,n]=a;return`rgba(${e}, ${t}, ${n}, ${r})`}let o=n.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9.]+)\s*\)$/i);if(o){let[,e,t,n]=o;return`rgba(${e}, ${t}, ${n}, ${r})`}return n},M=()=>{let e=v.value,t=b(`--color-text-primary`,e?`#f9fafb`:`#111827`),n=b(`--color-text-muted`,e?`#9ca3af`:`#6b7280`),r=b(`--color-text-secondary`,e?`#d1d5db`:`#374151`),i=b(`--color-border-subtle`,e?`#4b5563`:`#d1d5db`),a=b(`--color-accent-cyan`,`#06b6d4`),o=b(`--color-accent-green`,`#10b981`);return{title:t,subtitle:n,axis:r,tick:n,grid:j(i,e?.3:.4),zeroline:j(i,e?.45:.55),line:j(i,e?.6:.7),colorbarBorder:j(i,e?.55:.65),markerLine:j(i,e?.55:.65),heatmapZero:j(n,.4),heatmapLow:j(a,.3),heatmapMid:j(a,.6),heatmapHigh:j(o,.9)}},N=r(!1),P=r(null),F=r(null),I=r({}),L=r(null),_e=r([]),ve=r({}),R=r(`Ready to start calibration`),z=r(0),B=r(0),V=r(0),H=r(0),U=r(0),W=r(0),G=r(null),K=r(!1),q=r(!1),J=r(!1),Y=r(!1),X=null,ye={responsive:!0,displayModeBar:!0,modeBarButtonsToRemove:[`pan2d`,`select2d`,`lasso2d`,`autoScale2d`],displaylogo:!1,toImageButtonOptions:{format:`png`,filename:`cad-calibration-heatmap`,height:600,width:800,scale:2}};function be(){let e=M(),t=[{x:[],y:[],z:[],mode:`markers`,type:`scatter`,marker:{size:12,color:[],colorscale:[[0,e.heatmapZero],[.1,e.heatmapLow],[.5,e.heatmapMid],[1,e.heatmapHigh]],showscale:!0,colorbar:{title:{text:`Detection Rate (%)`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},bgcolor:`transparent`,bordercolor:e.colorbarBorder,borderwidth:1,thickness:15},line:{color:e.markerLine,width:1}},hovertemplate:`Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Channel Activity Detection Calibration`,font:{color:e.title,size:18},x:.5},xaxis:{title:{text:`CAD Peak Threshold`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},yaxis:{title:{text:`CAD Min Threshold`,font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},plot_bgcolor:`transparent`,paper_bgcolor:`transparent`,font:{color:e.title,family:`Inter, system-ui, sans-serif`},margin:{l:80,r:80,t:100,b:80},showlegend:!1};x.default.newPlot(`plotly-chart`,t,n,ye)}function xe(){if(Object.keys(I.value).length===0)return;let e=Object.values(I.value),t=[],n=[],r=[];for(let i of e)t.push(i.det_peak),n.push(i.det_min),r.push(i.detection_rate);let i={x:[t],y:[n],"marker.color":[r],hovertemplate:`Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Status: Tested
0&&e.stroke()}}function on(e,t,n){return n||=.5,!t||e&&e.x>t.left-n&&e.x c(i,y,_)&&s(i,y)!==0,x=()=>s(a,_)===0||c(a,y,_),S=()=>h||b(),C=()=>!h||x();for(let e=u,n=u;e<=d;++e)v=t[e%o],!v.skip&&(_=l(v[r]),_!==y&&(h=c(_,i,a),g===null&&S()&&(g=s(_,i)===0?e:n),g!==null&&C()&&(m.push(Er({start:g,end:e,loop:f,count:o,style:p})),g=null),n=e,y=_));return g!==null&&m.push(Er({start:g,end:d,loop:f,count:o,style:p})),m}function kr(e,t){let n=[],r=e.segments;for(let i=0;i 0&&e.stroke()}}function on(e,t,n){return n||=.5,!t||e&&e.x>t.left-n&&e.x c(i,y,_)&&s(i,y)!==0,x=()=>s(a,_)===0||c(a,y,_),S=()=>h||b(),C=()=>!h||x();for(let e=u,n=u;e<=d;++e)v=t[e%o],!v.skip&&(_=l(v[r]),_!==y&&(h=c(_,i,a),g===null&&S()&&(g=s(_,i)===0?e:n),g!==null&&C()&&(m.push(Er({start:g,end:e,loop:f,count:o,style:p})),g=null),n=e,y=_));return g!==null&&m.push(Er({start:g,end:d,loop:f,count:o,style:p})),m}function kr(e,t){let n=[],r=e.segments;for(let i=0;i Manage companion identities (TCP frame server) Manage companion identities (TCP frame server) API tokens are used for machine-to-machine authentication. Include the token in the Tokens are only shown once at creation. Store them securely. PyMC Console must be installed at Web frontend changes will take effect after restarting the pymc-repeater service. There are three layers of advert rate limit control: Each layer can be enabled/disabled independently and the others will still function. Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box) Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals) Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value. Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected. This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh. Each sender has a token bucket. Every forwarded advert uses one token. If a sender keeps hitting the limit, it is temporarily blocked. Adaptive mode adjusts limits based on recent advert activity. This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network. Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card. Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it. Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh. Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it. Memory tracing is running. Let the repeater run for a few minutes, then click Check Again to see which parts of the code are using more memory. API tokens are used for machine-to-machine authentication. Include the token in the Tokens are only shown once at creation. Store them securely. PyMC Console must be installed at Web frontend changes will take effect after restarting the pymc-repeater service. There are three layers of advert rate limit control: Each layer can be enabled/disabled independently and the others will still function. Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box) Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals) Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value. Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected. This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh. Each sender has a token bucket. Every forwarded advert uses one token. If a sender keeps hitting the limit, it is temporarily blocked. Adaptive mode adjusts limits based on recent advert activity. This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network. Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card. Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it. Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh. Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it. Memory tracing is running. Let the repeater run for a few minutes, then click Check Again to see which parts of the code are using more memory. Activity (Last 24 Hours) Activity (Last 24 Hours) {let n=this.getDatasetMeta(e);if(!n)throw Error(`No dataset found at index `+e);return{datasetIndex:e,element:n.data[t],index:t}});Ne(n,t)||(this._active=n,this._lastEvent=null,this._updateHoverStyles(n,t))}notifyPlugins(e,t,n){return this._plugins.notify(this,e,t,n)}isPluginEnabled(e){return this._plugins._cache.filter(t=>t.plugin.id===e).length===1}_updateHoverStyles(e,t,n){let r=this.options.hover,i=(e,t)=>e.filter(e=>!t.some(t=>e.datasetIndex===t.datasetIndex&&e.index===t.index)),a=i(t,e),o=n?e:i(e,t);a.length&&this.updateHoverStyle(a,r.mode,!1),o.length&&r.mode&&this.updateHoverStyle(o,r.mode,!0)}_eventHandler(e,t){let n={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},r=t=>(t.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins(`beforeEvent`,n,r)===!1)return;let i=this._handleEvent(e,t,n.inChartArea);return n.cancelable=!1,this.notifyPlugins(`afterEvent`,n,r),(i||n.changed)&&this.render(),this}_handleEvent(e,t,n){let{_active:r=[],options:i}=this,a=t,o=this._getActiveElements(e,r,n,a),s=Je(e),c=Io(e,this._lastEvent,n,s);n&&(this._lastEvent=null,F(i.onHover,[e,o,this],this),s&&F(i.onClick,[e,o,this],this));let l=!Ne(o,r);return(l||t)&&(this._active=o,this._updateHoverStyles(o,r,t)),this._lastEvent=c,l}_getActiveElements(e,t,n,r){if(e.type===`mouseout`)return[];if(!n)return t;let i=this.options.hover;return this.getElementsAtEventForMode(e,i.mode,i,r)}};function Ro(){return I(Lo.instances,e=>e._plugins.invalidate())}function zo(e,t,n){let{startAngle:r,x:i,y:a,outerRadius:o,innerRadius:s,options:c}=t,{borderWidth:l,borderJoinStyle:u}=c,d=Math.min(l/o,H(r-n));if(e.beginPath(),e.arc(i,a,o-l/2,r+d/2,n-d/2),s>0){let t=Math.min(l/s,H(r-n));e.arc(i,a,s+l/2,n-t/2,r+t/2,!0)}else{let t=Math.min(l/2,o*H(r-n));if(u===`round`)e.arc(i,a,t,n-L/2,r+L/2,!0);else if(u===`bevel`){let o=2*t*t,s=-o*Math.cos(n+L/2)+i,c=-o*Math.sin(n+L/2)+a,l=o*Math.cos(r+L/2)+i,u=o*Math.sin(r+L/2)+a;e.lineTo(s,c),e.lineTo(l,u)}}e.closePath(),e.moveTo(0,0),e.rect(0,0,e.canvas.width,e.canvas.height),e.clip(`evenodd`)}function Bo(e,t,n){let{startAngle:r,pixelMargin:i,x:a,y:o,outerRadius:s,innerRadius:c}=t,l=i/s;e.beginPath(),e.arc(a,o,s,r-l,n+l),c>i?(l=i/c,e.arc(a,o,c,n+l,r-l,!0)):e.arc(a,o,i,n+z,r-z),e.closePath(),e.clip()}function Vo(e){return bn(e,[`outerStart`,`outerEnd`,`innerStart`,`innerEnd`])}function Ho(e,t,n,r){let i=Vo(e.options.borderRadius),a=(n-t)/2,o=Math.min(a,r*t/2),s=e=>{let t=(n-Math.min(a,e))*r/2;return U(e,0,Math.min(a,t))};return{outerStart:s(i.outerStart),outerEnd:s(i.outerEnd),innerStart:U(i.innerStart,0,o),innerEnd:U(i.innerEnd,0,o)}}function Uo(e,t,n,r){return{x:n+e*Math.cos(t),y:r+e*Math.sin(t)}}function Wo(e,t,n,r,i,a){let{x:o,y:s,startAngle:c,pixelMargin:l,innerRadius:u}=t,d=Math.max(t.outerRadius+r+n-l,0),f=u>0?u+r+n+l:0,p=0,m=i-c;if(r){let e=((u>0?u-r:0)+(d>0?d-r:0))/2;p=(m-(e===0?m:m*e/(e+r)))/2}let h=(m-Math.max(.001,m*d-n/L)/d)/2,g=c+h+p,_=i-h-p,{outerStart:v,outerEnd:y,innerStart:b,innerEnd:x}=Ho(t,f,d,_-g),S=d-v,C=d-y,w=g+v/S,T=_-y/C,E=f+b,D=f+x,ee=g+b/E,O=_-x/D;if(e.beginPath(),a){let t=(w+T)/2;if(e.arc(o,s,d,w,t),e.arc(o,s,d,t,T),y>0){let t=Uo(C,T,o,s);e.arc(t.x,t.y,y,T,_+z)}let n=Uo(D,_,o,s);if(e.lineTo(n.x,n.y),x>0){let t=Uo(D,O,o,s);e.arc(t.x,t.y,x,_+z,O+Math.PI)}let r=(_-x/f+(g+b/f))/2;if(e.arc(o,s,f,_-x/f,r,!0),e.arc(o,s,f,r,g+b/f,!0),b>0){let t=Uo(E,ee,o,s);e.arc(t.x,t.y,b,ee+Math.PI,g-z)}let i=Uo(S,g,o,s);if(e.lineTo(i.x,i.y),v>0){let t=Uo(S,w,o,s);e.arc(t.x,t.y,v,g-z,w)}}else{e.moveTo(o,s);let t=Math.cos(w)*d+o,n=Math.sin(w)*d+s;e.lineTo(t,n);let r=Math.cos(T)*d+o,i=Math.sin(T)*d+s;e.lineTo(r,i)}e.closePath()}function Go(e,t,n,r,i){let{fullCircles:a,startAngle:o,circumference:s}=t,c=t.endAngle;if(a){Wo(e,t,n,r,c,i);for(let t=0;t=L&&p===0&&u!==`miter`&&zo(e,t,h),a||(Wo(e,t,n,r,h,i),e.stroke())}var qo=class extends ka{static id=`arc`;static defaults={borderAlign:`center`,borderColor:`#fff`,borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1};static defaultRoutes={backgroundColor:`backgroundColor`};static descriptors={_scriptable:!0,_indexable:e=>e!==`borderDash`};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(e){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,e&&Object.assign(this,e)}inRange(e,t,n){let{angle:r,distance:i}=ut(this.getProps([`x`,`y`],n),{x:e,y:t}),{startAngle:a,endAngle:o,innerRadius:s,outerRadius:c,circumference:l}=this.getProps([`startAngle`,`endAngle`,`innerRadius`,`outerRadius`,`circumference`],n),u=(this.options.spacing+this.options.borderWidth)/2,d=P(l,o-a),f=pt(r,a,o)&&a!==o,p=d>=R||f,m=W(i,s+u,c+u);return p&&m}getCenterPoint(e){let{x:t,y:n,startAngle:r,endAngle:i,innerRadius:a,outerRadius:o}=this.getProps([`x`,`y`,`startAngle`,`endAngle`,`innerRadius`,`outerRadius`],e),{offset:s,spacing:c}=this.options,l=(r+i)/2,u=(a+o+c+s)/2;return{x:t+Math.cos(l)*u,y:n+Math.sin(l)*u}}tooltipPosition(e){return this.getCenterPoint(e)}draw(e){let{options:t,circumference:n}=this,r=(t.offset||0)/4,i=(t.spacing||0)/2,a=t.circular;if(this.pixelMargin=t.borderAlign===`inner`?.33:0,this.fullCircles=n>R?Math.floor(n/R):0,n===0||this.innerRadius<0||this.outerRadius<0)return;e.save();let o=(this.startAngle+this.endAngle)/2;e.translate(Math.cos(o)*r,Math.sin(o)*r);let s=r*(1-Math.sin(Math.min(L,n||0)));e.fillStyle=t.backgroundColor,e.strokeStyle=t.borderColor,Go(e,this,s,i,a),Ko(e,this,s,i,a),e.restore()}};function Jo(e,t,n=t){e.lineCap=P(n.borderCapStyle,t.borderCapStyle),e.setLineDash(P(n.borderDash,t.borderDash)),e.lineDashOffset=P(n.borderDashOffset,t.borderDashOffset),e.lineJoin=P(n.borderJoinStyle,t.borderJoinStyle),e.lineWidth=P(n.borderWidth,t.borderWidth),e.strokeStyle=P(n.borderColor,t.borderColor)}function Yo(e,t,n){e.lineTo(n.x,n.y)}function Xo(e){return e.stepped?ln:e.tension||e.cubicInterpolationMode===`monotone`?un:Yo}function Zo(e,t,n={}){let r=e.length,{start:i=0,end:a=r-1}=n,{start:o,end:s}=t,c=Math.max(i,o),l=Math.min(a,s),u=in.length){for(c=0;c{let n=this.getDatasetMeta(e);if(!n)throw Error(`No dataset found at index `+e);return{datasetIndex:e,element:n.data[t],index:t}});Ne(n,t)||(this._active=n,this._lastEvent=null,this._updateHoverStyles(n,t))}notifyPlugins(e,t,n){return this._plugins.notify(this,e,t,n)}isPluginEnabled(e){return this._plugins._cache.filter(t=>t.plugin.id===e).length===1}_updateHoverStyles(e,t,n){let r=this.options.hover,i=(e,t)=>e.filter(e=>!t.some(t=>e.datasetIndex===t.datasetIndex&&e.index===t.index)),a=i(t,e),o=n?e:i(e,t);a.length&&this.updateHoverStyle(a,r.mode,!1),o.length&&r.mode&&this.updateHoverStyle(o,r.mode,!0)}_eventHandler(e,t){let n={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},r=t=>(t.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins(`beforeEvent`,n,r)===!1)return;let i=this._handleEvent(e,t,n.inChartArea);return n.cancelable=!1,this.notifyPlugins(`afterEvent`,n,r),(i||n.changed)&&this.render(),this}_handleEvent(e,t,n){let{_active:r=[],options:i}=this,a=t,o=this._getActiveElements(e,r,n,a),s=Je(e),c=Io(e,this._lastEvent,n,s);n&&(this._lastEvent=null,F(i.onHover,[e,o,this],this),s&&F(i.onClick,[e,o,this],this));let l=!Ne(o,r);return(l||t)&&(this._active=o,this._updateHoverStyles(o,r,t)),this._lastEvent=c,l}_getActiveElements(e,t,n,r){if(e.type===`mouseout`)return[];if(!n)return t;let i=this.options.hover;return this.getElementsAtEventForMode(e,i.mode,i,r)}};function Ro(){return I(Lo.instances,e=>e._plugins.invalidate())}function zo(e,t,n){let{startAngle:r,x:i,y:a,outerRadius:o,innerRadius:s,options:c}=t,{borderWidth:l,borderJoinStyle:u}=c,d=Math.min(l/o,H(r-n));if(e.beginPath(),e.arc(i,a,o-l/2,r+d/2,n-d/2),s>0){let t=Math.min(l/s,H(r-n));e.arc(i,a,s+l/2,n-t/2,r+t/2,!0)}else{let t=Math.min(l/2,o*H(r-n));if(u===`round`)e.arc(i,a,t,n-L/2,r+L/2,!0);else if(u===`bevel`){let o=2*t*t,s=-o*Math.cos(n+L/2)+i,c=-o*Math.sin(n+L/2)+a,l=o*Math.cos(r+L/2)+i,u=o*Math.sin(r+L/2)+a;e.lineTo(s,c),e.lineTo(l,u)}}e.closePath(),e.moveTo(0,0),e.rect(0,0,e.canvas.width,e.canvas.height),e.clip(`evenodd`)}function Bo(e,t,n){let{startAngle:r,pixelMargin:i,x:a,y:o,outerRadius:s,innerRadius:c}=t,l=i/s;e.beginPath(),e.arc(a,o,s,r-l,n+l),c>i?(l=i/c,e.arc(a,o,c,n+l,r-l,!0)):e.arc(a,o,i,n+z,r-z),e.closePath(),e.clip()}function Vo(e){return bn(e,[`outerStart`,`outerEnd`,`innerStart`,`innerEnd`])}function Ho(e,t,n,r){let i=Vo(e.options.borderRadius),a=(n-t)/2,o=Math.min(a,r*t/2),s=e=>{let t=(n-Math.min(a,e))*r/2;return U(e,0,Math.min(a,t))};return{outerStart:s(i.outerStart),outerEnd:s(i.outerEnd),innerStart:U(i.innerStart,0,o),innerEnd:U(i.innerEnd,0,o)}}function Uo(e,t,n,r){return{x:n+e*Math.cos(t),y:r+e*Math.sin(t)}}function Wo(e,t,n,r,i,a){let{x:o,y:s,startAngle:c,pixelMargin:l,innerRadius:u}=t,d=Math.max(t.outerRadius+r+n-l,0),f=u>0?u+r+n+l:0,p=0,m=i-c;if(r){let e=((u>0?u-r:0)+(d>0?d-r:0))/2;p=(m-(e===0?m:m*e/(e+r)))/2}let h=(m-Math.max(.001,m*d-n/L)/d)/2,g=c+h+p,_=i-h-p,{outerStart:v,outerEnd:y,innerStart:b,innerEnd:x}=Ho(t,f,d,_-g),S=d-v,C=d-y,w=g+v/S,T=_-y/C,E=f+b,D=f+x,ee=g+b/E,O=_-x/D;if(e.beginPath(),a){let t=(w+T)/2;if(e.arc(o,s,d,w,t),e.arc(o,s,d,t,T),y>0){let t=Uo(C,T,o,s);e.arc(t.x,t.y,y,T,_+z)}let n=Uo(D,_,o,s);if(e.lineTo(n.x,n.y),x>0){let t=Uo(D,O,o,s);e.arc(t.x,t.y,x,_+z,O+Math.PI)}let r=(_-x/f+(g+b/f))/2;if(e.arc(o,s,f,_-x/f,r,!0),e.arc(o,s,f,r,g+b/f,!0),b>0){let t=Uo(E,ee,o,s);e.arc(t.x,t.y,b,ee+Math.PI,g-z)}let i=Uo(S,g,o,s);if(e.lineTo(i.x,i.y),v>0){let t=Uo(S,w,o,s);e.arc(t.x,t.y,v,g-z,w)}}else{e.moveTo(o,s);let t=Math.cos(w)*d+o,n=Math.sin(w)*d+s;e.lineTo(t,n);let r=Math.cos(T)*d+o,i=Math.sin(T)*d+s;e.lineTo(r,i)}e.closePath()}function Go(e,t,n,r,i){let{fullCircles:a,startAngle:o,circumference:s}=t,c=t.endAngle;if(a){Wo(e,t,n,r,c,i);for(let t=0;t=L&&p===0&&u!==`miter`&&zo(e,t,h),a||(Wo(e,t,n,r,h,i),e.stroke())}var qo=class extends ka{static id=`arc`;static defaults={borderAlign:`center`,borderColor:`#fff`,borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1};static defaultRoutes={backgroundColor:`backgroundColor`};static descriptors={_scriptable:!0,_indexable:e=>e!==`borderDash`};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(e){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,e&&Object.assign(this,e)}inRange(e,t,n){let{angle:r,distance:i}=ut(this.getProps([`x`,`y`],n),{x:e,y:t}),{startAngle:a,endAngle:o,innerRadius:s,outerRadius:c,circumference:l}=this.getProps([`startAngle`,`endAngle`,`innerRadius`,`outerRadius`,`circumference`],n),u=(this.options.spacing+this.options.borderWidth)/2,d=P(l,o-a),f=pt(r,a,o)&&a!==o,p=d>=R||f,m=W(i,s+u,c+u);return p&&m}getCenterPoint(e){let{x:t,y:n,startAngle:r,endAngle:i,innerRadius:a,outerRadius:o}=this.getProps([`x`,`y`,`startAngle`,`endAngle`,`innerRadius`,`outerRadius`],e),{offset:s,spacing:c}=this.options,l=(r+i)/2,u=(a+o+c+s)/2;return{x:t+Math.cos(l)*u,y:n+Math.sin(l)*u}}tooltipPosition(e){return this.getCenterPoint(e)}draw(e){let{options:t,circumference:n}=this,r=(t.offset||0)/4,i=(t.spacing||0)/2,a=t.circular;if(this.pixelMargin=t.borderAlign===`inner`?.33:0,this.fullCircles=n>R?Math.floor(n/R):0,n===0||this.innerRadius<0||this.outerRadius<0)return;e.save();let o=(this.startAngle+this.endAngle)/2;e.translate(Math.cos(o)*r,Math.sin(o)*r);let s=r*(1-Math.sin(Math.min(L,n||0)));e.fillStyle=t.backgroundColor,e.strokeStyle=t.borderColor,Go(e,this,s,i,a),Ko(e,this,s,i,a),e.restore()}};function Jo(e,t,n=t){e.lineCap=P(n.borderCapStyle,t.borderCapStyle),e.setLineDash(P(n.borderDash,t.borderDash)),e.lineDashOffset=P(n.borderDashOffset,t.borderDashOffset),e.lineJoin=P(n.borderJoinStyle,t.borderJoinStyle),e.lineWidth=P(n.borderWidth,t.borderWidth),e.strokeStyle=P(n.borderColor,t.borderColor)}function Yo(e,t,n){e.lineTo(n.x,n.y)}function Xo(e){return e.stepped?ln:e.tension||e.cubicInterpolationMode===`monotone`?un:Yo}function Zo(e,t,n={}){let r=e.length,{start:i=0,end:a=r-1}=n,{start:o,end:s}=t,c=Math.max(i,o),l=Math.min(a,s),u=i Companions
Companions
X-API-Key header when making API requests. /opt/pymc_console/web/html before selecting this option. Service restart required
Why you may see the same advert more than once
Token Bucket Rate Limiting
- Copy 1 forwarded (2 → 1 tokens)
- Copy 2 forwarded (1 → 0 tokens)
- Copy 3 dropped (no tokens left) Penalty Box (Repeat Offenders)
Adaptive Mesh Activity Tiers
- 0.02 adverts/min → QUIET (bypass)
- 0.35 adverts/min → BUSY (tighter limits)
- 0.68 adverts/min → CONGESTED (strict limits) Recommended starting settings
Unencrypted Connection
Full Backup
Export Identity Key
X-API-Key header when making API requests. /opt/pymc_console/web/html before selecting this option. Service restart required
Why you may see the same advert more than once
Token Bucket Rate Limiting
- Copy 1 forwarded (2 → 1 tokens)
- Copy 2 forwarded (1 → 0 tokens)
- Copy 3 dropped (no tokens left) Penalty Box (Repeat Offenders)
Adaptive Mesh Activity Tiers
- 0.02 adverts/min → QUIET (bypass)
- 0.35 adverts/min → BUSY (tighter limits)
- 0.68 adverts/min → CONGESTED (strict limits) Recommended starting settings
Unencrypted Connection
Full Backup
Export Identity Key
[e.rxUtil,e.txUtil]))*1.05;A.value=Math.max(5,Math.ceil(g/5)*5),Z={data:h,yAxisMax:A.value,fetchedAt:Date.now()},C.value=!1,w.value=!1,D.value=!1,O.value=null,S(()=>L())}catch(e){console.error(`Failed to fetch airtime data:`,e),v.value=[],C.value=!1,w.value=!1,D.value=!1,O.value=e instanceof Error?e.message:`Failed to load chart data`,S(()=>L())}},L=()=>{if(!p.value)return;let e=p.value,t=e.getContext(`2d`);if(!t)return;let n=e.parentElement;if(!n)return;let r=n.getBoundingClientRect(),i=r.width,a=r.height;if(e.width=i*window.devicePixelRatio,e.height=a*window.devicePixelRatio,e.style.width=i+`px`,e.style.height=a+`px`,t.scale(window.devicePixelRatio,window.devicePixelRatio),t.clearRect(0,0,i,a),C.value){t.fillStyle=u().axisLabel,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`Loading chart data...`,i/2,a/2);return}if(v.value.length===0){t.fillStyle=u().axisLabel,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`No data available`,i/2,a/2);return}let o=i-45-20,s=a-40,l=A.value,d=A.value,f=u();t.strokeStyle=f.gridLine,t.lineWidth=1,t.font=`10px system-ui`,t.textAlign=`right`;for(let e=0;e<=5;e++){let n=20+s*e/5;t.beginPath(),t.moveTo(45,n),t.lineTo(i-20,n),t.stroke();let r=l-e/5*d;t.fillStyle=f.axisLabel,t.fillText(`${r.toFixed(0)}%`,40,n+3)}for(let e=0;e<=6;e++){let n=45+o*e/6;t.beginPath(),t.moveTo(n,20),t.lineTo(n,a-20),t.stroke()}v.value.length>1&&(t.strokeStyle=c.rx,t.lineWidth=2,t.beginPath(),v.value.forEach((e,n)=>{let r=45+o*n/(v.value.length-1),i=a-20-Math.min(e.rxUtil,A.value)/d*s;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke()),v.value.length>1&&(t.strokeStyle=c.tx,t.lineWidth=2,t.beginPath(),v.value.forEach((e,n)=>{let r=45+o*n/(v.value.length-1),i=a-20-Math.min(e.txUtil,A.value)/d*s;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke())};return N(I,{intervalMs:12e4,immediate:!1}),e(()=>{Z&&Date.now()-Z.fetchedAt Airtime Utilization
[e.rxUtil,e.txUtil]))*1.05;A.value=Math.max(5,Math.ceil(g/5)*5),Z={data:h,yAxisMax:A.value,fetchedAt:Date.now()},C.value=!1,w.value=!1,D.value=!1,O.value=null,S(()=>L())}catch(e){console.error(`Failed to fetch airtime data:`,e),v.value=[],C.value=!1,w.value=!1,D.value=!1,O.value=e instanceof Error?e.message:`Failed to load chart data`,S(()=>L())}},L=()=>{if(!p.value)return;let e=p.value,t=e.getContext(`2d`);if(!t)return;let n=e.parentElement;if(!n)return;let r=n.getBoundingClientRect(),i=r.width,a=r.height;if(e.width=i*window.devicePixelRatio,e.height=a*window.devicePixelRatio,e.style.width=i+`px`,e.style.height=a+`px`,t.scale(window.devicePixelRatio,window.devicePixelRatio),t.clearRect(0,0,i,a),C.value){t.fillStyle=u().axisLabel,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`Loading chart data...`,i/2,a/2);return}if(v.value.length===0){t.fillStyle=u().axisLabel,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`No data available`,i/2,a/2);return}let o=i-45-20,s=a-40,l=A.value,d=A.value,f=u();t.strokeStyle=f.gridLine,t.lineWidth=1,t.font=`10px system-ui`,t.textAlign=`right`;for(let e=0;e<=5;e++){let n=20+s*e/5;t.beginPath(),t.moveTo(45,n),t.lineTo(i-20,n),t.stroke();let r=l-e/5*d;t.fillStyle=f.axisLabel,t.fillText(`${r.toFixed(0)}%`,40,n+3)}for(let e=0;e<=6;e++){let n=45+o*e/6;t.beginPath(),t.moveTo(n,20),t.lineTo(n,a-20),t.stroke()}v.value.length>1&&(t.strokeStyle=c.rx,t.lineWidth=2,t.beginPath(),v.value.forEach((e,n)=>{let r=45+o*n/(v.value.length-1),i=a-20-Math.min(e.rxUtil,A.value)/d*s;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke()),v.value.length>1&&(t.strokeStyle=c.tx,t.lineWidth=2,t.beginPath(),v.value.forEach((e,n)=>{let r=45+o*n/(v.value.length-1),i=a-20-Math.min(e.txUtil,A.value)/d*s;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke())};return N(I,{intervalMs:12e4,immediate:!1}),e(()=>{Z&&Date.now()-Z.fetchedAt Airtime Utilization
n.far?null:{distance:l,point:Ii.clone(),object:e}}function zi(e,t,n,r,i,a,o,s,c,l){e.getVertexPosition(s,Ai),e.getVertexPosition(c,ji),e.getVertexPosition(l,Mi);let u=Ri(e,t,n,r,Ai,ji,Mi,Fi);if(u){let e=new q;hr.getBarycoord(Fi,Ai,ji,Mi,e),i&&(u.uv=hr.getInterpolatedAttribute(i,s,c,l,e,new K)),a&&(u.uv1=hr.getInterpolatedAttribute(a,s,c,l,e,new K)),o&&(u.normal=hr.getInterpolatedAttribute(o,s,c,l,e,new q),u.normal.dot(r.direction)>0&&u.normal.multiplyScalar(-1));let t={a:s,b:c,c:l,normal:new q,materialIndex:0};hr.getNormal(Ai,ji,Mi,t.normal),u.face=t,u.barycoord=e}return u}var Bi=class extends pn{constructor(e=null,t=1,n=1,r,i,a,o,s,c=O,l=O,u,d){super(null,a,o,s,c,l,r,i,u,d),this.isDataTexture=!0,this.image={data:e,width:t,height:n},this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}},Vi=new q,Hi=new q,Ui=new J,Wi=class{constructor(e=new q(1,0,0),t=0){this.isPlane=!0,this.normal=e,this.constant=t}set(e,t){return this.normal.copy(e),this.constant=t,this}setComponents(e,t,n,r){return this.normal.set(e,t,n),this.constant=r,this}setFromNormalAndCoplanarPoint(e,t){return this.normal.copy(e),this.constant=-t.dot(this.normal),this}setFromCoplanarPoints(e,t,n){let r=Vi.subVectors(n,t).cross(Hi.subVectors(e,t)).normalize();return this.setFromNormalAndCoplanarPoint(r,e),this}copy(e){return this.normal.copy(e.normal),this.constant=e.constant,this}normalize(){let e=1/this.normal.length();return this.normal.multiplyScalar(e),this.constant*=e,this}negate(){return this.constant*=-1,this.normal.negate(),this}distanceToPoint(e){return this.normal.dot(e)+this.constant}distanceToSphere(e){return this.distanceToPoint(e.center)-e.radius}projectPoint(e,t){return t.copy(e).addScaledVector(this.normal,-this.distanceToPoint(e))}intersectLine(e,t,n=!0){let r=e.delta(Vi),i=this.normal.dot(r);if(i===0)return this.distanceToPoint(e.start)===0?t.copy(e.start):null;let a=-(e.start.dot(this.normal)+this.constant)/i;return n===!0&&(a<0||a>1)?null:t.copy(e.start).addScaledVector(r,a)}intersectsLine(e){let t=this.distanceToPoint(e.start),n=this.distanceToPoint(e.end);return t<0&&n>0||n<0&&t>0}intersectsBox(e){return e.intersectsPlane(this)}intersectsSphere(e){return e.intersectsPlane(this)}coplanarPoint(e){return e.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(e,t){let n=t||Ui.getNormalMatrix(e),r=this.coplanarPoint(Vi).applyMatrix4(e),i=this.normal.applyMatrix3(n).normalize();return this.constant=-r.dot(i),this}translate(e){return this.constant-=e.dot(this.normal),this}equals(e){return e.normal.equals(this.normal)&&e.constant===this.constant}clone(){return new this.constructor().copy(this)}},Gi=new Vr,Ki=new K(.5,.5),qi=new q,Ji=class{constructor(e=new Wi,t=new Wi,n=new Wi,r=new Wi,i=new Wi,a=new Wi){this.planes=[e,t,n,r,i,a]}set(e,t,n,r,i,a){let o=this.planes;return o[0].copy(e),o[1].copy(t),o[2].copy(n),o[3].copy(r),o[4].copy(i),o[5].copy(a),this}copy(e){let t=this.planes;for(let n=0;n<6;n++)t[n].copy(e.planes[n]);return this}setFromProjectionMatrix(e,t=ft,n=!1){let r=this.planes,i=e.elements,a=i[0],o=i[1],s=i[2],c=i[3],l=i[4],u=i[5],d=i[6],f=i[7],p=i[8],m=i[9],h=i[10],g=i[11],_=i[12],v=i[13],y=i[14],b=i[15];if(r[0].setComponents(c-a,f-l,g-p,b-_).normalize(),r[1].setComponents(c+a,f+l,g+p,b+_).normalize(),r[2].setComponents(c+o,f+u,g+m,b+v).normalize(),r[3].setComponents(c-o,f-u,g-m,b-v).normalize(),n)r[4].setComponents(s,d,h,y).normalize(),r[5].setComponents(c-s,f-d,g-h,b-y).normalize();else if(r[4].setComponents(c-s,f-d,g-h,b-y).normalize(),t===2e3)r[5].setComponents(c+s,f+d,g+h,b+y).normalize();else if(t===2001)r[5].setComponents(s,d,h,y).normalize();else throw Error(`THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: `+t);return this}intersectsObject(e){if(e.boundingSphere!==void 0)e.boundingSphere===null&&e.computeBoundingSphere(),Gi.copy(e.boundingSphere).applyMatrix4(e.matrixWorld);else{let t=e.geometry;t.boundingSphere===null&&t.computeBoundingSphere(),Gi.copy(t.boundingSphere).applyMatrix4(e.matrixWorld)}return this.intersectsSphere(Gi)}intersectsSprite(e){return Gi.center.set(0,0,0),Gi.radius=.7071067811865476+Ki.distanceTo(e.center),Gi.applyMatrix4(e.matrixWorld),this.intersectsSphere(Gi)}intersectsSphere(e){let t=this.planes,n=e.center,r=-e.radius;for(let e=0;e<6;e++)if(t[e].distanceToPoint(n)n.far?null:{distance:l,point:Ii.clone(),object:e}}function zi(e,t,n,r,i,a,o,s,c,l){e.getVertexPosition(s,Ai),e.getVertexPosition(c,ji),e.getVertexPosition(l,Mi);let u=Ri(e,t,n,r,Ai,ji,Mi,Fi);if(u){let e=new q;hr.getBarycoord(Fi,Ai,ji,Mi,e),i&&(u.uv=hr.getInterpolatedAttribute(i,s,c,l,e,new K)),a&&(u.uv1=hr.getInterpolatedAttribute(a,s,c,l,e,new K)),o&&(u.normal=hr.getInterpolatedAttribute(o,s,c,l,e,new q),u.normal.dot(r.direction)>0&&u.normal.multiplyScalar(-1));let t={a:s,b:c,c:l,normal:new q,materialIndex:0};hr.getNormal(Ai,ji,Mi,t.normal),u.face=t,u.barycoord=e}return u}var Bi=class extends pn{constructor(e=null,t=1,n=1,r,i,a,o,s,c=O,l=O,u,d){super(null,a,o,s,c,l,r,i,u,d),this.isDataTexture=!0,this.image={data:e,width:t,height:n},this.generateMipmaps=!1,this.flipY=!1,this.unpackAlignment=1}},Vi=new q,Hi=new q,Ui=new J,Wi=class{constructor(e=new q(1,0,0),t=0){this.isPlane=!0,this.normal=e,this.constant=t}set(e,t){return this.normal.copy(e),this.constant=t,this}setComponents(e,t,n,r){return this.normal.set(e,t,n),this.constant=r,this}setFromNormalAndCoplanarPoint(e,t){return this.normal.copy(e),this.constant=-t.dot(this.normal),this}setFromCoplanarPoints(e,t,n){let r=Vi.subVectors(n,t).cross(Hi.subVectors(e,t)).normalize();return this.setFromNormalAndCoplanarPoint(r,e),this}copy(e){return this.normal.copy(e.normal),this.constant=e.constant,this}normalize(){let e=1/this.normal.length();return this.normal.multiplyScalar(e),this.constant*=e,this}negate(){return this.constant*=-1,this.normal.negate(),this}distanceToPoint(e){return this.normal.dot(e)+this.constant}distanceToSphere(e){return this.distanceToPoint(e.center)-e.radius}projectPoint(e,t){return t.copy(e).addScaledVector(this.normal,-this.distanceToPoint(e))}intersectLine(e,t,n=!0){let r=e.delta(Vi),i=this.normal.dot(r);if(i===0)return this.distanceToPoint(e.start)===0?t.copy(e.start):null;let a=-(e.start.dot(this.normal)+this.constant)/i;return n===!0&&(a<0||a>1)?null:t.copy(e.start).addScaledVector(r,a)}intersectsLine(e){let t=this.distanceToPoint(e.start),n=this.distanceToPoint(e.end);return t<0&&n>0||n<0&&t>0}intersectsBox(e){return e.intersectsPlane(this)}intersectsSphere(e){return e.intersectsPlane(this)}coplanarPoint(e){return e.copy(this.normal).multiplyScalar(-this.constant)}applyMatrix4(e,t){let n=t||Ui.getNormalMatrix(e),r=this.coplanarPoint(Vi).applyMatrix4(e),i=this.normal.applyMatrix3(n).normalize();return this.constant=-r.dot(i),this}translate(e){return this.constant-=e.dot(this.normal),this}equals(e){return e.normal.equals(this.normal)&&e.constant===this.constant}clone(){return new this.constructor().copy(this)}},Gi=new Vr,Ki=new K(.5,.5),qi=new q,Ji=class{constructor(e=new Wi,t=new Wi,n=new Wi,r=new Wi,i=new Wi,a=new Wi){this.planes=[e,t,n,r,i,a]}set(e,t,n,r,i,a){let o=this.planes;return o[0].copy(e),o[1].copy(t),o[2].copy(n),o[3].copy(r),o[4].copy(i),o[5].copy(a),this}copy(e){let t=this.planes;for(let n=0;n<6;n++)t[n].copy(e.planes[n]);return this}setFromProjectionMatrix(e,t=ft,n=!1){let r=this.planes,i=e.elements,a=i[0],o=i[1],s=i[2],c=i[3],l=i[4],u=i[5],d=i[6],f=i[7],p=i[8],m=i[9],h=i[10],g=i[11],_=i[12],v=i[13],y=i[14],b=i[15];if(r[0].setComponents(c-a,f-l,g-p,b-_).normalize(),r[1].setComponents(c+a,f+l,g+p,b+_).normalize(),r[2].setComponents(c+o,f+u,g+m,b+v).normalize(),r[3].setComponents(c-o,f-u,g-m,b-v).normalize(),n)r[4].setComponents(s,d,h,y).normalize(),r[5].setComponents(c-s,f-d,g-h,b-y).normalize();else if(r[4].setComponents(c-s,f-d,g-h,b-y).normalize(),t===2e3)r[5].setComponents(c+s,f+d,g+h,b+y).normalize();else if(t===2001)r[5].setComponents(s,d,h,y).normalize();else throw Error(`THREE.Frustum.setFromProjectionMatrix(): Invalid coordinate system: `+t);return this}intersectsObject(e){if(e.boundingSphere!==void 0)e.boundingSphere===null&&e.computeBoundingSphere(),Gi.copy(e.boundingSphere).applyMatrix4(e.matrixWorld);else{let t=e.geometry;t.boundingSphere===null&&t.computeBoundingSphere(),Gi.copy(t.boundingSphere).applyMatrix4(e.matrixWorld)}return this.intersectsSphere(Gi)}intersectsSprite(e){return Gi.center.set(0,0,0),Gi.radius=.7071067811865476+Ki.distanceTo(e.center),Gi.applyMatrix4(e.matrixWorld),this.intersectsSphere(Gi)}intersectsSphere(e){let t=this.planes,n=e.center,r=-e.radius;for(let e=0;e<6;e++)if(t[e].distanceToPoint(n)