Merge remote-tracking branch 'origin/dev' into feat/mqtt_merge

# Conflicts:
#	config.yaml.example
#	repeater/data_acquisition/__init__.py
#	repeater/data_acquisition/storage_collector.py
#	repeater/web/html/assets/CADCalibration-319vQEzv.js
#	repeater/web/html/assets/CADCalibration-Cwr0Kq49.js
#	repeater/web/html/assets/CADCalibration-DWusgblB.js
#	repeater/web/html/assets/Companions-DU19yZyB.js
#	repeater/web/html/assets/Companions-cufpceKN.js
#	repeater/web/html/assets/Companions-zmTexa6a.js
#	repeater/web/html/assets/Configuration-BmDpq7bV.js
#	repeater/web/html/assets/ConfirmDialog-BafURQpE.js
#	repeater/web/html/assets/ConfirmDialog-C9Yf394V.js
#	repeater/web/html/assets/ConfirmDialog-h2bJ_WKJ.js
#	repeater/web/html/assets/Dashboard-CnQfG826.js
#	repeater/web/html/assets/Login-BDsVY-me.js
#	repeater/web/html/assets/Logs-BpG7T8_d.js
#	repeater/web/html/assets/Logs-CVZ1ZqH8.js
#	repeater/web/html/assets/Logs-sxcWuUjs.js
#	repeater/web/html/assets/MessageDialog-B-qWtO0z.js
#	repeater/web/html/assets/MessageDialog-Cp4W1enq.js
#	repeater/web/html/assets/MessageDialog-D2OlpbZ7.js
#	repeater/web/html/assets/Neighbors-BAwKrJdF.js
#	repeater/web/html/assets/Neighbors-BamkiPcU.js
#	repeater/web/html/assets/Neighbors-WHAK_7hU.js
#	repeater/web/html/assets/RoomServers-DbCgmJ6x.js
#	repeater/web/html/assets/RoomServers-i32N0iwv.js
#	repeater/web/html/assets/RoomServers-o3kDed-S.js
#	repeater/web/html/assets/Sessions-B8ZVRIGt.js
#	repeater/web/html/assets/Sessions-B9uqWGaO.js
#	repeater/web/html/assets/Sessions-O3vBapMM.js
#	repeater/web/html/assets/Setup-DyJMgh0L.js
#	repeater/web/html/assets/Statistics-BbiQtXdu.js
#	repeater/web/html/assets/Statistics-CeTg6NYy.js
#	repeater/web/html/assets/Statistics-QSH8GjMX.js
#	repeater/web/html/assets/SystemStats-B7qxcRYp.js
#	repeater/web/html/assets/SystemStats-BmXJQonl.js
#	repeater/web/html/assets/SystemStats-DVaA1ybj.js
#	repeater/web/html/assets/Terminal-CUqcF84y.js
#	repeater/web/html/assets/Terminal-D1kRkrmc.js
#	repeater/web/html/assets/Terminal-Dq6FyjMj.js
#	repeater/web/html/assets/api-CiSov_eM.js
#	repeater/web/html/assets/api-DegLD39Y.js
#	repeater/web/html/assets/api-DjLVJkR1.js
#	repeater/web/html/assets/index-cutq4vvY.js
#	repeater/web/html/assets/packets-Bg0pkGLO.js
#	repeater/web/html/assets/packets-CPLd89q8.js
#	repeater/web/html/assets/packets-DmoWuBlc.js
#	repeater/web/html/assets/system-Bocs8bSU.js
#	repeater/web/html/assets/system-CsY7_jKa.js
#	repeater/web/html/assets/system-qCwV23PE.js
#	repeater/web/html/assets/useSignalQuality-DQTATYAm.js
#	repeater/web/html/assets/useSignalQuality-DlXA7j0p.js
#	repeater/web/html/assets/useSignalQuality-u0_rDpC6.js
#	repeater/web/html/index.html
This commit is contained in:
Rigear
2026-04-18 20:25:30 -07:00
54 changed files with 1906 additions and 73 deletions
+33 -10
View File
@@ -55,16 +55,6 @@ The repeater supports two radio backends:
The following hardware is currently supported out-of-the-box:
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported)
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16
Note: Only the SPI version is supported. The UART version will not work.
HackerGadgets uConsole
Hardware: uConsole RTL-SDR/LoRa/GPS/RTC/USB Hub
@@ -102,6 +92,27 @@ HT-RA62 module
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True
Zindello Industries UltraPeater
Hardware: EBYTE E22/P 1W Module
Platform: Luckfox Pico Ultra/W (NOT A PI DEVICE)
Frequency: 868MHz (EU) or 915Mhz (US/AU)
Tx Power: Up to 30dBm
SPI Bus: SPI0
GPIO Pins: CS=16, Reset=22, Busy=11, IRQ=10, TXEN=20 , RXEN=21 (E22 Only), EN=21 (E22P Only), TXLED=9, RXLED=1, use_dio2_rf=False, use_dio3_tcxo=True, use_gpiod_backend=True, gpio_chip=1
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
NO LONGER RECOMMENDED
Note: May experience issues on "Narrow" (62.5KHz) settings due to a lack of TCXO
Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported)
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16
Note: Only the SPI version is supported. The UART version will not work.
...
## Screenshots
@@ -189,6 +200,18 @@ The configuration file is created and configured during installation at:
/etc/pymc_repeater/config.yaml
```
### Optional pyMC_Glass integration
The repeater now supports an additive `glass` config section for central control-plane integration.
When enabled, it sends periodic `/inform` payloads to pyMC_Glass, receives queued commands, and reports command results on the next inform cycle.
Minimal example:
```yaml
glass:
enabled: true
base_url: "http://localhost:8080"
inform_interval_seconds: 30
```
To reconfigure radio and hardware settings after installation, run:
```bash
sudo bash setup-radio-config.sh /etc/pymc_repeater
+26
View File
@@ -343,6 +343,32 @@ mqtt:
# use_jwt_auth: true
# enabled: true
# pyMC_Glass control-plane integration (optional)
glass:
# Enable repeater -> pyMC_Glass /inform loop
enabled: false
# Base URL of Glass backend
# Example local dev: "http://localhost:8080"
# Example production: "https://glass.example.com"
base_url: "http://localhost:8080"
# Inform interval in seconds (used as initial/default interval;
# backend may override via noop.interval response)
inform_interval_seconds: 30
# HTTP timeout per inform request
request_timeout_seconds: 10
# Verify TLS certificates when using HTTPS
verify_tls: true
# Optional bearer token for future authenticated inform endpoints
api_token: ""
# Where cert_renewal payloads are written
cert_store_dir: "/etc/pymc_repeater/glass"
logging:
# Log level: DEBUG, INFO, WARNING, ERROR
level: INFO
+154 -24
View File
@@ -13,6 +13,10 @@ SERVICE_USER="repeater"
SERVICE_NAME="pymc-repeater"
SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}"
# R2 Wheels Configuration improves install speed on ARM devices
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
R2_ENABLED=1 # Set to 0 to disable R2 wheels and always build from source
# ---------------------------------------------------------------------------
# Virtual-environment helpers
# ---------------------------------------------------------------------------
@@ -259,8 +263,10 @@ install_repeater() {
return
fi
# Welcome screen
$DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70
# Welcome screen (Bypass if the script was passd with the "install" option, assume we want a silent install)
if [[ "${1:-}" != "install" ]]; then
$DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70
fi
# SPI Check - Universal approach that works on all boards (skip for CH341 USB-SPI adapter)
SPI_MISSING=0
@@ -396,9 +402,15 @@ install_repeater() {
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config
# Configure polkit for passwordless service restart
echo ">>> Configuring polkit for service management..."
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
# Work out which version of polkit is installed
POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}')
if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then
echo "Polkit 0.106 or greater detected, using rules file"
echo ">>> Configuring polkit for service management..."
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "pymc-repeater.service" &&
@@ -407,7 +419,20 @@ polkit.addRule(function(action, subject) {
}
});
EOF
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
else
echo "Polkit 0.105 or less detected, using pkla file"
mkdir -p /etc/polkit-1/localauthority/50-local.d
cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF'
[Allow repeater to restart pymc-repeater service]
Identity=unix-user:repeater
Action=org.freedesktop.systemd1.manage-units
ResultAny=yes
ResultInactive=yes
ResultActive=yes
EOF
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla
fi
# Also configure sudoers as fallback for service restart
echo ">>> Configuring sudoers for service management..."
@@ -462,7 +487,22 @@ fi
# ---- Remove old system-level packages to avoid confusion ----
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
# ---- Install into the venv ----
# ---- Try R2 wheels first for faster OTA upgrades ----
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
MACHINE_ARCH=$(uname -m)
case "$MACHINE_ARCH" in
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
esac
if [ -n "$ARCH_TAG" ]; then
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..."
"$VENV_PIP" install --no-index --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true
fi
# ---- Install pymc_repeater from git ----
exec "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
@@ -496,17 +536,42 @@ UPGRADEEOF
else
export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5"
fi
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
# We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform.
if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
fi
echo "Note: Using optimized binary wheels for faster installation"
echo ""
# Ensure venv exists
ensure_venv
# Install into the venv (clean, no system-packages flags needed)
echo "Installing pymc_repeater into venv ($VENV_DIR)..."
# Attempt R2 wheels first for faster installation
if [ "$R2_ENABLED" -eq 1 ]; then
MACHINE_ARCH=$(uname -m)
case "$MACHINE_ARCH" in
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
esac
if [ -n "$ARCH_TAG" ]; then
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..."
echo " Trying install from R2 pre-built wheels..."
"$VENV_PIP" install --no-index --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0
if [ "$R2_SUCCESS" -eq 1 ]; then
echo " ✓ R2 wheels installed"
else
echo " - R2 wheels unavailable for this platform/tag, falling back"
fi
fi
fi
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
echo ""
echo "✓ Python package installation completed successfully!"
@@ -566,7 +631,9 @@ UPGRADEEOF
fi
echo "═══════════════════════════════════════════════════════════════"
echo ""
read -p "Press Enter to return to main menu..." || true
if [[ "${1:-}" != "install" ]]; then #Headless install support
read -p "Press Enter to return to main menu..." || true
fi
else
show_error "Installation completed but service failed to start!\n\nCheck logs from the main menu for details."
fi
@@ -732,18 +799,25 @@ upgrade_repeater() {
echo " ✓ User groups updated"
echo "[6/9] Fixing permissions..."
# Venv stays root-owned (pip runs as root); service user only needs read+execute
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true
chown root:root "$INSTALL_DIR" 2>/dev/null || true
chmod 755 "$INSTALL_DIR" 2>/dev/null || true
chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true
chmod 755 /var/lib/pymc_repeater 2>/dev/null || true
# Pre-create the .config directory that the service will need
mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater 2>/dev/null || true
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config 2>/dev/null || true
# Configure polkit for passwordless service restart
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}')
if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then
echo "Polkit 0.106 or greater detected, using rules file"
echo ">>> Configuring polkit for service management..."
mkdir -p /etc/polkit-1/rules.d
cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF'
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "pymc-repeater.service" &&
@@ -752,7 +826,20 @@ polkit.addRule(function(action, subject) {
}
});
EOF
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
else
echo "Polkit 0.105 or less detected, using pkla file"
mkdir -p /etc/polkit-1/localauthority/50-local.d
cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF'
[Allow repeater to restart pymc-repeater service]
Identity=unix-user:repeater
Action=org.freedesktop.systemd1.manage-units
ResultAny=yes
ResultInactive=yes
ResultActive=yes
EOF
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla
fi
# Also configure sudoers as fallback for service restart
mkdir -p /etc/sudoers.d
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
@@ -804,11 +891,26 @@ fi
# ---- Remove old system-level packages to avoid confusion ----
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
# ---- Install into the venv ----
exec "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
# ---- Try R2 wheels first for faster OTA upgrades ----
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
MACHINE_ARCH=$(uname -m)
case "$MACHINE_ARCH" in
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
esac
if [ -n "$ARCH_TAG" ]; then
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..."
"$VENV_PIP" install --no-index --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true
fi
# ---- Install pymc_repeater from git ----
exec "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
UPGRADEEOF
chmod 0755 /usr/local/bin/pymc-do-upgrade
echo " ✓ Permissions updated"
@@ -837,8 +939,11 @@ UPGRADEEOF
export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5"
fi
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
# We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform.
if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then
# Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi)
export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil
fi
echo "Note: Using optimized binary wheels for faster installation"
echo ""
@@ -847,6 +952,30 @@ UPGRADEEOF
# Install into the venv (clean, no system-packages flags needed)
echo "Upgrading pymc_repeater into venv ($VENV_DIR)..."
# Attempt R2 wheels first for faster installation
if [ "$R2_ENABLED" -eq 1 ]; then
MACHINE_ARCH=$(uname -m)
case "$MACHINE_ARCH" in
aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;;
armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;;
x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;;
*) ARCH_TAG=""; PLATFORM_TAG="" ;;
esac
if [ -n "$ARCH_TAG" ]; then
PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311")
WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}"
echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..."
echo " Trying install from R2 pre-built wheels..."
"$VENV_PIP" install --no-index --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0
if [ "$R2_SUCCESS" -eq 1 ]; then
echo " ✓ R2 wheels installed"
else
echo " - R2 wheels unavailable for this platform/tag, falling back"
fi
fi
fi
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
echo ""
echo "✓ Package and dependencies upgraded successfully!"
@@ -966,7 +1095,8 @@ uninstall_repeater() {
systemctl daemon-reload
echo "50"; echo "# Removing polkit and sudoers rules..."
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules
rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules || true
rm -f /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla || true
rm -f /etc/sudoers.d/pymc-repeater
rm -f /usr/local/bin/pymc-do-upgrade
@@ -1215,7 +1345,7 @@ fi
# Handle command line arguments
case "$1" in
"install")
install_repeater
install_repeater install
exit 0
;;
"upgrade")
+11
View File
@@ -68,6 +68,17 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if "mesh" not in config:
config["mesh"] = {}
if "glass" not in config:
config["glass"] = {
"enabled": False,
"base_url": "http://localhost:8080",
"inform_interval_seconds": 30,
"request_timeout_seconds": 10,
"verify_tls": True,
"api_token": "",
"cert_store_dir": "/etc/pymc_repeater/glass",
}
# Ensure repeater.security exists with defaults for upgrades from older configs
if "repeater" not in config:
config["repeater"] = {}
+1 -1
View File
@@ -69,7 +69,7 @@ class ConfigManager:
# Default sections to update if not specified
if sections is None:
sections = ['repeater', 'delays', 'radio', 'acl', 'identities']
sections = ['repeater', 'delays', 'radio', 'acl', 'identities', 'glass']
# Update each section
for section in sections:
+2 -4
View File
@@ -1,7 +1,5 @@
#from .mqtt_handler import MQTTHandler
from .glass_handler import GlassHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_collector import StorageCollector
#__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"]
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector"]
__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"]
+957
View File
@@ -0,0 +1,957 @@
import asyncio
import hashlib
import json
import logging
import os
import ssl
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from urllib import error, request
import psutil
try:
import paho.mqtt.client as mqtt
except ImportError:
mqtt = None
from repeater import __version__
from repeater.service_utils import restart_service
logger = logging.getLogger("GlassHandler")
_SENSITIVE_KEY_MARKERS = (
"password",
"passphrase",
"secret",
"token",
"private_key",
"identity_key",
"client_key",
"api_key",
)
_SENSITIVE_KEY_EXCEPTIONS = ("pubkey", "public_key")
class GlassHandler:
def __init__(self, config: dict, daemon_instance=None, config_manager=None):
self.config = config
self.daemon_instance = daemon_instance
self.config_manager = config_manager
self.enabled = False
self.base_url = "http://localhost:8080"
self.request_timeout_seconds = 10
self.verify_tls = True
self.api_token = ""
self.inform_interval_seconds = 30
self.cert_store_dir = "/etc/pymc_repeater/glass"
self._cert_expires_at: Optional[str] = None
self.mqtt_enabled = False
self.mqtt_broker_host = "localhost"
self.mqtt_broker_port = 1883
self.mqtt_base_topic = "glass"
self.mqtt_tls_enabled = False
self.mqtt_username: Optional[str] = None
self.mqtt_password: Optional[str] = None
self.client_cert_path: Optional[str] = None
self.client_key_path: Optional[str] = None
self.ca_cert_path: Optional[str] = None
self._mqtt_client = None
self._mqtt_ready = False
self._mqtt_runtime_signature: Optional[
Tuple[
str,
int,
str,
bool,
bool,
Optional[str],
Optional[str],
Optional[str],
Optional[str],
Optional[str],
]
] = None
self._managed_settings_filename = "managed.json"
self._task: Optional[asyncio.Task] = None
self._stop_event: Optional[asyncio.Event] = None
self._pending_command_results: List[Dict[str, Any]] = []
self._pending_lock = asyncio.Lock()
self._reload_runtime_settings()
async def start(self) -> None:
self._reload_runtime_settings()
if not self.enabled:
logger.info("Glass integration disabled")
self._close_mqtt_publisher()
return
if self._task and not self._task.done():
return
self._sync_mqtt_publisher()
self._stop_event = asyncio.Event()
self._task = asyncio.create_task(self._run_loop(), name="glass-inform-loop")
logger.info(
"Glass integration started (base_url=%s, inform_interval=%ss)",
self.base_url,
self.inform_interval_seconds,
)
async def stop(self) -> None:
if self._task:
if self._stop_event:
self._stop_event.set()
try:
await self._task
except Exception as exc:
logger.debug("Glass task stop ignored exception: %s", exc)
finally:
self._task = None
self._stop_event = None
self._close_mqtt_publisher()
def _reload_runtime_settings(self) -> None:
glass_cfg = self.config.get("glass", {})
self.enabled = bool(glass_cfg.get("enabled", False))
base_url = str(glass_cfg.get("base_url", "http://localhost:8080")).strip()
self.base_url = base_url.rstrip("/") if base_url else "http://localhost:8080"
self.request_timeout_seconds = max(3, int(glass_cfg.get("request_timeout_seconds", 10)))
self.verify_tls = bool(glass_cfg.get("verify_tls", True))
self.api_token = str(glass_cfg.get("api_token", "") or "").strip()
self.inform_interval_seconds = self._clamp_interval(
int(glass_cfg.get("inform_interval_seconds", self.inform_interval_seconds))
)
self.cert_store_dir = str(
glass_cfg.get("cert_store_dir", "/etc/pymc_repeater/glass") or "/etc/pymc_repeater/glass"
)
self.client_cert_path = (
str(glass_cfg.get("client_cert_path")).strip()
if glass_cfg.get("client_cert_path")
else None
)
self.client_key_path = (
str(glass_cfg.get("client_key_path")).strip()
if glass_cfg.get("client_key_path")
else None
)
self.ca_cert_path = (
str(glass_cfg.get("ca_cert_path")).strip()
if glass_cfg.get("ca_cert_path")
else None
)
managed_cfg = self._load_managed_settings()
parsed_base_url = urlparse(self.base_url)
default_host = parsed_base_url.hostname or "localhost"
self.mqtt_enabled = bool(managed_cfg.get("mqtt_enabled", False))
host_value = managed_cfg.get("mqtt_broker_host", default_host)
self.mqtt_broker_host = str(host_value or default_host).strip() or default_host
try:
self.mqtt_broker_port = max(1, int(managed_cfg.get("mqtt_broker_port", 1883)))
except (TypeError, ValueError):
self.mqtt_broker_port = 1883
topic_value = managed_cfg.get("mqtt_base_topic", "glass")
self.mqtt_base_topic = str(topic_value or "glass").strip("/")
self.mqtt_tls_enabled = bool(managed_cfg.get("mqtt_tls_enabled", False))
username = managed_cfg.get("mqtt_username")
password = managed_cfg.get("mqtt_password")
self.mqtt_username = str(username).strip() if isinstance(username, str) and username else None
self.mqtt_password = str(password) if isinstance(password, str) and password else None
def _managed_settings_path(self) -> Path:
return Path(self.cert_store_dir) / self._managed_settings_filename
def _load_managed_settings(self) -> Dict[str, Any]:
path = self._managed_settings_path()
if not path.exists():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("Invalid Glass managed settings file at %s: %s", path, exc)
return {}
if not isinstance(raw, dict):
logger.warning("Ignoring non-object Glass managed settings file at %s", path)
return {}
return raw
def _save_managed_settings(self, updates: Dict[str, Any], *, replace: bool) -> Tuple[bool, str]:
if not isinstance(updates, dict):
return False, "glass_managed must be an object"
path = self._managed_settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
current = {} if replace else self._load_managed_settings()
if not isinstance(current, dict):
current = {}
merged = dict(current)
merged.update(updates)
try:
path.write_text(
json.dumps(merged, indent=2, sort_keys=True),
encoding="utf-8",
)
os.chmod(path, 0o600)
return True, "Managed settings updated"
except Exception as exc:
return False, f"Failed writing managed settings: {exc}"
async def _run_loop(self) -> None:
while self._stop_event and not self._stop_event.is_set():
self._reload_runtime_settings()
self._sync_mqtt_publisher()
try:
interval = await self._inform_once()
except Exception as exc:
logger.warning("Glass inform failed: %s", exc)
interval = self.inform_interval_seconds
wait_seconds = self._clamp_interval(interval)
if not self._stop_event:
break
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=wait_seconds)
except asyncio.TimeoutError:
continue
async def _inform_once(self) -> int:
self._reload_runtime_settings()
if not self.enabled:
return self.inform_interval_seconds
payload = await self._build_inform_payload()
response = await self._post_inform(payload)
if payload.get("command_results"):
async with self._pending_lock:
self._pending_command_results = []
response_type = str(response.get("type", "noop"))
response_interval = response.get("interval")
if response_type == "command":
await self._handle_command_response(response)
elif response_type == "config_update":
ok, message = self._apply_config_update(
response.get("config", {}),
str(response.get("merge_mode", "patch")),
)
if ok:
logger.info("Applied Glass config update")
else:
logger.warning("Failed to apply Glass config update: %s", message)
elif response_type == "cert_renewal":
ok, message = self._apply_cert_renewal(response)
if ok:
logger.info("Applied Glass certificate renewal")
else:
logger.warning("Failed to apply Glass certificate renewal: %s", message)
elif response_type == "upgrade":
logger.warning("Glass upgrade action received but not implemented on repeater")
elif response_type != "noop":
logger.warning("Unknown Glass response type: %s", response_type)
if isinstance(response_interval, int):
self.inform_interval_seconds = self._clamp_interval(response_interval)
return self.inform_interval_seconds
async def _build_inform_payload(self) -> Dict[str, Any]:
if not self.daemon_instance or not getattr(self.daemon_instance, "local_identity", None):
raise RuntimeError("Local identity not available for Glass inform")
stats = self.daemon_instance.get_stats() if self.daemon_instance else {}
local_identity = self.daemon_instance.local_identity
public_key = bytes(local_identity.get_public_key()).hex()
node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater")
uptime_seconds = int(stats.get("uptime_seconds", 0))
if uptime_seconds <= 0:
repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
if repeater_handler and getattr(repeater_handler, "start_time", None):
uptime_seconds = max(0, int(time.time() - repeater_handler.start_time))
tx_total = int(stats.get("sent_flood_count", 0)) + int(stats.get("sent_direct_count", 0))
if tx_total <= 0:
tx_total = int(stats.get("forwarded_count", 0))
command_results = await self._get_pending_command_results()
settings_snapshot = self._build_settings_snapshot()
location = self._extract_location_from_settings(settings_snapshot)
return {
"type": "inform",
"version": 1,
"node_name": node_name,
"pubkey": f"0x{public_key}",
"software_version": __version__,
"state": self.config.get("repeater", {}).get("mode", "forward"),
"location": location,
"uptime_seconds": uptime_seconds,
"config_hash": self._compute_config_hash(self.config),
"cert_expires_at": self._cert_expires_at,
"system": self._collect_system_stats(),
"radio": {
"frequency": int(self.config.get("radio", {}).get("frequency", 0)),
"spreading_factor": int(self.config.get("radio", {}).get("spreading_factor", 7)),
"bandwidth": int(self.config.get("radio", {}).get("bandwidth", 0)),
"tx_power": int(self.config.get("radio", {}).get("tx_power", 0)),
"noise_floor_dbm": stats.get("noise_floor_dbm"),
"mode": self.config.get("repeater", {}).get("mode", "forward"),
},
"counters": {
"rx_total": int(stats.get("rx_count", 0)),
"tx_total": max(0, tx_total),
"forwarded": int(stats.get("forwarded_count", 0)),
"dropped": int(stats.get("dropped_count", 0)),
"duplicates": int(stats.get("flood_dup_count", 0))
+ int(stats.get("direct_dup_count", 0)),
"airtime_percent": float(stats.get("utilization_percent", 0.0)),
},
"settings": settings_snapshot,
"command_results": command_results,
}
def _build_settings_snapshot(self) -> Dict[str, Any]:
normalized = self._normalize_for_hash(self.config)
sanitized = self._sanitize_settings_for_export(normalized)
if isinstance(sanitized, dict):
return sanitized
return {}
def _sanitize_settings_for_export(self, value: Any, key_name: Optional[str] = None) -> Any:
if isinstance(value, dict):
output: Dict[str, Any] = {}
for child_key, child_value in value.items():
if self._is_sensitive_key(child_key):
output[child_key] = "<redacted>"
continue
output[child_key] = self._sanitize_settings_for_export(child_value, child_key)
return output
if isinstance(value, list):
return [self._sanitize_settings_for_export(item, key_name) for item in value]
return value
@staticmethod
def _is_sensitive_key(key: str) -> bool:
lowered = str(key).lower()
if any(exception in lowered for exception in _SENSITIVE_KEY_EXCEPTIONS):
return False
return any(marker in lowered for marker in _SENSITIVE_KEY_MARKERS)
@staticmethod
def _normalize_location(value: Any) -> Optional[str]:
if isinstance(value, str):
text = value.strip()
if not text:
return None
parts = [part.strip() for part in text.split(",")]
if len(parts) != 2:
return None
try:
lat = float(parts[0])
lng = float(parts[1])
except ValueError:
return None
elif isinstance(value, dict):
lat = value.get("lat", value.get("latitude"))
lng = value.get("lng", value.get("longitude"))
try:
if lat is None or lng is None:
return None
lat = float(lat)
lng = float(lng)
except (TypeError, ValueError):
return None
elif isinstance(value, (list, tuple)) and len(value) == 2:
try:
lat = float(value[0])
lng = float(value[1])
except (TypeError, ValueError):
return None
else:
return None
if lat < -90 or lat > 90 or lng < -180 or lng > 180:
return None
return f"{lat:.6f},{lng:.6f}"
def _extract_location_from_settings(self, settings: Dict[str, Any]) -> Optional[str]:
repeater_settings = settings.get("repeater")
repeater_dict = repeater_settings if isinstance(repeater_settings, dict) else {}
candidates = [
settings.get("location"),
repeater_dict.get("location"),
settings.get("gps"),
repeater_dict.get("gps"),
{
"lat": repeater_dict.get("latitude"),
"lng": repeater_dict.get("longitude"),
},
]
for candidate in candidates:
location = self._normalize_location(candidate)
if location:
return location
return None
def _collect_system_stats(self) -> Dict[str, Any]:
temperature_c = None
try:
temperatures = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {}
if temperatures:
for values in temperatures.values():
if values:
temperature_c = values[0].current
break
except Exception:
temperature_c = None
load_avg_1m = None
try:
if hasattr(os, "getloadavg"):
load_avg_1m = float(os.getloadavg()[0])
except Exception:
load_avg_1m = None
return {
"cpu_percent": float(psutil.cpu_percent(interval=None)),
"memory_percent": float(psutil.virtual_memory().percent),
"disk_percent": float(psutil.disk_usage("/").percent),
"temperature_c": temperature_c,
"load_avg_1m": load_avg_1m,
}
async def _post_inform(self, payload: Dict[str, Any]) -> Dict[str, Any]:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._post_inform_sync, payload)
def _post_inform_sync(self, payload: Dict[str, Any]) -> Dict[str, Any]:
url = f"{self.base_url}/inform"
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
body = json.dumps(payload).encode("utf-8")
req = request.Request(url=url, data=body, method="POST", headers=headers)
ssl_context = self._build_ssl_context(url)
try:
with request.urlopen(
req,
timeout=self.request_timeout_seconds,
context=ssl_context,
) as response:
response_bytes = response.read()
except error.HTTPError as exc:
details = ""
try:
details = exc.read().decode("utf-8")
except Exception:
details = str(exc)
raise RuntimeError(f"HTTP {exc.code}: {details}") from exc
except error.URLError as exc:
raise RuntimeError(f"Connection error: {exc}") from exc
if not response_bytes:
return {"type": "noop", "interval": self.inform_interval_seconds}
try:
response_payload = json.loads(response_bytes.decode("utf-8"))
except Exception as exc:
raise RuntimeError("Invalid JSON response from Glass backend") from exc
if not isinstance(response_payload, dict):
raise RuntimeError("Invalid response payload from Glass backend")
return response_payload
def _build_ssl_context(self, url: str) -> Optional[ssl.SSLContext]:
if not str(url).startswith("https"):
return None
if self.verify_tls:
if self.ca_cert_path:
ca_path = self._require_ssl_file(self.ca_cert_path, "ca_cert_path")
context = ssl.create_default_context(cafile=ca_path)
else:
context = ssl.create_default_context()
else:
context = ssl._create_unverified_context()
if self.client_cert_path or self.client_key_path:
cert_path = self._require_ssl_file(self.client_cert_path, "client_cert_path")
key_path = self._require_ssl_file(self.client_key_path, "client_key_path")
context.load_cert_chain(certfile=cert_path, keyfile=key_path)
return context
@staticmethod
def _require_ssl_file(path_value: Optional[str], field_name: str) -> str:
if not path_value or not str(path_value).strip():
raise RuntimeError(f"Missing {field_name} for Glass TLS configuration")
normalized = str(path_value).strip()
if not Path(normalized).exists():
raise RuntimeError(f"Configured {field_name} does not exist: {normalized}")
return normalized
async def _handle_command_response(self, response: Dict[str, Any]) -> None:
command_id = str(response.get("command_id", "")).strip()
action = str(response.get("action", "")).strip()
params = response.get("params", {})
if not command_id or not action:
logger.warning("Glass command response missing command_id or action")
return
success = False
message = "Action failed"
details: Optional[Dict[str, Any]] = None
try:
success, message, details = await self._execute_command_action(action, params)
except Exception as exc:
success = False
message = f"Exception executing action: {exc}"
details = None
await self._queue_command_result(
command_id=command_id,
status="success" if success else "failed",
message=message,
details=details,
)
async def _execute_command_action(
self,
action: str,
params: Any,
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
params = params if isinstance(params, dict) else {}
if action == "restart_service":
success, message = restart_service()
return success, message, None
if action == "send_advert":
if not self.daemon_instance or not hasattr(self.daemon_instance, "send_advert"):
return False, "send_advert unavailable", None
success = await self.daemon_instance.send_advert()
return success, "Advert sent" if success else "Failed to send advert", None
if action == "set_mode":
mode = str(params.get("mode", "")).strip()
if mode not in ("forward", "monitor", "no_tx"):
return False, "Invalid mode parameter", None
success, message = self._apply_config_update(
{"repeater": {"mode": mode}},
merge_mode="patch",
)
return success, message, None
if action == "set_inform_interval":
interval = params.get("interval_seconds", params.get("interval"))
if not isinstance(interval, int):
return False, "interval_seconds must be an integer", None
interval = self._clamp_interval(interval)
self.inform_interval_seconds = interval
success, message = self._apply_config_update(
{"glass": {"inform_interval_seconds": interval}},
merge_mode="patch",
)
return success, message, None
if action == "rotate_cert":
return True, "Certificate rotation requested", None
if action == "config_update":
config_patch = params.get("config", params)
merge_mode = str(params.get("merge_mode", "patch"))
success, message = self._apply_config_update(config_patch, merge_mode=merge_mode)
return success, message, None
if action == "transport_keys_sync":
success, message, details = self._apply_transport_keys_sync(params)
return success, message, details
if action == "set_radio":
radio_values = params.get("radio", params)
if not isinstance(radio_values, dict):
return False, "radio settings must be an object", None
success, message = self._apply_config_update({"radio": radio_values}, merge_mode="patch")
return success, message, None
if action == "run_diagnostic":
stats = self.daemon_instance.get_stats() if self.daemon_instance else {}
return True, (
f"rx={int(stats.get('rx_count', 0))}, "
f"tx={int(stats.get('forwarded_count', 0))}, "
f"dropped={int(stats.get('dropped_count', 0))}"
), None
if action == "export_config":
normalized_config = self._normalize_for_hash(self.config)
return (
True,
"Configuration exported",
{
"config": normalized_config,
"config_hash": self._compute_config_hash(self.config),
},
)
return False, f"Unsupported action: {action}", None
def _apply_config_update(self, updates: Any, merge_mode: str = "patch") -> Tuple[bool, str]:
if not isinstance(updates, dict) or not updates:
return False, "Config update payload must be a non-empty object"
merge_mode = merge_mode.lower().strip()
if merge_mode not in ("patch", "replace"):
return False, f"Unsupported merge_mode: {merge_mode}"
updates_to_apply = dict(updates)
managed_updates = updates_to_apply.pop("glass_managed", None)
if managed_updates is not None:
managed_ok, managed_message = self._save_managed_settings(
managed_updates,
replace=merge_mode == "replace",
)
if not managed_ok:
return False, managed_message
self._reload_runtime_settings()
self._sync_mqtt_publisher()
if not updates_to_apply:
return True, "Managed settings updated"
sections = list(updates_to_apply.keys())
if merge_mode == "replace":
for section, value in updates_to_apply.items():
self.config[section] = value
if self.config_manager:
saved = self.config_manager.save_to_file()
live_updated = self.config_manager.live_update_daemon(sections)
return (
bool(saved and live_updated),
"Config replaced" if saved and live_updated else "Failed to persist replace update",
)
return True, "Config replaced"
# patch mode
if self.config_manager:
result = self.config_manager.update_and_save(
updates=updates_to_apply,
live_update=True,
live_update_sections=sections,
)
if result.get("success"):
if "glass" in sections:
self._reload_runtime_settings()
self._sync_mqtt_publisher()
return True, "Config patched"
return False, str(result.get("error", "Failed to patch config"))
self._deep_merge(self.config, updates_to_apply)
if "glass" in sections:
self._reload_runtime_settings()
self._sync_mqtt_publisher()
return True, "Config patched"
def _get_sqlite_handler(self):
if not self.daemon_instance:
return None
repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
storage = getattr(repeater_handler, "storage", None)
return getattr(storage, "sqlite_handler", None)
def _apply_transport_keys_sync(
self,
params: Dict[str, Any],
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
if not isinstance(params, dict):
return False, "transport_keys_sync params must be an object", None
entries = params.get("transport_keys")
if not isinstance(entries, list):
return False, "transport_keys_sync payload must include a transport_keys list", None
sqlite_handler = self._get_sqlite_handler()
if sqlite_handler is None:
return False, "SQLite handler unavailable for transport key sync", None
try:
result = sqlite_handler.sync_transport_keys(entries)
except Exception as exc:
return False, f"Transport key sync failed: {exc}", None
payload_hash = params.get("payload_hash")
details: Dict[str, Any] = {
"applied_nodes": int(result.get("applied_nodes", 0)),
"generated_keys": int(result.get("generated_keys", 0)),
}
if isinstance(payload_hash, str) and payload_hash.strip():
details["payload_hash"] = payload_hash
return True, f"Applied transport key sync ({details['applied_nodes']} nodes)", details
def _apply_cert_renewal(self, response: Dict[str, Any]) -> Tuple[bool, str]:
client_cert = response.get("client_cert")
client_key = response.get("client_key")
ca_cert = response.get("ca_cert")
if not all(isinstance(item, str) and item.strip() for item in (client_cert, client_key, ca_cert)):
return False, "Missing certificate payload values"
cert_dir = Path(self.cert_store_dir)
cert_dir.mkdir(parents=True, exist_ok=True)
client_cert_path = cert_dir / "glass-client.crt"
client_key_path = cert_dir / "glass-client.key"
ca_cert_path = cert_dir / "glass-ca.crt"
client_cert_path.write_text(client_cert, encoding="utf-8")
client_key_path.write_text(client_key, encoding="utf-8")
ca_cert_path.write_text(ca_cert, encoding="utf-8")
os.chmod(client_key_path, 0o600)
return self._apply_config_update(
{
"glass": {
"client_cert_path": str(client_cert_path),
"client_key_path": str(client_key_path),
"ca_cert_path": str(ca_cert_path),
}
},
merge_mode="patch",
)
async def _get_pending_command_results(self) -> List[Dict[str, Any]]:
async with self._pending_lock:
return list(self._pending_command_results)
async def _queue_command_result(
self,
command_id: str,
status: str,
message: str,
details: Optional[Dict[str, Any]] = None,
) -> None:
completed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
result = {
"command_id": command_id,
"status": status,
"message": message[:1024] if message else "",
"completed_at": completed_at,
}
if details:
result["details"] = details
async with self._pending_lock:
self._pending_command_results.append(result)
def publish_telemetry(self, record_type: str, record: Dict[str, Any]) -> None:
if not self.enabled or not self.mqtt_enabled or not self._mqtt_ready:
return
if not self._mqtt_client:
return
node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater")
event_type = "event"
event_name: Optional[str] = record_type
if record_type in ("packet", "advert"):
event_type = record_type
event_name = None
topic = self._mqtt_topic_for_record(node_name=node_name, record_type=record_type)
timestamp = self._to_rfc3339_timestamp(record.get("timestamp"))
payload = self._normalize_for_hash(record)
envelope: Dict[str, Any] = {
"version": 1,
"type": event_type,
"topic": topic,
"node_name": node_name,
"timestamp": timestamp,
"payload": payload,
}
if event_type == "event" and event_name:
envelope["event_name"] = event_name
try:
message = json.dumps(envelope, separators=(",", ":"), sort_keys=True, default=str)
self._mqtt_client.publish(topic, message, qos=0, retain=False)
except Exception as exc:
logger.debug("Failed publishing Glass telemetry MQTT message: %s", exc)
def _mqtt_topic_for_record(self, *, node_name: str, record_type: str) -> str:
base = self.mqtt_base_topic.strip("/") or "glass"
if record_type in ("packet", "advert"):
return f"{base}/{node_name}/{record_type}"
return f"{base}/{node_name}/event/{record_type}"
def _to_rfc3339_timestamp(self, value: Any) -> str:
if isinstance(value, (int, float)):
dt = datetime.fromtimestamp(float(value), timezone.utc)
elif isinstance(value, str):
normalized = value.strip()
if normalized.endswith("Z"):
return normalized
try:
dt = datetime.fromisoformat(normalized)
except ValueError:
dt = datetime.now(timezone.utc)
elif isinstance(value, datetime):
dt = value
else:
dt = datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
return dt.isoformat().replace("+00:00", "Z")
def _init_mqtt_publisher(self) -> None:
if not self.mqtt_enabled:
self._close_mqtt_publisher()
return
if mqtt is None:
logger.warning("Glass MQTT telemetry publishing enabled but paho-mqtt is unavailable")
self._close_mqtt_publisher()
return
if self._mqtt_client is not None:
return
try:
client = mqtt.Client()
if self.mqtt_username:
client.username_pw_set(self.mqtt_username, self.mqtt_password)
if self.mqtt_tls_enabled:
ca_certs = self._require_ssl_file(self.ca_cert_path, "ca_cert_path") if self.ca_cert_path else None
certfile = None
keyfile = None
if self.client_cert_path or self.client_key_path:
certfile = self._require_ssl_file(self.client_cert_path, "client_cert_path")
keyfile = self._require_ssl_file(self.client_key_path, "client_key_path")
cert_reqs = ssl.CERT_REQUIRED if self.verify_tls else ssl.CERT_NONE
client.tls_set(
ca_certs=ca_certs,
certfile=certfile,
keyfile=keyfile,
cert_reqs=cert_reqs,
tls_version=ssl.PROTOCOL_TLS_CLIENT,
)
if not self.verify_tls:
client.tls_insecure_set(True)
client.on_connect = self._on_mqtt_connect
client.on_disconnect = self._on_mqtt_disconnect
client.connect_async(self.mqtt_broker_host, self.mqtt_broker_port, 60)
client.loop_start()
self._mqtt_client = client
self._mqtt_runtime_signature = self._current_mqtt_signature()
logger.info(
"Glass MQTT telemetry publisher started (%s:%s, base_topic=%s)",
self.mqtt_broker_host,
self.mqtt_broker_port,
self.mqtt_base_topic,
)
except Exception as exc:
self._mqtt_client = None
self._mqtt_ready = False
self._mqtt_runtime_signature = None
logger.warning("Failed to start Glass MQTT telemetry publisher: %s", exc)
def _close_mqtt_publisher(self) -> None:
client = self._mqtt_client
self._mqtt_client = None
self._mqtt_ready = False
self._mqtt_runtime_signature = None
if client is None:
return
try:
client.loop_stop()
client.disconnect()
except Exception as exc:
logger.debug("Error stopping Glass MQTT telemetry publisher: %s", exc)
def _on_mqtt_connect(self, _client, _userdata, _flags, reason_code, _properties=None) -> None:
rc = getattr(reason_code, "value", reason_code)
if rc == 0:
self._mqtt_ready = True
logger.info("Glass MQTT telemetry publisher connected")
return
self._mqtt_ready = False
logger.warning("Glass MQTT telemetry publisher connect failed (code=%s)", rc)
def _on_mqtt_disconnect(self, _client, _userdata, reason_code, _properties=None) -> None:
self._mqtt_ready = False
rc = getattr(reason_code, "value", reason_code)
if rc:
logger.warning("Glass MQTT telemetry publisher disconnected (code=%s)", rc)
def _current_mqtt_signature(
self,
) -> Tuple[str, int, str, bool, bool, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]]:
return (
self.mqtt_broker_host,
self.mqtt_broker_port,
self.mqtt_base_topic,
self.mqtt_tls_enabled,
self.verify_tls,
self.ca_cert_path,
self.client_cert_path,
self.client_key_path,
self.mqtt_username,
self.mqtt_password,
)
def _sync_mqtt_publisher(self) -> None:
if not self.enabled or not self.mqtt_enabled:
self._close_mqtt_publisher()
return
if mqtt is None:
self._close_mqtt_publisher()
return
signature = self._current_mqtt_signature()
if self._mqtt_client is None:
self._init_mqtt_publisher()
return
if self._mqtt_runtime_signature != signature:
self._close_mqtt_publisher()
self._init_mqtt_publisher()
@staticmethod
def _deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None:
for key, value in source.items():
if (
isinstance(value, dict)
and isinstance(target.get(key), dict)
):
GlassHandler._deep_merge(target[key], value)
else:
target[key] = value
@staticmethod
def _normalize_for_hash(value: Any) -> Any:
if isinstance(value, bytes):
return value.hex()
if isinstance(value, dict):
return {k: GlassHandler._normalize_for_hash(v) for k, v in value.items()}
if isinstance(value, list):
return [GlassHandler._normalize_for_hash(v) for v in value]
return value
@staticmethod
def _compute_config_hash(config: dict) -> str:
normalized = GlassHandler._normalize_for_hash(config)
encoded = json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8")
digest = hashlib.sha256(encoded).hexdigest()
return f"sha256:{digest}"
@staticmethod
def _clamp_interval(interval_seconds: int) -> int:
if interval_seconds < 5:
return 5
if interval_seconds > 3600:
return 3600
return interval_seconds
+116
View File
@@ -1588,6 +1588,122 @@ class SQLiteHandler:
logger.error(f"Failed to delete transport key: {e}")
return False
def sync_transport_keys(self, entries: List[Dict[str, Any]]) -> Dict[str, int]:
"""
Replace transport key tree from a canonical Glass payload.
Args:
entries: Flat list of nodes with fields:
- node_id: unique stable id in payload
- name: key/group display name
- flood_policy: 'allow' | 'deny'
- transport_key: optional explicit key material
- parent_node_id: optional parent node reference
Returns:
Dict containing applied node count and generated key count.
"""
if not isinstance(entries, list):
raise ValueError("transport_keys payload must be a list")
normalized: Dict[str, Dict[str, Any]] = {}
used_names: set[str] = set()
for raw in entries:
if not isinstance(raw, dict):
raise ValueError("Each transport key entry must be an object")
node_id = str(raw.get("node_id", "")).strip()
name = str(raw.get("name", "")).strip()
flood_policy = str(raw.get("flood_policy", "")).strip().lower()
parent_node_id = raw.get("parent_node_id")
transport_key = raw.get("transport_key")
if not node_id:
raise ValueError("transport key entry is missing node_id")
if node_id in normalized:
raise ValueError(f"Duplicate node_id in payload: {node_id}")
if not name:
raise ValueError(f"transport key entry '{node_id}' is missing name")
if name in used_names:
raise ValueError(f"Duplicate transport key name in payload: {name}")
if flood_policy not in {"allow", "deny"}:
raise ValueError(f"Invalid flood_policy for '{name}': {flood_policy}")
if transport_key is not None and not isinstance(transport_key, str):
raise ValueError(f"transport_key for '{name}' must be a string or null")
normalized[node_id] = {
"node_id": node_id,
"name": name,
"flood_policy": flood_policy,
"parent_node_id": str(parent_node_id).strip() if parent_node_id else None,
"transport_key": transport_key.strip() if isinstance(transport_key, str) else None,
}
used_names.add(name)
for node in normalized.values():
parent_node_id = node.get("parent_node_id")
if parent_node_id and parent_node_id not in normalized:
raise ValueError(
f"Parent node '{parent_node_id}' does not exist for '{node['node_id']}'"
)
ordered: List[Dict[str, Any]] = []
pending = dict(normalized)
resolved_ids: set[str] = set()
while pending:
progressed = False
for node_id, node in list(pending.items()):
parent_node_id = node.get("parent_node_id")
if parent_node_id and parent_node_id not in resolved_ids:
continue
ordered.append(node)
resolved_ids.add(node_id)
pending.pop(node_id)
progressed = True
if not progressed:
raise ValueError("Cycle detected in transport key tree payload")
generated_keys = 0
now = time.time()
with sqlite3.connect(self.sqlite_path) as conn:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("DELETE FROM transport_keys")
db_ids: Dict[str, int] = {}
for node in ordered:
transport_key = node.get("transport_key")
if not transport_key:
transport_key = self.generate_transport_key(node["name"])
generated_keys += 1
parent_id = (
db_ids.get(node["parent_node_id"])
if node.get("parent_node_id")
else None
)
cursor = conn.execute(
"""
INSERT INTO transport_keys (
name,
flood_policy,
transport_key,
parent_id,
last_used,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
node["name"],
node["flood_policy"],
transport_key,
parent_id,
None,
now,
now,
),
)
db_ids[node["node_id"]] = int(cursor.lastrowid)
conn.commit()
return {"applied_nodes": len(ordered), "generated_keys": generated_keys}
def delete_advert(self, advert_id: int) -> bool:
try:
with sqlite3.connect(self.sqlite_path) as conn:
+16 -3
View File
@@ -6,7 +6,6 @@ from pathlib import Path
from typing import Any, Dict, Optional
from .mqtt_handler import MeshCoreToMqttPusher
#from .old_mqtt_handler import MQTTHandler
from .rrdtool_handler import RRDToolHandler
from .sqlite_handler import SQLiteHandler
from .storage_utils import PacketRecord
@@ -18,6 +17,7 @@ class StorageCollector:
def __init__(self, config: dict, local_identity=None, repeater_handler=None):
self.config = config
self.repeater_handler = repeater_handler
self.glass_publish_callback = None
storage_dir_cfg = (
config.get("storage", {}).get("storage_dir")
@@ -129,7 +129,7 @@ class StorageCollector:
self.sqlite_handler.store_packet(packet_record)
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
#self.old_mqtt_handler.publish(packet_record, "packet")
self._publish_to_glass(packet_record, "packet")
# Broadcast to WebSocket clients for real-time updates
if self.websocket_available:
@@ -189,17 +189,20 @@ class StorageCollector:
def record_advert(self, advert_record: dict):
self.sqlite_handler.store_advert(advert_record)
self.mqtt_handler.publish_mqtt("advert", advert_record)
self._publish_to_glass(advert_record, "advert")
def record_noise_floor(self, noise_floor_dbm: float):
noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm}
self.sqlite_handler.store_noise_floor(noise_record)
self.mqtt_handler.publish_mqtt("noise_floor", noise_record)
self._publish_to_glass(noise_record, "noise_floor")
def record_crc_errors(self, count: int):
"""Record a batch of CRC errors detected since last poll."""
crc_record = {"timestamp": time.time(), "count": count}
self.sqlite_handler.store_crc_errors(crc_record)
self.mqtt_handler.publish_mqtt("crc_errors", crc_record)
self._publish_to_glass(crc_record, "crc_errors")
def get_crc_error_count(self, hours: int = 24) -> int:
return self.sqlite_handler.get_crc_error_count(hours)
@@ -292,7 +295,6 @@ class StorageCollector:
return self.sqlite_handler.get_noise_floor_stats(hours)
def close(self):
#self.old_mqtt_handler.close()
if self.mqtt_handler:
try:
self.mqtt_handler.disconnect()
@@ -300,6 +302,17 @@ class StorageCollector:
except Exception as e:
logger.error(f"Error disconnecting MQTT handler: {e}")
def set_glass_publisher(self, publish_callback):
self.glass_publish_callback = publish_callback
def _publish_to_glass(self, record: dict, record_type: str):
if not self.glass_publish_callback:
return
try:
self.glass_publish_callback(record_type, record)
except Exception as e:
logger.debug(f"Failed to publish telemetry to Glass MQTT: {e}")
def create_transport_key(
self,
name: str,
+23
View File
@@ -10,6 +10,7 @@ import time
from repeater.companion.utils import validate_companion_node_name, normalize_companion_identity_key
from repeater.config import get_radio_for_board, load_config, save_config
from repeater.config_manager import ConfigManager
from repeater.data_acquisition.glass_handler import GlassHandler
from repeater.engine import RepeaterHandler
from repeater.handler_helpers import (
AdvertHelper,
@@ -47,6 +48,7 @@ class RepeaterDaemon:
self.text_helper = None
self.path_helper = None
self.protocol_request_helper = None
self.glass_handler = None
self.acl = None
self.router = None
self.companion_bridges: dict[int, object] = {}
@@ -337,6 +339,20 @@ class RepeaterDaemon:
# When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)
self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions
# Optional pyMC_Glass integration loop (inform/control plane)
self.glass_handler = GlassHandler(
config=self.config,
daemon_instance=self,
config_manager=self.config_manager,
)
await self.glass_handler.start()
if (
self.repeater_handler
and self.repeater_handler.storage
and hasattr(self.repeater_handler.storage, "set_glass_publisher")
):
self.repeater_handler.storage.set_glass_publisher(self.glass_handler.publish_telemetry)
except Exception as e:
logger.error(f"Failed to initialize dispatcher: {e}")
raise
@@ -1023,6 +1039,13 @@ class RepeaterDaemon:
except Exception as e:
logger.warning(f"Error stopping HTTP server: {e}")
# Stop Glass inform loop
if self.glass_handler:
try:
await self.glass_handler.stop()
except Exception as e:
logger.warning(f"Error stopping Glass handler: {e}")
# Release radio resources
if self.radio and hasattr(self.radio, "cleanup"):
try:
+1 -1
View File
@@ -4512,7 +4512,7 @@ class APIEndpoints:
# Sections we allow to be imported
ALLOWED_SECTIONS = {
"repeater", "mesh", "radio", "identities", "delays",
"ch341", "web", "letsmesh", "logging", "radio_type",
"ch341", "web", "letsmesh", "glass", "logging", "radio_type",
}
updated_sections = []
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,5 @@
<<<<<<<< HEAD:repeater/web/html/assets/ConfirmDialog-h2bJ_WKJ.js
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={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(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,e(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`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)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`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(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,e(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`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`},e(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},e(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
========
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{h as s}from"./index-CmQtu2qv.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={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(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,e(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`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)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`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(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,e(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`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`},e(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},e(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
>>>>>>>> origin/dev:repeater/web/html/assets/ConfirmDialog-C9Yf394V.js
@@ -1 +0,0 @@
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={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(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,e(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`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)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`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(),a(`svg`,p,[...g[7]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,m,e(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`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`},e(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},e(_.confirmText),3)])])])):n(``,!0)}});export{g as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.bg-gradient-light[data-v-63d1c99c]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-63d1c99c]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-63d1c99c]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-63d1c99c]{background:#11191c66}.input-glass[data-v-63d1c99c]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-63d1c99c]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-63d1c99c]:focus{background:#fff}.dark .input-glass[data-v-63d1c99c]:focus{background:#ffffff1a}.input-glass[data-v-63d1c99c]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-63d1c99c]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-63d1c99c]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-63d1c99c]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-63d1c99c]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-63d1c99c]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-63d1c99c]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-63d1c99c]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-63d1c99c]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-63d1c99c]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-63d1c99c]{opacity:0}.dark .logo-glow[data-v-63d1c99c]{opacity:1}@keyframes float-63d1c99c{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-63d1c99c{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-63d1c99c{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-63d1c99c{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-63d1c99c]{animation:8s ease-in-out infinite pulse-slow-63d1c99c}.animate-pulse-slower[data-v-63d1c99c]{animation:10s ease-in-out infinite pulse-slower-63d1c99c}.animate-pulse-slowest[data-v-63d1c99c]{animation:12s ease-in-out infinite pulse-slowest-63d1c99c}@keyframes shake-63d1c99c{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-63d1c99c]{animation:.5s ease-in-out shake-63d1c99c}.form-group[data-v-63d1c99c]{position:relative}.form-group:hover label[data-v-63d1c99c]{color:#aae8e8e6;transition:color .3s}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.bg-gradient-light[data-v-0539335e]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-0539335e]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-0539335e]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-0539335e]{background:#11191c66}.input-glass[data-v-0539335e]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-0539335e]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-0539335e]:focus{background:#fff}.dark .input-glass[data-v-0539335e]:focus{background:#ffffff1a}.input-glass[data-v-0539335e]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-0539335e]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-0539335e]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-0539335e]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-0539335e]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-0539335e]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-0539335e]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-0539335e]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-0539335e]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-0539335e]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-0539335e]{opacity:0}.dark .logo-glow[data-v-0539335e]{opacity:1}@keyframes float-0539335e{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-0539335e{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-0539335e{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-0539335e{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-0539335e]{animation:8s ease-in-out infinite pulse-slow-0539335e}.animate-pulse-slower[data-v-0539335e]{animation:10s ease-in-out infinite pulse-slower-0539335e}.animate-pulse-slowest[data-v-0539335e]{animation:12s ease-in-out infinite pulse-slowest-0539335e}@keyframes shake-0539335e{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-0539335e]{animation:.5s ease-in-out shake-0539335e}@keyframes logo-aura-cycle-0539335e{0%,to{filter:brightness()saturate()drop-shadow(0 0 7px #38bdf873)}25%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #6366f16b)}50%{filter:brightness()saturate(1.03)drop-shadow(0 0 8px #22d3ee73)}75%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #34d3996b)}}.logo-image-animated[data-v-0539335e]{will-change:filter;animation:6s ease-in-out infinite logo-aura-cycle-0539335e}.form-group[data-v-0539335e]{position:relative}.form-group:hover label[data-v-0539335e]{color:#aae8e8e6;transition:color .3s}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
<<<<<<<< HEAD:repeater/web/html/assets/MessageDialog-Cp4W1enq.js
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
========
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{h as s}from"./index-CmQtu2qv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
>>>>>>>> origin/dev:repeater/web/html/assets/MessageDialog-B-qWtO0z.js
@@ -1 +0,0 @@
import{dt as e,g as t,l as n,lt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-IofF4kUm.js";import{m as s}from"./index-cutq4vvY.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},y={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(t,m)=>h.show?(o(),a(`div`,{key:0,onClick:_,class:`fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4`,style:{"backdrop-filter":`blur(8px) saturate(180%)`,position:`fixed`,top:`0`,left:`0`,right:`0`,bottom:`0`}},[i(`div`,{class:`bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10`,onClick:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:r([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),i(`p`,f,e(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:r([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{n as e}from"./index-CmQtu2qv.js";export{e as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

@@ -1 +0,0 @@
import{o as e,z as t}from"./runtime-core.esm-bundler-IofF4kUm.js";import{n}from"./pinia-BrpcNUEi.js";import{t as r}from"./api-DjLVJkR1.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
@@ -0,0 +1,5 @@
<<<<<<<< HEAD:repeater/web/html/assets/system-CsY7_jKa.js
import{o as e,z as t}from"./runtime-core.esm-bundler-IofF4kUm.js";import{n}from"./pinia-BrpcNUEi.js";import{t as r}from"./api-DjLVJkR1.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
========
import{o as e,z as t}from"./runtime-core.esm-bundler-IofF4kUm.js";import{n}from"./pinia-BrpcNUEi.js";import{t as r}from"./api-DegLD39Y.js";var i=n(`system`,()=>{let n=t(null),i=t(!1),a=t(null),o=t(null),s=t(`forward`),c=t(!0),l=t(0),u=t(10),d=t(!1),f=e(()=>n.value?.config?.node_name??`Unknown`),p=e(()=>{let e=n.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),m=e(()=>n.value!==null),h=e(()=>n.value?.version??`Unknown`),g=e(()=>n.value?.core_version??`Unknown`),_=e(()=>n.value?.noise_floor_dbm??null),v=e(()=>u.value>0?Math.min(l.value/u.value*100,100):0),y=e(()=>s.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:s.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:c.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),b=e(()=>({mode:s.value})),x=e(()=>c.value?{active:!0,warning:!1}:{active:!1,warning:!0}),S=e=>{d.value=e};async function C(){try{i.value=!0,a.value=null;let e=await r.get(`/stats`);if(e.success&&e.data)return n.value=e.data,o.value=new Date,w(e.data),e.data;if(e&&`version`in e){let t=e;return n.value=t,o.value=new Date,w(t),t}else throw Error(e.error||`Failed to fetch stats`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{i.value=!1}}function w(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?s.value=t:t!==void 0&&(s.value=`forward`);let n=e.config.duty_cycle;if(n){c.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?u.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(u.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?l.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(l.value=t.parsedValue||0)}async function T(e){try{let t=await r.post(`/set_mode`,{mode:e});if(t.success)return s.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function E(e){try{let t=await r.post(`/set_duty_cycle`,{enabled:e});if(t.success)return c.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function D(){try{let e=await r.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw a.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function O(){return await E(!c.value)}function k(e){n.value?(e.uptime_seconds!==void 0&&(n.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(n.value.noise_floor_dbm=e.noise_floor_dbm)):n.value=e,o.value=new Date,w(e)}async function A(e=5e3,t=!1){t||await C();let n=null;return t||(n=setInterval(async()=>{try{await C()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function j(){n.value=null,a.value=null,o.value=null,i.value=!1,s.value=`forward`,c.value=!0,l.value=0,u.value=10}return{stats:n,isLoading:i,error:a,lastUpdated:o,currentMode:s,dutyCycleEnabled:c,dutyCycleUtilization:l,dutyCycleMax:u,cadCalibrationRunning:d,nodeName:f,pubKey:p,hasStats:m,version:h,coreVersion:g,noiseFloorDbm:_,dutyCyclePercentage:v,statusBadge:y,modeButtonState:b,dutyCycleButtonState:x,fetchStats:C,setMode:T,setDutyCycle:E,sendAdvert:D,toggleDutyCycle:O,startAutoRefresh:A,updateRealtimeStats:k,reset:j,setCadCalibrationRunning:S}});export{i as t};
>>>>>>>> origin/dev:repeater/web/html/assets/system-qCwV23PE.js
@@ -1 +0,0 @@
import{o as e}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t}from"./system-CsY7_jKa.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
@@ -0,0 +1,5 @@
<<<<<<<< HEAD:repeater/web/html/assets/useSignalQuality-DlXA7j0p.js
import{o as e}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t}from"./system-CsY7_jKa.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
========
import{o as e}from"./runtime-core.esm-bundler-IofF4kUm.js";import{t}from"./system-qCwV23PE.js";var n={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},r=-116,i=8,a=5;function o(e,t){return e-t}function s(e){return n[e]??n[i]}function c(e,t){let n=t+a;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,snr:e,quality:n===0?`none`:`poor`}}if(e<n){let n=(e-t)/a<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,snr:e,quality:`fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,snr:e,quality:r===5?`excellent`:`good`}}function l(){let n=t(),a=e(()=>n.noiseFloorDbm??r),l=e(()=>n.stats?.config?.radio?.spreading_factor??i),u=e(()=>s(l.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,snr:-999,quality:`none`};let t=o(e,a.value);return c(Math.max(-30,Math.min(20,t)),u.value)},noiseFloor:a,spreadingFactor:l,minSNR:u}}export{l as t};
>>>>>>>> origin/dev:repeater/web/html/assets/useSignalQuality-u0_rDpC6.js
+5 -5
View File
@@ -8,17 +8,17 @@
<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-cutq4vvY.js"></script>
<script type="module" crossorigin src="/assets/index-CmQtu2qv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-V-yks4gF.js">
<link rel="modulepreload" crossorigin href="/assets/chunk-DECur_0Z.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-IofF4kUm.js">
<link rel="modulepreload" crossorigin href="/assets/vue-router-BsDVl_JC.js">
<link rel="modulepreload" crossorigin href="/assets/api-DjLVJkR1.js">
<link rel="modulepreload" crossorigin href="/assets/api-DegLD39Y.js">
<link rel="modulepreload" crossorigin href="/assets/pinia-BrpcNUEi.js">
<link rel="modulepreload" crossorigin href="/assets/useTheme-Dlt6-wEf.js">
<link rel="modulepreload" crossorigin href="/assets/packets-CPLd89q8.js">
<link rel="modulepreload" crossorigin href="/assets/system-CsY7_jKa.js">
<link rel="stylesheet" crossorigin href="/assets/index-BMaV7Ce0.css">
<link rel="modulepreload" crossorigin href="/assets/packets-DmoWuBlc.js">
<link rel="modulepreload" crossorigin href="/assets/system-qCwV23PE.js">
<link rel="stylesheet" crossorigin href="/assets/index-B6bC_m88.css">
</head>
<body>
<div id="app"></div>
+471
View File
@@ -0,0 +1,471 @@
import asyncio
import importlib.util
import json
import time
from pathlib import Path
import pytest
_MODULE_PATH = Path(__file__).resolve().parents[1] / "repeater" / "data_acquisition" / "glass_handler.py"
_SPEC = importlib.util.spec_from_file_location("repeater_glass_handler", _MODULE_PATH)
_MODULE = importlib.util.module_from_spec(_SPEC)
assert _SPEC and _SPEC.loader
_SPEC.loader.exec_module(_MODULE)
GlassHandler = _MODULE.GlassHandler
class _DummyIdentity:
@staticmethod
def get_public_key():
return bytes.fromhex("ab" * 32)
class _DummyConfigManager:
def __init__(self):
self.calls = []
def update_and_save(self, updates, live_update=True, live_update_sections=None):
self.calls.append(
{
"updates": updates,
"live_update": live_update,
"live_update_sections": live_update_sections,
}
)
return {"success": True, "saved": True, "live_updated": True}
@staticmethod
def save_to_file():
return True
@staticmethod
def live_update_daemon(_sections):
return True
class _DummyDaemon:
def __init__(self):
self.local_identity = _DummyIdentity()
self.repeater_handler = type("RH", (), {"start_time": time.time() - 60})()
@staticmethod
def get_stats():
return {
"rx_count": 11,
"forwarded_count": 7,
"dropped_count": 2,
"flood_dup_count": 3,
"direct_dup_count": 1,
"sent_flood_count": 5,
"sent_direct_count": 2,
"utilization_percent": 4.2,
"noise_floor_dbm": -111.5,
"uptime_seconds": 60,
}
@staticmethod
async def send_advert():
return True
class _DummyMqttClient:
def __init__(self):
self.published = []
def publish(self, topic, message, qos=0, retain=False):
self.published.append(
{
"topic": topic,
"message": message,
"qos": qos,
"retain": retain,
}
)
class _DummyPahoClient:
def __init__(self):
self.username = None
self.password = None
self.tls_set_kwargs = None
self.tls_insecure = None
self.connected = None
self.loop_started = False
self.loop_stopped = False
self.disconnected = False
self.on_connect = None
self.on_disconnect = None
def username_pw_set(self, username, password):
self.username = username
self.password = password
def tls_set(self, **kwargs):
self.tls_set_kwargs = kwargs
def tls_insecure_set(self, value):
self.tls_insecure = value
def connect_async(self, host, port, keepalive):
self.connected = (host, port, keepalive)
def loop_start(self):
self.loop_started = True
def loop_stop(self):
self.loop_stopped = True
def disconnect(self):
self.disconnected = True
class _DummyPahoModule:
def __init__(self, client):
self._client = client
def Client(self):
return self._client
class _DummyHttpResponse:
def __init__(self, payload):
self._payload = json.dumps(payload).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return self._payload
class _DummySslContext:
def __init__(self):
self.cert_chain = None
def load_cert_chain(self, certfile, keyfile):
self.cert_chain = (certfile, keyfile)
def _make_config():
return {
"repeater": {
"node_name": "mesh-repeater-01",
"mode": "forward",
"location": "51.5074,-0.1278",
"identity_key": "PRIVATE-KEY",
},
"radio": {
"frequency": 869618000,
"spreading_factor": 8,
"bandwidth": 62500,
"tx_power": 14,
},
"glass": {
"enabled": False,
"base_url": "http://localhost:8080",
"inform_interval_seconds": 30,
"request_timeout_seconds": 10,
"verify_tls": True,
"api_token": "",
"cert_store_dir": "/tmp/pymc-glass-test",
"mqtt_password": "super-secret",
},
}
def test_compute_config_hash_has_expected_format():
config = _make_config()
config["repeater"]["identity_key"] = b"\x01" * 32
digest = GlassHandler._compute_config_hash(config)
assert digest.startswith("sha256:")
assert len(digest) == 71
def test_build_inform_payload_contains_expected_fields():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
asyncio.run(handler._queue_command_result("cmd-1", "success", "ok"))
payload = asyncio.run(handler._build_inform_payload())
assert payload["type"] == "inform"
assert payload["version"] == 1
assert payload["node_name"] == "mesh-repeater-01"
assert payload["pubkey"].startswith("0x")
assert payload["config_hash"].startswith("sha256:")
assert payload["location"] == "51.907400,-0.157800"
assert payload["radio"]["frequency"] == 869618000
assert payload["counters"]["duplicates"] == 4
assert payload["settings"]["repeater"]["location"] == "51.9074,-0.1570"
assert payload["settings"]["repeater"]["identity_key"] == "<redacted>"
assert payload["settings"]["glass"]["mqtt_password"] == "<redacted>"
assert payload["command_results"][0]["command_id"] == "cmd-1"
def test_execute_set_mode_command_updates_config():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
ok, message = asyncio.run(handler._execute_command_action("set_mode", {"mode": "monitor"}))
assert ok is True
assert "Config patched" in message
assert manager.calls
assert manager.calls[-1]["updates"]["repeater"]["mode"] == "monitor"
def test_handle_command_response_queues_result():
config = _make_config()
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
asyncio.run(
handler._handle_command_response(
{
"type": "command",
"command_id": "cmd-42",
"action": "run_diagnostic",
"params": {},
}
)
)
assert handler._pending_command_results
queued = handler._pending_command_results[-1]
assert queued["command_id"] == "cmd-42"
assert queued["status"] == "success"
def test_publish_telemetry_packet_envelope_to_expected_topic():
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.mqtt_enabled = True
handler._mqtt_ready = True
handler._mqtt_client = _DummyMqttClient()
handler.publish_telemetry(
"packet",
{
"timestamp": 1760000000,
"packet_hash": "ABCDEF123456",
"rssi": -80.5,
},
)
assert len(handler._mqtt_client.published) == 1
published = handler._mqtt_client.published[0]
assert published["topic"] == "glass/mesh-repeater-01/packet"
payload = json.loads(published["message"])
assert payload["version"] == 1
assert payload["type"] == "packet"
assert payload["node_name"] == "mesh-repeater-01"
assert payload["topic"] == "glass/mesh-repeater-01/packet"
assert payload["payload"]["packet_hash"] == "ABCDEF123456"
assert published["qos"] == 0
assert published["retain"] is False
def test_publish_telemetry_event_uses_event_topic_suffix():
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.mqtt_enabled = True
handler._mqtt_ready = True
handler._mqtt_client = _DummyMqttClient()
handler.publish_telemetry(
"noise_floor",
{
"timestamp": "2026-04-15T12:30:45Z",
"noise_floor_dbm": -112.3,
},
)
assert len(handler._mqtt_client.published) == 1
published = handler._mqtt_client.published[0]
assert published["topic"] == "glass/mesh-repeater-01/event/noise_floor"
payload = json.loads(published["message"])
assert payload["type"] == "event"
assert payload["event_name"] == "noise_floor"
assert payload["topic"] == "glass/mesh-repeater-01/event/noise_floor"
def test_apply_config_update_glass_managed_updates_runtime_and_file(tmp_path):
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["cert_store_dir"] = str(tmp_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler._sync_mqtt_publisher = lambda: None
ok, message = handler._apply_config_update(
{
"glass_managed": {
"mqtt_enabled": True,
"mqtt_broker_host": "emqx",
"mqtt_broker_port": 1883,
"mqtt_base_topic": "glass/fleet",
"mqtt_tls_enabled": True,
}
},
merge_mode="patch",
)
assert ok is True
assert "Managed settings updated" in message
managed_path = tmp_path / "managed.json"
assert managed_path.exists()
managed = json.loads(managed_path.read_text(encoding="utf-8"))
assert managed["mqtt_enabled"] is True
assert managed["mqtt_broker_host"] == "emqx"
assert managed["mqtt_broker_port"] == 1883
assert managed["mqtt_base_topic"] == "glass/fleet"
assert managed["mqtt_tls_enabled"] is True
assert handler.mqtt_enabled is True
assert handler.mqtt_broker_host == "emqx"
assert handler.mqtt_broker_port == 1883
assert handler.mqtt_base_topic == "glass/fleet"
assert handler.mqtt_tls_enabled is True
def test_sync_mqtt_publisher_restarts_when_signature_changes(monkeypatch):
config = _make_config()
config["glass"]["enabled"] = True
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.enabled = True
handler.mqtt_enabled = True
handler._mqtt_client = object()
handler._mqtt_runtime_signature = ("old-host", 1883, "glass", None, None)
calls = []
def _fake_close():
calls.append("close")
handler._mqtt_client = None
handler._mqtt_runtime_signature = None
def _fake_init():
calls.append("init")
monkeypatch.setattr(_MODULE, "mqtt", object())
monkeypatch.setattr(handler, "_close_mqtt_publisher", _fake_close)
monkeypatch.setattr(handler, "_init_mqtt_publisher", _fake_init)
handler.mqtt_broker_host = "new-host"
handler._sync_mqtt_publisher()
assert calls == ["close", "init"]
def test_init_mqtt_publisher_uses_mtls_cert_material(tmp_path, monkeypatch):
cert_path = tmp_path / "glass-client.crt"
key_path = tmp_path / "glass-client.key"
ca_path = tmp_path / "glass-ca.crt"
cert_path.write_text("CERT", encoding="utf-8")
key_path.write_text("KEY", encoding="utf-8")
ca_path.write_text("CA", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["verify_tls"] = True
config["glass"]["client_cert_path"] = str(cert_path)
config["glass"]["client_key_path"] = str(key_path)
config["glass"]["ca_cert_path"] = str(ca_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
handler.enabled = True
handler.mqtt_enabled = True
handler.mqtt_tls_enabled = True
handler.mqtt_broker_host = "emqx"
handler.mqtt_broker_port = 8883
handler.mqtt_base_topic = "glass"
fake_client = _DummyPahoClient()
monkeypatch.setattr(_MODULE, "mqtt", _DummyPahoModule(fake_client))
handler._init_mqtt_publisher()
assert fake_client.tls_set_kwargs is not None
assert fake_client.tls_set_kwargs["ca_certs"] == str(ca_path)
assert fake_client.tls_set_kwargs["certfile"] == str(cert_path)
assert fake_client.tls_set_kwargs["keyfile"] == str(key_path)
assert fake_client.tls_set_kwargs["cert_reqs"] == _MODULE.ssl.CERT_REQUIRED
assert fake_client.connected == ("emqx", 8883, 60)
assert fake_client.loop_started is True
assert handler._mqtt_client is fake_client
assert handler._mqtt_runtime_signature == handler._current_mqtt_signature()
def test_post_inform_sync_uses_configured_ca_and_client_cert_chain(tmp_path, monkeypatch):
cert_path = tmp_path / "glass-client.crt"
key_path = tmp_path / "glass-client.key"
ca_path = tmp_path / "glass-ca.crt"
cert_path.write_text("CERT", encoding="utf-8")
key_path.write_text("KEY", encoding="utf-8")
ca_path.write_text("CA", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["base_url"] = "https://glass.example"
config["glass"]["verify_tls"] = True
config["glass"]["client_cert_path"] = str(cert_path)
config["glass"]["client_key_path"] = str(key_path)
config["glass"]["ca_cert_path"] = str(ca_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
calls = {}
fake_context = _DummySslContext()
def _fake_create_default_context(cafile=None):
calls["cafile"] = cafile
return fake_context
def _fake_urlopen(req, timeout=None, context=None):
calls["timeout"] = timeout
calls["context"] = context
assert req.full_url == "https://glass.example/inform"
return _DummyHttpResponse({"type": "noop", "interval": 30})
monkeypatch.setattr(_MODULE.ssl, "create_default_context", _fake_create_default_context)
monkeypatch.setattr(_MODULE.request, "urlopen", _fake_urlopen)
response = handler._post_inform_sync({"type": "inform"})
assert response["type"] == "noop"
assert calls["cafile"] == str(ca_path)
assert calls["context"] is fake_context
assert fake_context.cert_chain == (str(cert_path), str(key_path))
def test_build_ssl_context_raises_when_client_key_missing(tmp_path):
cert_path = tmp_path / "glass-client.crt"
cert_path.write_text("CERT", encoding="utf-8")
config = _make_config()
config["glass"]["enabled"] = True
config["glass"]["base_url"] = "https://glass.example"
config["glass"]["verify_tls"] = False
config["glass"]["client_cert_path"] = str(cert_path)
daemon = _DummyDaemon()
manager = _DummyConfigManager()
handler = GlassHandler(config=config, daemon_instance=daemon, config_manager=manager)
with pytest.raises(RuntimeError, match="client_key_path"):
handler._build_ssl_context("https://glass.example/inform")