mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-06 17:03:32 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"] = {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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};
|
||||
+5
-1
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
+4
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
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user