mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-10 19:03:22 +02:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43a112ce7c | |||
| e52637535d | |||
| 0e26ed3cb3 | |||
| 1a2bf9e4fd | |||
| 8b3894d2d5 | |||
| daec7f0ebc | |||
| a5c85dd9d9 | |||
| ff43d85b85 | |||
| 229d71459f | |||
| 047ad19fd3 | |||
| 05449987c1 | |||
| ae6ac1bd05 | |||
| ae68112377 | |||
| 34747d2610 | |||
| b84479de0c | |||
| baa89e836c | |||
| 823308aa3f | |||
| a964062bf6 | |||
| 9be74efeb0 | |||
| 38efb05eeb | |||
| a53df3d76f | |||
| 172c8cc016 | |||
| 60357f5808 | |||
| 6de2b23266 | |||
| 864030283c | |||
| 751c9e3807 | |||
| fbbc7e6844 | |||
| 4d095dad42 | |||
| fcaad1dacf | |||
| e2ae7e18a6 | |||
| 11f8ea2f43 | |||
| d24a346cf7 | |||
| af6bea5bbe | |||
| 9846666076 | |||
| 42340f683f | |||
| 27e68167d5 | |||
| f0408d263d | |||
| 135d6b1d8d | |||
| 9748058010 | |||
| cc0dc2ffd6 | |||
| d63ed4aade | |||
| 0ec8ef4741 | |||
| b47b971b98 | |||
| 27ccf5453d | |||
| 42cd75e6b0 | |||
| 1a6e09f63d | |||
| 0d0615f3f2 | |||
| 432b00906c | |||
| af88459f29 | |||
| 08b4667b2c | |||
| 16e88579ae | |||
| 6f6e984d9d | |||
| ffb083652f | |||
| 2b67dea96b | |||
| cf896b2b16 | |||
| 71f4e4779d | |||
| 2b07e79ccd | |||
| 44a2d2f72f | |||
| b4b44831b0 | |||
| f1e860d17e | |||
| c0d919c0e2 | |||
| 89658302c5 | |||
| 3b7a20a192 | |||
| ee6f6a0d0d | |||
| c761c425d1 | |||
| 6c5b6ba00a | |||
| 018f425dc1 | |||
| 4a8876a95e | |||
| 9cff088853 | |||
| 0c6d27a9b1 | |||
| 06e6a7375f | |||
| dc7bcb8a2a | |||
| cd763e2cac | |||
| 2435757197 | |||
| 0f980e4e03 | |||
| 0ccc3536ee | |||
| 875ea47192 | |||
| 211fa51cd6 | |||
| b557116fdf | |||
| 1881ad9b93 | |||
| 5aebb4dd24 | |||
| 1c186d7feb | |||
| f2b7d254a7 | |||
| 5d3733a960 | |||
| 642c019076 | |||
| dde487bce3 | |||
| 77fc244895 |
+6
-6
@@ -3,17 +3,17 @@
|
||||
|
||||
# Published image to run. Use a different repository or tag if you are testing
|
||||
# a fork or a specific release.
|
||||
PYMC_REPEATER_IMAGE=pymcdev/pymc-repeater:main
|
||||
OPENHOP_REPEATER_IMAGE=openhop/openhop-repeater:main
|
||||
|
||||
# Storage defaults to Docker named volumes. This is the safest option for
|
||||
# Portainer and fresh installs because Docker preserves the image ownership.
|
||||
# To use host bind mounts instead, create the folders first and make them
|
||||
# writable by UID/GID 15888:
|
||||
# sudo mkdir -p /opt/pymc-repeater/config /opt/pymc-repeater/data
|
||||
# sudo chown -R 15888:15888 /opt/pymc-repeater/config /opt/pymc-repeater/data
|
||||
# sudo mkdir -p /opt/openhop-repeater/config /opt/openhop-repeater/data
|
||||
# sudo chown -R 15888:15888 /opt/openhop-repeater/config /opt/openhop-repeater/data
|
||||
# Then uncomment and adjust these paths:
|
||||
# PYMC_CONFIG_VOLUME=/opt/pymc-repeater/config
|
||||
# PYMC_DATA_VOLUME=/opt/pymc-repeater/data
|
||||
# OPENHOP_CONFIG_VOLUME=/opt/openhop-repeater/config
|
||||
# OPENHOP_DATA_VOLUME=/opt/openhop-repeater/data
|
||||
|
||||
# Serial/SPI/GPIO access uses the host's numeric group IDs. Check your host with:
|
||||
# getent group dialout
|
||||
@@ -29,6 +29,6 @@ GPIO_GID=986
|
||||
SPI_GID=989
|
||||
|
||||
# Local build only. These are used by docker-compose.build.yml if you build the
|
||||
# image yourself instead of pulling PYMC_REPEATER_IMAGE.
|
||||
# image yourself instead of pulling OPENHOP_REPEATER_IMAGE.
|
||||
PUID=15888
|
||||
PGID=15888
|
||||
|
||||
@@ -10,14 +10,14 @@ on:
|
||||
image_repository:
|
||||
description: "Docker image repository to publish to"
|
||||
required: false
|
||||
default: "pymcdev/pymc-repeater"
|
||||
default: "openhop/openhop-repeater"
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.repository == 'pyMC-dev/pyMC_Repeater' ||
|
||||
github.repository == 'yellowcooln/pyMC_Repeater'
|
||||
github.repository == 'openhop-dev/openhop_repeater' ||
|
||||
github.repository == 'yellowcooln/openhop_repeater'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -58,10 +58,10 @@ jobs:
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.image_repository }}" ]; then
|
||||
image_repository="${{ inputs.image_repository }}"
|
||||
elif [ "${{ github.repository }}" = "yellowcooln/pyMC_Repeater" ]; then
|
||||
image_repository="yellowcooln/pymc-repeater"
|
||||
elif [ "${{ github.repository }}" = "yellowcooln/openhop_repeater" ]; then
|
||||
image_repository="yellowcooln/openhop-repeater"
|
||||
else
|
||||
image_repository="pymcdev/pymc-repeater"
|
||||
image_repository="openhop/openhop-repeater"
|
||||
fi
|
||||
|
||||
echo "Using image repository: ${image_repository}"
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Notify Home Assistant add-on repository
|
||||
if: github.repository == 'pyMC-dev/pyMC_Repeater'
|
||||
if: github.repository == 'openhop-dev/openhop_repeater'
|
||||
env:
|
||||
DISPATCH_TOKEN: ${{ secrets.HA_ADDON_REPO_DISPATCH_TOKEN }}
|
||||
CHANNEL: ${{ github.ref_name }}
|
||||
@@ -107,5 +107,5 @@ jobs:
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${DISPATCH_TOKEN}" \
|
||||
https://api.github.com/repos/pyMC-dev/pyMC-HA-Add-on/dispatches \
|
||||
https://api.github.com/repos/openhop-dev/openHop-HA-Add-on/dispatches \
|
||||
-d "{\"event_type\":\"sync-upstream-channel\",\"client_payload\":{\"channel\":\"${CHANNEL}\",\"revision\":\"${REVISION}\"}}"
|
||||
|
||||
+5
-3
@@ -28,9 +28,9 @@ share/python-wheels/
|
||||
DEBIAN/
|
||||
debian/files
|
||||
debian/.debhelper/
|
||||
debian/pymc-repeater/
|
||||
debian/pymc-repeater.debhelper.log
|
||||
debian/pymc-repeater.substvars
|
||||
debian/openhop-repeater/
|
||||
debian/openhop-repeater.debhelper.log
|
||||
debian/openhop-repeater.substvars
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
@@ -38,6 +38,8 @@ debian/pymc-repeater.substvars
|
||||
env/
|
||||
ENV/
|
||||
|
||||
.ruff_cache
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# pyMC Repeater
|
||||
# openHop Repeater
|
||||
|
||||
Lightweight Python MeshCore repeater daemon built on `pymc_core`.
|
||||
Lightweight Python MeshCore repeater daemon built on `openhop_core`.
|
||||
|
||||
pyMC Repeater is designed to run continuously on low-power Linux hardware such
|
||||
Formerly `pyMC_Repeater` built on `pyMC_core`.
|
||||
|
||||
openHop Repeater is designed to run continuously on low-power Linux hardware such
|
||||
as Raspberry Pi-class devices, Proxmox LXC containers, and network-attached
|
||||
radio modems. It forwards LoRa packets, exposes a web dashboard, and provides
|
||||
configuration tools for radio setup, policy management, monitoring, and
|
||||
@@ -29,7 +31,7 @@ integrations.
|
||||
## Overview
|
||||
|
||||
The repeater daemon runs as a background service and forwards LoRa packets using
|
||||
the `pymc_core` dispatcher and routing stack. The project favors a simple,
|
||||
the `openhop_core` dispatcher and routing stack. The project favors a simple,
|
||||
hackable architecture:
|
||||
|
||||
- CherryPy provides a lightweight HTTP server for the web UI and API.
|
||||
@@ -59,7 +61,7 @@ Historical statistics and performance metrics.
|
||||
|
||||
## Supported Hardware
|
||||
|
||||
pyMC Repeater supports these radio backends:
|
||||
openHop Repeater supports these radio backends:
|
||||
|
||||
- **SX1262 over Linux SPI**: set `radio_type: sx1262`
|
||||
- **SX1262 over CH341 USB-to-SPI**: set `radio_type: sx1262_ch341`
|
||||
@@ -111,8 +113,8 @@ sudo apt install git -y
|
||||
### Clone The Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/rightup/pyMC_Repeater.git
|
||||
cd pyMC_Repeater
|
||||
git clone https://github.com/openhop-dev/openhop_repeater.git
|
||||
cd openhop_repeater
|
||||
```
|
||||
|
||||
### Quick Install
|
||||
@@ -124,17 +126,17 @@ sudo bash ./manage.sh install
|
||||
The installer will:
|
||||
|
||||
- Create a dedicated `repeater` service user with hardware access
|
||||
- Install application files to `/opt/pymc_repeater`
|
||||
- Create the configuration directory at `/etc/pymc_repeater`
|
||||
- Create the log directory at `/var/log/pymc_repeater`
|
||||
- Install application files to `/opt/openhop_repeater`
|
||||
- Create the configuration directory at `/etc/openhop_repeater`
|
||||
- Create the log directory at `/var/log/openhop_repeater`
|
||||
- Launch the interactive radio and hardware setup wizard
|
||||
- Install and enable the `pymc-repeater` systemd service
|
||||
- Install and enable the `openhop-repeater` systemd service
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# View live logs
|
||||
sudo journalctl -u pymc-repeater -f
|
||||
sudo journalctl -u openhop-repeater -f
|
||||
```
|
||||
|
||||
Open the web dashboard at:
|
||||
@@ -160,7 +162,7 @@ pip install -e ".[dev]"
|
||||
The main configuration file is created during installation:
|
||||
|
||||
```text
|
||||
/etc/pymc_repeater/config.yaml
|
||||
/etc/openhop_repeater/config.yaml
|
||||
```
|
||||
|
||||
### Setup Wizard
|
||||
@@ -207,19 +209,19 @@ TX power defaults to 14 dBm and can be changed later.
|
||||
To reconfigure radio and hardware settings after installation:
|
||||
|
||||
```bash
|
||||
sudo bash setup-radio-config.sh /etc/pymc_repeater
|
||||
sudo bash setup-radio-config.sh /etc/openhop_repeater
|
||||
```
|
||||
|
||||
You can also launch the management menu:
|
||||
|
||||
```bash
|
||||
sudo ./manage.sh
|
||||
sudo systemctl restart pymc-repeater
|
||||
sudo systemctl restart openhop-repeater
|
||||
```
|
||||
|
||||
### Optional pyMC_Glass Integration
|
||||
|
||||
pyMC Repeater supports an optional `glass` configuration section for
|
||||
openHop Repeater supports an optional `glass` configuration section for
|
||||
pyMC_Glass control-plane integration. When enabled, the repeater sends periodic
|
||||
`/inform` payloads to pyMC_Glass, receives queued commands, and reports command
|
||||
results on the next inform cycle.
|
||||
@@ -258,7 +260,7 @@ The web interface can upgrade an installation or switch branches.
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd pyMC_Repeater
|
||||
cd openhop_repeater
|
||||
sudo bash ./manage.sh upgrade
|
||||
```
|
||||
|
||||
@@ -272,7 +274,7 @@ The upgrade script will:
|
||||
|
||||
## Proxmox LXC Installation
|
||||
|
||||
pyMC Repeater can run inside a Proxmox LXC container using a CH341 USB-to-SPI
|
||||
openHop Repeater can run inside a Proxmox LXC container using a CH341 USB-to-SPI
|
||||
adapter or a TCP modem. This is useful for headless, always-on deployments
|
||||
without dedicating a full Raspberry Pi.
|
||||
|
||||
@@ -295,12 +297,13 @@ Hardware, choose one:
|
||||
Run this command on the Proxmox host, not inside a container:
|
||||
|
||||
```bash
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/rightup/pyMC_Repeater/main/scripts/proxmox-install.sh)"
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/openhop-dev/openhop_repeater/main/scripts/proxmox-install.sh)"
|
||||
```
|
||||
|
||||
Replace `main` in the URL with another branch name if needed.
|
||||
|
||||
The installer will prompt for container settings and then:
|
||||
The installer will prompt for container settings (container ID, hostname, RAM,
|
||||
disk, bridge, etc.) and then:
|
||||
|
||||
1. Download a Debian 12 LXC template.
|
||||
2. Create a privileged container with USB passthrough.
|
||||
@@ -313,7 +316,8 @@ The installer will prompt for container settings and then:
|
||||
|
||||
| Setting | Default |
|
||||
|---------|---------|
|
||||
| Hostname | `pymc-repeater` |
|
||||
| Container ID | Next available |
|
||||
| Hostname | `openhop-repeater` |
|
||||
| RAM | 1024 MB |
|
||||
| Disk | 4 GB |
|
||||
| CPU cores | 2 |
|
||||
@@ -328,10 +332,10 @@ The installer will prompt for container settings and then:
|
||||
pct enter <CTID>
|
||||
|
||||
# View service logs
|
||||
journalctl -u pymc-repeater -f
|
||||
journalctl -u openhop-repeater -f
|
||||
|
||||
# Manage the repeater
|
||||
cd /opt/pymc_repeater
|
||||
cd /opt/openhop_repeater
|
||||
bash manage.sh
|
||||
```
|
||||
|
||||
@@ -392,7 +396,7 @@ The script prompts before each optional removal step.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
You can run pyMC Repeater in Docker using the published image.
|
||||
You can run openHop Repeater in Docker using the published image.
|
||||
|
||||
Copy `.env.example` to `.env` before starting:
|
||||
|
||||
@@ -408,13 +412,13 @@ root-owned `./config` and `./data` bind mount folders on first start. If you
|
||||
want host bind mounts, use absolute host paths and pre-create/chown them to
|
||||
`15888:15888`.
|
||||
|
||||
Do not mount `./config.yaml:/etc/pymc_repeater/config.yaml`; Docker can create
|
||||
Do not mount `./config.yaml:/etc/openhop_repeater/config.yaml`; Docker can create
|
||||
that source as a directory, which breaks startup.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Review `.env` and update `PYMC_REPEATER_IMAGE`, `DIALOUT_GID`,
|
||||
2. Review `.env` and update `OPENHOP_REPEATER_IMAGE`, `DIALOUT_GID`,
|
||||
`GPIO_GID`, or `SPI_GID` if needed.
|
||||
3. Configure `docker-compose.yml` for your hardware and device paths.
|
||||
4. Uncomment the USB device mapping only if your host has that device path.
|
||||
@@ -428,9 +432,9 @@ docker compose up -d
|
||||
|
||||
```yaml
|
||||
services:
|
||||
pymc-repeater:
|
||||
image: ${PYMC_REPEATER_IMAGE:-pymcdev/pymc-repeater:main}
|
||||
container_name: pymc-repeater
|
||||
openhop-repeater:
|
||||
image: ${OPENHOP_REPEATER_IMAGE:-${PYMC_REPEATER_IMAGE:-openhop/openhop-repeater:main}}
|
||||
container_name: openhop-repeater
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8000:8000
|
||||
@@ -453,12 +457,12 @@ services:
|
||||
- plugdev
|
||||
|
||||
volumes:
|
||||
- ${PYMC_CONFIG_VOLUME:-pymc-repeater-config}:/etc/pymc_repeater
|
||||
- ${PYMC_DATA_VOLUME:-pymc-repeater-data}:/var/lib/pymc_repeater
|
||||
- ${OPENHOP_CONFIG_VOLUME:-${PYMC_CONFIG_VOLUME:-openhop-repeater-config}}:/etc/openhop_repeater
|
||||
- ${OPENHOP_DATA_VOLUME:-${PYMC_DATA_VOLUME:-openhop-repeater-data}}:/var/lib/openhop_repeater
|
||||
|
||||
volumes:
|
||||
pymc-repeater-config:
|
||||
pymc-repeater-data:
|
||||
openhop-repeater-config:
|
||||
openhop-repeater-data:
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
@@ -507,7 +511,7 @@ pre-commit run --all-files
|
||||
```
|
||||
|
||||
Hardware support for LoRa radio drivers is included in the base installation
|
||||
through `pymc_core[hardware]`.
|
||||
through `openhop_core[hardware]`.
|
||||
|
||||
Pre-commit hooks will automatically:
|
||||
- Lint and auto-fix Python issues with Ruff
|
||||
@@ -516,7 +520,7 @@ Pre-commit hooks will automatically:
|
||||
|
||||
## Support
|
||||
|
||||
- [pyMC Core](https://github.com/pyMC-dev/pyMC_core)
|
||||
- [pyMC Core](https://github.com/openhop-dev/openhop_core)
|
||||
- [MeshCore Discord](https://meshcore.gg)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
+26
-26
@@ -1,28 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Buildroot/Luckfox management entrypoint for pyMC Repeater
|
||||
# Buildroot/Luckfox management entrypoint for openHop Repeater
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
INSTALL_DIR="/opt/pymc_repeater"
|
||||
INSTALL_DIR="/opt/openhop_repeater"
|
||||
VENV_DIR="$INSTALL_DIR/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
CONFIG_DIR="/etc/pymc_repeater"
|
||||
LOG_DIR="/var/log/pymc_repeater"
|
||||
DATA_DIR="/var/lib/pymc_repeater"
|
||||
CONFIG_DIR="/etc/openhop_repeater"
|
||||
LOG_DIR="/var/log/openhop_repeater"
|
||||
DATA_DIR="/var/lib/openhop_repeater"
|
||||
SERVICE_USER="root"
|
||||
INIT_SCRIPT="/etc/init.d/S80pymc-repeater"
|
||||
PIDFILE="/var/run/pymc-repeater.pid"
|
||||
INIT_SCRIPT="/etc/init.d/S80openhop-repeater"
|
||||
PIDFILE="/var/run/openhop-repeater.pid"
|
||||
LOGFILE="$LOG_DIR/repeater.log"
|
||||
SERVICE_NAME="pymc-repeater"
|
||||
SERVICE_NAME="openhop-repeater"
|
||||
SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}"
|
||||
|
||||
R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps"
|
||||
PIWHEELS_INDEX_URL="https://www.piwheels.org/simple"
|
||||
R2_ENABLED=1
|
||||
YQ_VERSION="${YQ_VERSION:-v4.44.3}"
|
||||
PYMC_CORE_REPO="${PYMC_CORE_REPO:-https://github.com/rightup/pyMC_core.git}"
|
||||
PYMC_CORE_REPO="${PYMC_CORE_REPO:-https://github.com/openhop-dev/openhop_core.git}"
|
||||
PYMC_CORE_REF="${PYMC_CORE_REF:-}"
|
||||
PYMC_CORE_LOCAL_DIR="${PYMC_CORE_LOCAL_DIR:-}"
|
||||
PYMC_SKIP_BUILDROOT_DEP_INSTALL="${PYMC_SKIP_BUILDROOT_DEP_INSTALL:-0}"
|
||||
@@ -486,9 +486,9 @@ cleanup_legacy_install_state() {
|
||||
|
||||
for path in \
|
||||
"$INSTALL_DIR/repeater" \
|
||||
"$INSTALL_DIR/pymc_core" \
|
||||
"$INSTALL_DIR/pyMC_Repeater" \
|
||||
"$INSTALL_DIR/pyMC_core"
|
||||
"$INSTALL_DIR/openhop_core" \
|
||||
"$INSTALL_DIR/openhop-repeater" \
|
||||
"$INSTALL_DIR/openhop-core"
|
||||
do
|
||||
if [ -e "$path" ]; then
|
||||
rm -rf "$path"
|
||||
@@ -641,8 +641,8 @@ install_core_into_venv() {
|
||||
local core_repo core_ref core_spec
|
||||
|
||||
if [ -n "$PYMC_CORE_LOCAL_DIR" ]; then
|
||||
[ -d "$PYMC_CORE_LOCAL_DIR" ] || fail "Missing local pyMC_core checkout: $PYMC_CORE_LOCAL_DIR"
|
||||
stage "Installing pyMC_core"
|
||||
[ -d "$PYMC_CORE_LOCAL_DIR" ] || fail "Missing local openhop-core checkout: $PYMC_CORE_LOCAL_DIR"
|
||||
stage "Installing openhop-core"
|
||||
info "Local dir: ${PYMC_CORE_LOCAL_DIR}"
|
||||
"$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$PYMC_CORE_LOCAL_DIR"
|
||||
return 0
|
||||
@@ -654,8 +654,8 @@ install_core_into_venv() {
|
||||
*) core_repo="${core_repo}.git" ;;
|
||||
esac
|
||||
core_ref=$(resolve_core_ref)
|
||||
core_spec="pyMC_core[hardware] @ git+${core_repo}@${core_ref}"
|
||||
stage "Installing pyMC_core"
|
||||
core_spec="openhop-core[hardware] @ git+${core_repo}@${core_ref}"
|
||||
stage "Installing openhop-core"
|
||||
info "Repo: ${PYMC_CORE_REPO}"
|
||||
info "Ref: ${core_ref}"
|
||||
"$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$core_spec"
|
||||
@@ -667,7 +667,7 @@ import glob
|
||||
import json
|
||||
import os
|
||||
|
||||
matches = glob.glob("/opt/pymc_repeater/venv/lib/python*/site-packages/pymc_core-*.dist-info/direct_url.json")
|
||||
matches = glob.glob("/opt/openhop_repeater/venv/lib/python*/site-packages/openhop_core-*.dist-info/direct_url.json")
|
||||
for path in matches:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
@@ -693,7 +693,7 @@ resolve_core_commit() {
|
||||
}
|
||||
|
||||
install_repeater_package() {
|
||||
stage "Installing pyMC Repeater into venv"
|
||||
stage "Installing openHop Repeater into venv"
|
||||
info "Installing checked-out repo without re-resolving dependencies"
|
||||
"$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$SCRIPT_DIR"
|
||||
}
|
||||
@@ -1232,7 +1232,7 @@ start_or_restart_service() {
|
||||
|
||||
get_version() {
|
||||
if [ -x "$VENV_PYTHON" ]; then
|
||||
"$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null || echo "not installed"
|
||||
"$VENV_PYTHON" -c "from importlib.metadata import version; print(version('openhop_repeater'))" 2>/dev/null || echo "not installed"
|
||||
else
|
||||
echo "not installed"
|
||||
fi
|
||||
@@ -1247,12 +1247,12 @@ prepare_git_version() {
|
||||
git -C "$SCRIPT_DIR" fetch --tags 2>/dev/null || true
|
||||
git_version=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5")
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="$git_version"
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER="$git_version"
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENHOP_REPEATER="$git_version"
|
||||
info "Using version: $git_version"
|
||||
else
|
||||
git_version="1.0.5"
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="$git_version"
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER="$git_version"
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENHOP_REPEATER="$git_version"
|
||||
info "Using fallback version: $git_version"
|
||||
fi
|
||||
|
||||
@@ -1355,7 +1355,7 @@ install_repeater() {
|
||||
info "Install dir: $INSTALL_DIR"
|
||||
info "Config dir: $CONFIG_DIR"
|
||||
info "Data dir: $DATA_DIR"
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" "$DATA_DIR/.config/pymc_repeater"
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" "$DATA_DIR/.config/openhop_repeater"
|
||||
chown -R root:root "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR"
|
||||
chmod 755 "$INSTALL_DIR" "$DATA_DIR"
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR"
|
||||
@@ -1417,7 +1417,7 @@ upgrade_repeater() {
|
||||
git_version=$(prepare_git_version)
|
||||
preinstall_r2_wheels
|
||||
|
||||
stage "Upgrading pyMC Repeater"
|
||||
stage "Upgrading openHop Repeater"
|
||||
if [ "${PYMC_FORCE_DEPS:-0}" = "1" ]; then
|
||||
info "Forcing dependency reinstall"
|
||||
install_buildroot_dependencies
|
||||
@@ -1431,13 +1431,13 @@ upgrade_repeater() {
|
||||
target_core_commit=$(resolve_core_commit)
|
||||
installed_core_commit=$(get_installed_core_commit)
|
||||
if [ -n "$target_core_commit" ] && [ "$installed_core_commit" = "$target_core_commit" ] && [ "${PYMC_FORCE_CORE:-0}" != "1" ]; then
|
||||
info "pyMC_core is already at ${target_core_commit}; skipping reinstall"
|
||||
info "openhop-core is already at ${target_core_commit}; skipping reinstall"
|
||||
else
|
||||
install_core_into_venv
|
||||
fi
|
||||
|
||||
if [ "$current_version" = "$git_version" ] && [ "${PYMC_FORCE_REPEATER:-0}" != "1" ]; then
|
||||
info "pyMC Repeater is already at ${git_version}; skipping reinstall"
|
||||
info "openHop Repeater is already at ${git_version}; skipping reinstall"
|
||||
else
|
||||
ensure_yq >/dev/null 2>&1 || true
|
||||
install_repeater_package
|
||||
@@ -1521,7 +1521,7 @@ Usage: bash buildroot-manage.sh <command>
|
||||
|
||||
Commands:
|
||||
doctor Check Buildroot/Luckfox prerequisites
|
||||
install Install pyMC Repeater on the Buildroot image
|
||||
install Install openHop Repeater on the Buildroot image
|
||||
upgrade Upgrade the Buildroot installation from the checked-out repo
|
||||
config Prompt for repeater settings and rewrite config.yaml
|
||||
configure Same as config
|
||||
|
||||
+57
-8
@@ -139,6 +139,7 @@ gps:
|
||||
# Source type:
|
||||
# serial = read directly from an attached GPS module
|
||||
# file = read NMEA lines from source_path (useful for gpsd/sidecar bridges)
|
||||
# modem_http = poll a modem's generic HTTP /api/stats GPS JSON
|
||||
source: serial
|
||||
|
||||
# Serial source settings (used when source: serial)
|
||||
@@ -150,9 +151,19 @@ gps:
|
||||
# File source settings (used when source: file)
|
||||
# The file may contain raw NMEA lines or JSON with a "sentences" list /
|
||||
# "last_sentence" field.
|
||||
source_path: "/var/lib/pymc_repeater/gps_nmea.txt"
|
||||
source_path: "/var/lib/openhop_repeater/gps_nmea.txt"
|
||||
poll_interval_seconds: 2.0
|
||||
|
||||
# Modem HTTP source settings (used when source: modem_http)
|
||||
# modem's HTTP API; later it can point at another board such as Heltec V4
|
||||
# if that modem exposes the same GPS JSON shape.
|
||||
host: ""
|
||||
port: 80
|
||||
endpoint: "/api/stats"
|
||||
scheme: "http"
|
||||
username: "admin"
|
||||
password: null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location behaviour
|
||||
# Three independent controls — read the comments carefully, they do
|
||||
@@ -276,6 +287,22 @@ sensors:
|
||||
# bus_number: 1 # I2C bus number (1 for Raspberry Pi default)
|
||||
# low_cell_mv: 3150 # Per-cell warning threshold in mV
|
||||
|
||||
# Example pyMC modem sensor. This reads a networked pyMC modem's
|
||||
# HTTP /api/stats JSON API for modem diagnostics, including GPS fields
|
||||
# when the modem exposes them. If password is set to the same value as
|
||||
# pymc_tcp.token, the repeater can use one shared modem secret.
|
||||
# - type: pymc_modem
|
||||
# name: modem
|
||||
# enabled: true
|
||||
# settings:
|
||||
# host: "pymc-modem.local" # or the modem LAN IP
|
||||
# port: 80
|
||||
# endpoint: "/api/stats"
|
||||
# username: "admin"
|
||||
# password: "" # modem HTTP password; may match pymc_tcp.token
|
||||
# poll_interval_seconds: 60.0 # avoid contending with modem traffic
|
||||
# timeout_seconds: 2.0
|
||||
|
||||
|
||||
|
||||
# Mesh Network Configuration
|
||||
@@ -379,7 +406,7 @@ radio:
|
||||
# Use implicit header mode
|
||||
implicit_header: false
|
||||
|
||||
# KISS modem (when radio_type: kiss). Requires pyMC_core with KISS support.
|
||||
# KISS modem (when radio_type: kiss). Requires openhop-core with KISS support.
|
||||
# kiss:
|
||||
# port: "/dev/ttyUSB0"
|
||||
# baud_rate: 9600
|
||||
@@ -395,7 +422,7 @@ radio:
|
||||
# # kiss_full_duplex: false # disable carrier-sense/CSMA entirely (not recommended)
|
||||
|
||||
# pymc_usb firmware modem over Wi-Fi/TCP (when radio_type: pymc_tcp).
|
||||
# Requires pyMC_core with the TCPLoRaRadio driver
|
||||
# Requires openhop-core with the TCPLoRaRadio driver
|
||||
# pymc_tcp:
|
||||
# host: "pymc-3e2834.local" # modem hostname / mDNS name / LAN IP
|
||||
# port: 5055 # firmware default
|
||||
@@ -405,7 +432,7 @@ radio:
|
||||
# lbt_max_attempts: 5
|
||||
|
||||
# pymc_usb firmware modem over USB-CDC (when radio_type: pymc_usb).
|
||||
# Requires pyMC_core with the USBLoRaRadio driver
|
||||
# Requires openhop-core with the USBLoRaRadio driver
|
||||
# pymc_usb:
|
||||
# port: "/dev/ttyACM0" # USB-CDC device; udev rule may symlink to /dev/lora-modem
|
||||
# baudrate: 921600 # must match firmware monitor_speed
|
||||
@@ -465,8 +492,8 @@ duty_cycle:
|
||||
# Storage Configuration
|
||||
storage:
|
||||
# Directory for persistent storage files (SQLite, RRD).
|
||||
# Use a writable path for local/dev (e.g. "./var/pymc_repeater" or "~/var/pymc_repeater").
|
||||
storage_dir: "/var/lib/pymc_repeater"
|
||||
# Use a writable path for local/dev (e.g. "./var/openhop_repeater" or "~/var/openhop_repeater").
|
||||
storage_dir: "/var/lib/openhop_repeater"
|
||||
|
||||
# Data retention settings
|
||||
retention:
|
||||
@@ -499,7 +526,7 @@ mqtt_brokers:
|
||||
# format: meshcoretomqtt|letsmesh|waev|mqtt
|
||||
# meshcoretomqtt - canonical open-source MC2MQTT topic structure
|
||||
# letsmesh, waev - MC2MQTT family flavors (same topic structure, network-specific identity)
|
||||
# mqtt - legacy pyMC_Repeater local-broker convention (custom topic, singular 'packet')
|
||||
# mqtt - legacy openhop-repeater local-broker convention (custom topic, singular 'packet')
|
||||
# retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery)
|
||||
# tls:
|
||||
# enabled: true|false # Enable TLS. If the endpoint's certificate is self-signed, the Root CA should be added to the OS's certificate store.
|
||||
@@ -575,7 +602,7 @@ glass:
|
||||
api_token: ""
|
||||
|
||||
# Where cert_renewal payloads are written
|
||||
cert_store_dir: "/etc/pymc_repeater/glass"
|
||||
cert_store_dir: "/etc/openhop_repeater/glass"
|
||||
|
||||
logging:
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||
@@ -584,6 +611,28 @@ logging:
|
||||
# Log format
|
||||
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
# HTTP server configuration (web UI + API)
|
||||
http:
|
||||
# Enable/disable the embedded HTTP server at runtime.
|
||||
# Changes are applied live via ConfigManager (no daemon restart required).
|
||||
enabled: true
|
||||
|
||||
# Listen address and port for the web UI/API server.
|
||||
host: "0.0.0.0"
|
||||
port: 8000
|
||||
|
||||
# CherryPy worker/thread tuning.
|
||||
# thread_pool is the initial worker count (minimum enforced by server: 2).
|
||||
thread_pool: 8
|
||||
# thread_pool_max is the max worker count (always >= thread_pool).
|
||||
thread_pool_max: 16
|
||||
|
||||
# Socket tuning.
|
||||
# socket_timeout closes idle sockets so worker threads can be reused (min: 15 seconds).
|
||||
socket_timeout: 65
|
||||
# socket_queue_size controls pending TCP connection backlog (min: 10).
|
||||
socket_queue_size: 100
|
||||
|
||||
# Web interface configuration
|
||||
web:
|
||||
# Enable Cross-Origin Resource Sharing (CORS) headers
|
||||
|
||||
+20
-20
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Convert MeshCore firmware 64-byte private key to pyMC_Repeater format
|
||||
# Convert MeshCore firmware 64-byte private key to openhop-repeater format
|
||||
#
|
||||
# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]
|
||||
# Example: sudo ./convert_firmware_key.sh 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2
|
||||
@@ -12,18 +12,18 @@ if [ $# -eq 0 ]; then
|
||||
echo "Usage: sudo $0 <64-byte-hex-key> [--output-format=<yaml|identity>] [config-path]"
|
||||
echo ""
|
||||
echo "This script imports a 64-byte MeshCore firmware private key into"
|
||||
echo "pyMC_Repeater for full identity compatibility."
|
||||
echo "openhop-repeater for full identity compatibility."
|
||||
echo ""
|
||||
echo "The 64-byte key format: [32-byte scalar][32-byte nonce]"
|
||||
echo " - Enables same node address as firmware device"
|
||||
echo " - Supports signing using MeshCore/orlp ed25519 algorithm"
|
||||
echo " - Fully compatible with pyMC_core LocalIdentity"
|
||||
echo " - Fully compatible with openhop-core LocalIdentity"
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " --output-format: Optional output format (yaml|identity, default: yaml)"
|
||||
echo " yaml - Store in config.yaml (embedded binary)"
|
||||
echo " identity - Save to identity.key file (base64 encoded)"
|
||||
echo " config-path: Optional path to config.yaml (default: /etc/pymc_repeater/config.yaml)"
|
||||
echo " config-path: Optional path to config.yaml (default: /etc/openhop_repeater/config.yaml)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " # Save to config.yaml (default)"
|
||||
@@ -67,7 +67,7 @@ fi
|
||||
|
||||
# Set default config path if not provided
|
||||
if [ -z "$CONFIG_PATH" ]; then
|
||||
CONFIG_PATH="/etc/pymc_repeater/config.yaml"
|
||||
CONFIG_PATH="/etc/openhop_repeater/config.yaml"
|
||||
fi
|
||||
|
||||
# Validate hex string
|
||||
@@ -92,7 +92,7 @@ if [ "$OUTPUT_FORMAT" = "yaml" ]; then
|
||||
fi
|
||||
else
|
||||
# For identity format, use system-wide location matching config.yaml
|
||||
IDENTITY_DIR="/etc/pymc_repeater"
|
||||
IDENTITY_DIR="/etc/openhop_repeater"
|
||||
IDENTITY_PATH="$IDENTITY_DIR/identity.key"
|
||||
fi
|
||||
|
||||
@@ -261,39 +261,39 @@ fi
|
||||
|
||||
# Offer to restart service (only relevant for yaml format)
|
||||
if [ "$OUTPUT_FORMAT" = "yaml" ]; then
|
||||
if systemctl is-active --quiet pymc-repeater 2>/dev/null; then
|
||||
read -p "Restart pymc-repeater service now? (yes/no): " RESTART
|
||||
if systemctl is-active --quiet openhop-repeater 2>/dev/null; then
|
||||
read -p "Restart openhop-repeater service now? (yes/no): " RESTART
|
||||
if [ "$RESTART" = "yes" ]; then
|
||||
systemctl restart pymc-repeater
|
||||
systemctl restart openhop-repeater
|
||||
echo "✓ Service restarted"
|
||||
echo ""
|
||||
echo "Check logs for new identity:"
|
||||
echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'"
|
||||
echo " sudo journalctl -u openhop-repeater -f | grep -i 'identity\|hash'"
|
||||
else
|
||||
echo "Remember to restart the service:"
|
||||
echo " sudo systemctl restart pymc-repeater"
|
||||
echo " sudo systemctl restart openhop-repeater"
|
||||
fi
|
||||
else
|
||||
echo "Note: pymc-repeater service is not running"
|
||||
echo "Start it with: sudo systemctl start pymc-repeater"
|
||||
echo "Note: openhop-repeater service is not running"
|
||||
echo "Start it with: sudo systemctl start openhop-repeater"
|
||||
fi
|
||||
else
|
||||
echo "Identity key saved to file."
|
||||
echo ""
|
||||
if systemctl is-active --quiet pymc-repeater 2>/dev/null; then
|
||||
read -p "Restart pymc-repeater service now? (yes/no): " RESTART
|
||||
if systemctl is-active --quiet openhop-repeater 2>/dev/null; then
|
||||
read -p "Restart openhop-repeater service now? (yes/no): " RESTART
|
||||
if [ "$RESTART" = "yes" ]; then
|
||||
systemctl restart pymc-repeater
|
||||
systemctl restart openhop-repeater
|
||||
echo "✓ Service restarted"
|
||||
echo ""
|
||||
echo "Check logs for new identity:"
|
||||
echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'"
|
||||
echo " sudo journalctl -u openhop-repeater -f | grep -i 'identity\|hash'"
|
||||
else
|
||||
echo "Remember to restart the service:"
|
||||
echo " sudo systemctl restart pymc-repeater"
|
||||
echo " sudo systemctl restart openhop-repeater"
|
||||
fi
|
||||
else
|
||||
echo "Note: pymc-repeater service is not running"
|
||||
echo "Start it with: sudo systemctl start pymc-repeater"
|
||||
echo "Note: openhop-repeater service is not running"
|
||||
echo "Start it with: sudo systemctl start openhop-repeater"
|
||||
fi
|
||||
fi
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
*.substvars
|
||||
.debhelper/
|
||||
files
|
||||
pymc-repeater/
|
||||
openhop-repeater/
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
pymc-repeater (1.0.5~dev0) unstable; urgency=medium
|
||||
openhop-repeater (1.0.5~dev0) unstable; urgency=medium
|
||||
|
||||
* Development build from git commit 7112da9
|
||||
* Version: 1.0.5.post0
|
||||
|
||||
Vendored
+5
-5
@@ -1,4 +1,4 @@
|
||||
Source: pymc-repeater
|
||||
Source: openhop-repeater
|
||||
Section: net
|
||||
Priority: optional
|
||||
Maintainer: Rightup <rightup@pymc.dev>
|
||||
@@ -15,10 +15,10 @@ Build-Depends: debhelper-compat (= 13),
|
||||
python3-psutil,
|
||||
git
|
||||
Standards-Version: 4.6.2
|
||||
Homepage: https://github.com/rightup/pyMC_Repeater
|
||||
Homepage: https://github.com/openhop-dev/openhop_repeater
|
||||
X-Python3-Version: >= 3.9
|
||||
|
||||
Package: pymc-repeater
|
||||
Package: openhop-repeater
|
||||
Architecture: all
|
||||
Depends: ${python3:Depends},
|
||||
${misc:Depends},
|
||||
@@ -36,8 +36,8 @@ Recommends: python3-periphery,
|
||||
Description: PyMC Repeater Daemon
|
||||
A mesh networking repeater daemon for LoRa devices.
|
||||
.
|
||||
This package provides the pymc-repeater service for managing
|
||||
This package provides the openhop-repeater service for managing
|
||||
mesh network repeater functionality with a web interface.
|
||||
.
|
||||
Note: This package will install pymc_core, cherrypy-cors, and ws4py
|
||||
Note: This package will install openhop_core, cherrypy-cors, and ws4py
|
||||
from PyPI during postinst as they are not available in Debian repos.
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
pymc-repeater
|
||||
openhop-repeater
|
||||
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
etc/pymc_repeater
|
||||
var/log/pymc_repeater
|
||||
usr/share/pymc_repeater
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
config.yaml.example usr/share/pymc_repeater/
|
||||
radio-presets.json usr/share/pymc_repeater/
|
||||
radio-settings.json usr/share/pymc_repeater/
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
# Create system user
|
||||
if ! getent passwd pymc-repeater >/dev/null; then
|
||||
adduser --system --group --home /var/lib/pymc-repeater \
|
||||
--gecos "PyMC Repeater Service" pymc-repeater
|
||||
fi
|
||||
|
||||
# Add user to gpio and spi groups for hardware access
|
||||
if getent group gpio >/dev/null; then
|
||||
usermod -a -G gpio pymc-repeater
|
||||
fi
|
||||
if getent group spi >/dev/null; then
|
||||
usermod -a -G spi pymc-repeater
|
||||
fi
|
||||
# Create and set permissions on data directory
|
||||
mkdir -p /var/lib/pymc_repeater
|
||||
chown -R pymc-repeater:pymc-repeater /var/lib/pymc_repeater
|
||||
chmod 750 /var/lib/pymc_repeater
|
||||
# Set permissions
|
||||
chown -R pymc-repeater:pymc-repeater /etc/pymc_repeater
|
||||
chown -R pymc-repeater:pymc-repeater /var/log/pymc-repeater
|
||||
chmod 750 /etc/pymc_repeater
|
||||
chmod 750 /var/log/pymc-repeater
|
||||
|
||||
# Copy example config if no config exists
|
||||
if [ ! -f /etc/pymc_repeater/config.yaml ]; then
|
||||
cp /usr/share/pymc_repeater/config.yaml.example /etc/pymc_repeater/config.yaml
|
||||
chown pymc-repeater:pymc-repeater /etc/pymc_repeater/config.yaml
|
||||
chmod 640 /etc/pymc_repeater/config.yaml
|
||||
fi
|
||||
|
||||
# Install pymc_core from PyPI if not already installed
|
||||
if ! python3 -c "import pymc_core" 2>/dev/null; then
|
||||
echo "Installing pymc_core[hardware] from PyPI..."
|
||||
python3 -m pip install --break-system-packages 'pymc_core[hardware]>=1.0.7' || true
|
||||
fi
|
||||
|
||||
# Install packages not available in Debian repos
|
||||
if ! python3 -c "import cherrypy_cors" 2>/dev/null; then
|
||||
echo "Installing cherrypy-cors from PyPI..."
|
||||
python3 -m pip install --break-system-packages 'cherrypy-cors==1.7.0' || true
|
||||
fi
|
||||
|
||||
if ! python3 -c "import ws4py" 2>/dev/null; then
|
||||
echo "Installing ws4py from PyPI..."
|
||||
python3 -m pip install --break-system-packages 'ws4py>=0.5.1' || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
Vendored
-18
@@ -1,18 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
case "$1" in
|
||||
purge)
|
||||
# Remove user and directories
|
||||
if getent passwd pymc-repeater >/dev/null; then
|
||||
deluser --system pymc-repeater || true
|
||||
fi
|
||||
rm -rf /etc/pymc-repeater
|
||||
rm -rf /var/log/pymc-repeater
|
||||
rm -rf /var/lib/pymc-repeater
|
||||
;;
|
||||
esac
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
||||
Vendored
-19
@@ -1,19 +0,0 @@
|
||||
[Unit]
|
||||
Description=PyMC Repeater Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pymc-repeater
|
||||
Group=pymc-repeater
|
||||
WorkingDirectory=/etc/pymc-repeater
|
||||
ExecStart=/usr/bin/pymc-repeater
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Allow GPS time sync to update CLOCK_REALTIME without running as root
|
||||
CapabilityBoundingSet=CAP_SYS_TIME
|
||||
AmbientCapabilities=CAP_SYS_TIME
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/make -f
|
||||
# -*- makefile -*-
|
||||
|
||||
export PYBUILD_NAME=pymc-repeater
|
||||
export PYBUILD_NAME=openhop-repeater
|
||||
export DH_VERBOSE=1
|
||||
|
||||
%:
|
||||
@@ -19,4 +19,4 @@ override_dh_auto_test:
|
||||
rm -f repeater/_version.py
|
||||
|
||||
override_dh_installsystemd:
|
||||
dh_installsystemd --name=pymc-repeater
|
||||
dh_installsystemd --name=openhop-repeater
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
pymc-repeater:
|
||||
image: pymc-repeater:local
|
||||
openhop-repeater:
|
||||
image: openhop-repeater:local
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dockerfile
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
pymc-repeater:
|
||||
image: ${PYMC_REPEATER_IMAGE:-pymcdev/pymc-repeater:main}
|
||||
container_name: pymc-repeater
|
||||
openhop-repeater:
|
||||
image: ${OPENHOP_REPEATER_IMAGE:-${PYMC_REPEATER_IMAGE:-openhop/openhop-repeater:main}}
|
||||
container_name: openhop-repeater
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8000:8000
|
||||
@@ -21,9 +21,9 @@ services:
|
||||
- "${SPI_GID:-989}"
|
||||
- plugdev
|
||||
volumes:
|
||||
- ${PYMC_CONFIG_VOLUME:-pymc-repeater-config}:/etc/pymc_repeater
|
||||
- ${PYMC_DATA_VOLUME:-pymc-repeater-data}:/var/lib/pymc_repeater
|
||||
- ${OPENHOP_CONFIG_VOLUME:-${PYMC_CONFIG_VOLUME:-openhop-repeater-config}}:/etc/openhop_repeater
|
||||
- ${OPENHOP_DATA_VOLUME:-${PYMC_DATA_VOLUME:-openhop-repeater-data}}:/var/lib/openhop_repeater
|
||||
|
||||
volumes:
|
||||
pymc-repeater-config:
|
||||
pymc-repeater-data:
|
||||
openhop-repeater-config:
|
||||
openhop-repeater-data:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/pymc_repeater}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/pymc_repeater}"
|
||||
CONFIG_PATH="${PYMC_REPEATER_CONFIG:-${CONFIG_DIR}/config.yaml}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/openhop_repeater}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-/etc/openhop_repeater}"
|
||||
CONFIG_PATH="${OPENHOP_REPEATER_CONFIG:-${PYMC_REPEATER_CONFIG:-${CONFIG_DIR}/config.yaml}}"
|
||||
EXAMPLE_PATH="${CONFIG_DIR}/config.yaml.example"
|
||||
BUNDLED_EXAMPLE_PATH="${INSTALL_DIR}/config.yaml.example"
|
||||
RUNTIME_USER="${USER:-repeater}"
|
||||
@@ -22,7 +22,7 @@ fail_bad_config_mount() {
|
||||
echo "Invalid Docker config mount: ${CONFIG_PATH} is a directory, but it must be the config file." >&2
|
||||
echo "This usually happens when ./config.yaml is bind-mounted before that host file exists." >&2
|
||||
echo "Use the supported folder mount instead:" >&2
|
||||
echo " - ./config:/etc/pymc_repeater" >&2
|
||||
echo " - ./config:/etc/openhop_repeater" >&2
|
||||
echo "Then place the config at ./config/config.yaml." >&2
|
||||
print_permission_help
|
||||
exit 1
|
||||
@@ -40,7 +40,7 @@ copy_or_die() {
|
||||
|
||||
use_runtime_merged_config() {
|
||||
src="$1"
|
||||
runtime_dir="$(mktemp -d /tmp/pymc-repeater-config.XXXXXX)"
|
||||
runtime_dir="$(mktemp -d /tmp/openhop-repeater-config.XXXXXX)"
|
||||
runtime_config="${runtime_dir}/config.yaml"
|
||||
|
||||
if ! cp "${src}" "${runtime_config}"; then
|
||||
|
||||
+4
-4
@@ -11,13 +11,13 @@ ARG SPI_GID=989
|
||||
ARG TARGETARCH
|
||||
ARG YQ_VERSION=v4.40.5
|
||||
|
||||
ENV INSTALL_DIR=/opt/pymc_repeater \
|
||||
CONFIG_DIR=/etc/pymc_repeater \
|
||||
DATA_DIR=/var/lib/pymc_repeater \
|
||||
ENV INSTALL_DIR=/opt/openhop_repeater \
|
||||
CONFIG_DIR=/etc/openhop_repeater \
|
||||
DATA_DIR=/var/lib/openhop_repeater \
|
||||
HOME_DIR=/home/${USER} \
|
||||
PATH=/home/${USER}/.local/bin:${PATH} \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER=${PACKAGE_VERSION} \
|
||||
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENHOP_REPEATER=${PACKAGE_VERSION} \
|
||||
PUID=${PUID} \
|
||||
PGID=${PGID} \
|
||||
DIALOUT_GID=${DIALOUT_GID} \
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Adding a New Sensor Plug-in
|
||||
|
||||
Sensors in pyMC_Repeater are self-contained modules that live in `repeater/sensors/`. The subsystem is plug-in based: adding a new sensor requires only one new file. The manager discovers and loads it automatically at runtime by importing the module named after the sensor type.
|
||||
Sensors in openhop-repeater are self-contained modules that live in `repeater/sensors/`. The subsystem is plug-in based: adding a new sensor requires only one new file. The manager discovers and loads it automatically at runtime by importing the module named after the sensor type.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-6
@@ -3,17 +3,17 @@
|
||||
|
||||
# Published image to run. Use a different repository or tag if you are testing
|
||||
# a fork or a specific release.
|
||||
PYMC_REPEATER_IMAGE=pymcdev/pymc-repeater:main
|
||||
OPENHOP_REPEATER_IMAGE=openhop/openhop-repeater:main
|
||||
|
||||
# Storage defaults to Docker named volumes. This is the safest option for
|
||||
# Portainer and fresh installs because Docker preserves the image ownership.
|
||||
# To use host bind mounts instead, create the folders first and make them
|
||||
# writable by UID/GID 15888:
|
||||
# sudo mkdir -p /opt/pymc-repeater/config /opt/pymc-repeater/data
|
||||
# sudo chown -R 15888:15888 /opt/pymc-repeater/config /opt/pymc-repeater/data
|
||||
# sudo mkdir -p /opt/openhop-repeater/config /opt/openhop-repeater/data
|
||||
# sudo chown -R 15888:15888 /opt/openhop-repeater/config /opt/openhop-repeater/data
|
||||
# Then uncomment and adjust these paths:
|
||||
# PYMC_CONFIG_VOLUME=/opt/pymc-repeater/config
|
||||
# PYMC_DATA_VOLUME=/opt/pymc-repeater/data
|
||||
# OPENHOP_CONFIG_VOLUME=/opt/openhop-repeater/config
|
||||
# OPENHOP_DATA_VOLUME=/opt/openhop-repeater/data
|
||||
|
||||
# SPI/GPIO access uses the host's numeric group IDs. Check your host with:
|
||||
# getent group gpio
|
||||
@@ -26,6 +26,6 @@ GPIO_GID=986
|
||||
SPI_GID=989
|
||||
|
||||
# Local build only. These are used by docker-compose.build.yml if you build the
|
||||
# image yourself instead of pulling PYMC_REPEATER_IMAGE.
|
||||
# image yourself instead of pulling OPENHOP_REPEATER_IMAGE.
|
||||
PUID=15888
|
||||
PGID=15888
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
#!/bin/bash
|
||||
# pyMC Repeater Management Script - Deploy, Upgrade, Uninstall
|
||||
# openHop Repeater Management Script - Deploy, Upgrade, Uninstall
|
||||
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/pymc_repeater"
|
||||
INSTALL_DIR="/opt/openhop_repeater"
|
||||
VENV_DIR="$INSTALL_DIR/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
CONFIG_DIR="/etc/pymc_repeater"
|
||||
LOG_DIR="/var/log/pymc_repeater"
|
||||
CONFIG_DIR="/etc/openhop_repeater"
|
||||
LOG_DIR="/var/log/openhop_repeater"
|
||||
DATA_DIR="/var/lib/openhop_repeater"
|
||||
SERVICE_USER="repeater"
|
||||
SERVICE_NAME="pymc-repeater"
|
||||
SERVICE_NAME="openhop-repeater"
|
||||
SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}"
|
||||
|
||||
LEGACY_PYMC_INSTALL_DIR="/opt/pymc_repeater"
|
||||
LEGACY_PYMC_CONFIG_DIR="/etc/pymc_repeater"
|
||||
LEGACY_PYMC_LOG_DIR="/var/log/pymc_repeater"
|
||||
LEGACY_PYMC_DATA_DIR="/var/lib/pymc_repeater"
|
||||
|
||||
# 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
|
||||
@@ -21,16 +27,114 @@ R2_ENABLED=1 # Set to 0 to disable R2 wheels and always build from source
|
||||
# Virtual-environment helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Create (or re-create) the dedicated venv for pymc_repeater
|
||||
ensure_venv() {
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo ">>> Creating virtual environment at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
# Upgrade pip inside the venv
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
cleanup_stale_source_trees() {
|
||||
local removed=0
|
||||
local path
|
||||
|
||||
for path in \
|
||||
"$INSTALL_DIR/repeater" \
|
||||
"$INSTALL_DIR/openhop_core" \
|
||||
"$INSTALL_DIR/openhop-repeater" \
|
||||
"$INSTALL_DIR/openhop-core" \
|
||||
"$LEGACY_PYMC_INSTALL_DIR/repeater" \
|
||||
"$LEGACY_PYMC_INSTALL_DIR/pymc_core" \
|
||||
"$LEGACY_PYMC_INSTALL_DIR/pymc-repeater" \
|
||||
"$LEGACY_PYMC_INSTALL_DIR/pymc-core"
|
||||
do
|
||||
if [ -e "$path" ]; then
|
||||
rm -rf "$path"
|
||||
removed=1
|
||||
echo " ✓ Removed stale source tree at $path"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$removed" -eq 0 ]; then
|
||||
echo " ✓ No stale source-tree paths found"
|
||||
fi
|
||||
}
|
||||
|
||||
migrate_legacy_paths() {
|
||||
local timestamp legacy current label backup_path
|
||||
timestamp="$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
migrate_one_path() {
|
||||
legacy="$1"
|
||||
current="$2"
|
||||
label="$3"
|
||||
|
||||
if [ ! -e "$legacy" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$current" 2>/dev/null || true
|
||||
|
||||
if [ ! -e "$current" ] || [ -z "$(ls -A "$current" 2>/dev/null)" ]; then
|
||||
rm -rf "$current" 2>/dev/null || true
|
||||
mv "$legacy" "$current"
|
||||
echo " ✓ Migrated legacy $label path: $legacy -> $current"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cp -an "$legacy"/. "$current"/ 2>/dev/null || true
|
||||
backup_path="${legacy}.migrated.${timestamp}"
|
||||
mv "$legacy" "$backup_path"
|
||||
echo " ✓ Merged legacy $label data into $current"
|
||||
echo " ✓ Archived legacy $label path at $backup_path"
|
||||
}
|
||||
|
||||
migrate_one_path "$LEGACY_PYMC_CONFIG_DIR" "$CONFIG_DIR" "config"
|
||||
migrate_one_path "$LEGACY_PYMC_LOG_DIR" "$LOG_DIR" "log"
|
||||
migrate_one_path "$LEGACY_PYMC_DATA_DIR" "$DATA_DIR" "data"
|
||||
migrate_one_path "$LEGACY_PYMC_INSTALL_DIR" "$INSTALL_DIR" "install"
|
||||
}
|
||||
|
||||
ensure_service_user_home() {
|
||||
local current_home
|
||||
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
current_home="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
|
||||
if [ -z "$current_home" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$current_home" != "$DATA_DIR" ]; then
|
||||
mkdir -p "$DATA_DIR" 2>/dev/null || true
|
||||
usermod -d "$DATA_DIR" "$SERVICE_USER" 2>/dev/null || true
|
||||
echo " ✓ Updated $SERVICE_USER home directory: $current_home -> $DATA_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create (or re-create) the dedicated venv for openhop_repeater
|
||||
ensure_venv() {
|
||||
local recreate=0
|
||||
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
recreate=1
|
||||
elif ! "$VENV_PYTHON" -c 'import sys; print(sys.executable)' >/dev/null 2>&1; then
|
||||
# Venv python exists but points to a missing interpreter (stale venv).
|
||||
recreate=1
|
||||
elif ! "$VENV_PYTHON" -m pip --version >/dev/null 2>&1; then
|
||||
# Pip script/shebang can break after Python upgrades; treat as stale.
|
||||
recreate=1
|
||||
fi
|
||||
|
||||
if [ "$recreate" -eq 1 ]; then
|
||||
if [ -d "$VENV_DIR" ]; then
|
||||
echo ">>> Rebuilding broken virtual environment at $VENV_DIR ..."
|
||||
rm -rf "$VENV_DIR"
|
||||
else
|
||||
echo ">>> Creating virtual environment at $VENV_DIR ..."
|
||||
fi
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# Always use python -m pip so we don't rely on a potentially stale pip wrapper.
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
# Migrate an existing system-pip install into the venv.
|
||||
# Idempotent: safe to call on every upgrade.
|
||||
migrate_to_venv() {
|
||||
@@ -38,37 +142,46 @@ migrate_to_venv() {
|
||||
|
||||
# 1. Ensure the venv exists
|
||||
ensure_venv
|
||||
ensure_service_user_home
|
||||
|
||||
# 2. Remove legacy PYTHONPATH from the service unit
|
||||
local svc_unit="/etc/systemd/system/pymc-repeater.service"
|
||||
local svc_unit="/etc/systemd/system/openhop-repeater.service"
|
||||
if [ -f "$svc_unit" ]; then
|
||||
if grep -q 'PYTHONPATH' "$svc_unit" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$svc_unit"
|
||||
echo " ✓ Removed legacy PYTHONPATH from service unit"
|
||||
fi
|
||||
# 3. Fix WorkingDirectory if still pointing at old source
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$svc_unit" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$svc_unit"
|
||||
if grep -q 'WorkingDirectory=/opt/openhop_repeater' "$svc_unit" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/openhop_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$svc_unit"
|
||||
echo " ✓ Fixed WorkingDirectory in service unit"
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater\|WorkingDirectory=/var/lib/pymc_repeater' "$svc_unit" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$svc_unit"
|
||||
sed -i 's|WorkingDirectory=/var/lib/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$svc_unit"
|
||||
echo " ✓ Migrated legacy WorkingDirectory to openhop path"
|
||||
fi
|
||||
# 4. Ensure ExecStart uses the venv python
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$svc_unit" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$svc_unit"
|
||||
echo " ✓ Updated ExecStart to use venv python"
|
||||
fi
|
||||
if grep -q 'ExecStart=/opt/pymc_repeater/venv/bin/python' "$svc_unit" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/opt/pymc_repeater/venv/bin/python|ExecStart=$VENV_PYTHON|" "$svc_unit"
|
||||
echo " ✓ Migrated legacy ExecStart to openhop venv"
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
|
||||
# 5. Remove the package from system python (best-effort)
|
||||
python3 -m pip uninstall -y openhop_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y openhop_core 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
echo " ✓ Cleaned up system-level packages (if any)"
|
||||
|
||||
# 6. Remove stale source trees that could shadow the venv package
|
||||
if [ -d "$INSTALL_DIR/repeater" ]; then
|
||||
rm -rf "$INSTALL_DIR/repeater"
|
||||
echo " ✓ Removed stale source tree from $INSTALL_DIR/repeater"
|
||||
fi
|
||||
cleanup_stale_source_trees
|
||||
}
|
||||
|
||||
is_silent_flag() {
|
||||
@@ -117,22 +230,22 @@ fi
|
||||
|
||||
# Function to show info box
|
||||
show_info() {
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "$1" --msgbox "$2" 12 70
|
||||
$DIALOG --backtitle "openHop Repeater Management" --title "$1" --msgbox "$2" 12 70
|
||||
}
|
||||
|
||||
# Function to show error box
|
||||
show_error() {
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "Error" --msgbox "$1" 8 60
|
||||
$DIALOG --backtitle "openHop Repeater Management" --title "Error" --msgbox "$1" 8 60
|
||||
}
|
||||
|
||||
# Function to ask yes/no question
|
||||
ask_yes_no() {
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "$1" --yesno "$2" 10 70
|
||||
$DIALOG --backtitle "openHop Repeater Management" --title "$1" --yesno "$2" 10 70
|
||||
}
|
||||
|
||||
# Function to show progress
|
||||
show_progress() {
|
||||
echo "$2" | $DIALOG --backtitle "pyMC Repeater Management" --title "$1" --gauge "$3" 8 70 0
|
||||
echo "$2" | $DIALOG --backtitle "openHop Repeater Management" --title "$1" --gauge "$3" 8 70 0
|
||||
}
|
||||
|
||||
# Function to check if service exists
|
||||
@@ -155,15 +268,35 @@ is_enabled() {
|
||||
systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Stop/disable legacy service names that can conflict on GPIO.
|
||||
disable_legacy_services() {
|
||||
local legacy_services="pymc-repeater pymc-repeater.service"
|
||||
local svc removed_unit=0
|
||||
|
||||
for svc in $legacy_services; do
|
||||
systemctl stop "$svc" >/dev/null 2>&1 || true
|
||||
systemctl disable "$svc" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
if [ -f /etc/systemd/system/pymc-repeater.service ]; then
|
||||
rm -f /etc/systemd/system/pymc-repeater.service
|
||||
removed_unit=1
|
||||
fi
|
||||
|
||||
if [ "$removed_unit" -eq 1 ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to get current version
|
||||
get_version() {
|
||||
# Read version from the pip-installed package in the venv
|
||||
if [ -x "$VENV_PYTHON" ]; then
|
||||
"$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \
|
||||
"$VENV_PYTHON" -c "from importlib.metadata import version; print(version('openhop_repeater'))" 2>/dev/null \
|
||||
|| echo "not installed"
|
||||
else
|
||||
# Fallback: try system python for pre-migration installs
|
||||
python3 -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \
|
||||
python3 -c "from importlib.metadata import version; print(version('openhop_repeater'))" 2>/dev/null \
|
||||
|| echo "not installed"
|
||||
fi
|
||||
}
|
||||
@@ -183,11 +316,11 @@ get_status_display() {
|
||||
show_main_menu() {
|
||||
local status=$(get_status_display)
|
||||
|
||||
CHOICE=$($DIALOG --backtitle "pyMC Repeater Management" --title "pyMC Repeater Management" --menu "\nCurrent Status: $status\n\nChoose an action:" 18 70 9 \
|
||||
"install" "Install pyMC Repeater" \
|
||||
CHOICE=$($DIALOG --backtitle "openHop Repeater Management" --title "openHop Repeater Management" --menu "\nCurrent Status: $status\n\nChoose an action:" 18 70 9 \
|
||||
"install" "Install openHop Repeater" \
|
||||
"upgrade" "Upgrade existing installation" \
|
||||
"reset" "reset existing installation to defaults" \
|
||||
"uninstall" "Remove pyMC Repeater completely" \
|
||||
"uninstall" "Remove openHop Repeater completely" \
|
||||
"config" "Configure radio settings" \
|
||||
"start" "Start the service" \
|
||||
"stop" "Stop the service" \
|
||||
@@ -199,7 +332,7 @@ show_main_menu() {
|
||||
case $CHOICE in
|
||||
"install")
|
||||
if is_installed; then
|
||||
show_error "pyMC Repeater is already installed!\n\nUse 'upgrade' to update or 'uninstall' first."
|
||||
show_error "openHop Repeater is already installed!\n\nUse 'upgrade' to update or 'uninstall' first."
|
||||
else
|
||||
install_repeater
|
||||
fi
|
||||
@@ -208,21 +341,21 @@ show_main_menu() {
|
||||
if is_installed; then
|
||||
upgrade_repeater "false"
|
||||
else
|
||||
show_error "pyMC Repeater is not installed!\n\nUse 'install' first."
|
||||
show_error "openHop Repeater is not installed!\n\nUse 'install' first."
|
||||
fi
|
||||
;;
|
||||
"reset")
|
||||
if is_installed; then
|
||||
reset_repeater
|
||||
else
|
||||
show_error "pyMC Repeater is not installed!\n\nUse 'install' first."
|
||||
show_error "openHop Repeater is not installed!\n\nUse 'install' first."
|
||||
fi
|
||||
;;
|
||||
"uninstall")
|
||||
if is_installed; then
|
||||
uninstall_repeater
|
||||
else
|
||||
show_error "pyMC Repeater is not installed."
|
||||
show_error "openHop Repeater is not installed."
|
||||
fi
|
||||
;;
|
||||
"config")
|
||||
@@ -240,7 +373,7 @@ show_main_menu() {
|
||||
"logs")
|
||||
clear
|
||||
echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[1;37mopenHop Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m"
|
||||
echo ""
|
||||
@@ -265,7 +398,7 @@ install_repeater() {
|
||||
|
||||
# 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
|
||||
$DIALOG --backtitle "openHop Repeater Management" --title "Welcome" --msgbox "\nWelcome to openHop 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)
|
||||
@@ -321,14 +454,17 @@ install_repeater() {
|
||||
# Installation progress
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " Installing pyMC Repeater"
|
||||
echo " Installing openHop Repeater"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
echo ">>> Creating service user..."
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
useradd --system --home /var/lib/pymc_repeater --shell /sbin/nologin "$SERVICE_USER"
|
||||
useradd --system --home "$DATA_DIR" --shell /sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
ensure_service_user_home
|
||||
|
||||
disable_legacy_services
|
||||
|
||||
(
|
||||
echo "10"; echo "# Adding user to hardware groups..."
|
||||
@@ -336,8 +472,12 @@ install_repeater() {
|
||||
getent group "$grp" >/dev/null 2>&1 && usermod -a -G "$grp" "$SERVICE_USER" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "20"; echo "# Creating directories..."
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
|
||||
echo "20"; echo "# Migrating legacy paths..."
|
||||
migrate_legacy_paths
|
||||
cleanup_stale_source_trees
|
||||
|
||||
echo "23"; echo "# Creating directories..."
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR"
|
||||
|
||||
echo "25"; echo "# Installing system dependencies..."
|
||||
apt-get update -qq
|
||||
@@ -367,9 +507,9 @@ install_repeater() {
|
||||
|
||||
echo "29"; echo "# Installing files..."
|
||||
cp "$SCRIPT_DIR/manage.sh" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/pymc-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-presets.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/openhop-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true
|
||||
|
||||
echo "45"; echo "# Installing configuration..."
|
||||
cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example"
|
||||
@@ -378,28 +518,28 @@ install_repeater() {
|
||||
fi
|
||||
|
||||
echo "55"; echo "# Installing systemd service..."
|
||||
cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/
|
||||
cp "$SCRIPT_DIR/openhop-repeater.service" /etc/systemd/system/
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "58"; echo "# Installing udev rules for CH341..."
|
||||
if [ -f "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" ]; then
|
||||
cp "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
|
||||
if [ -f "$SCRIPT_DIR/../openhop-core/99-ch341.rules" ]; then
|
||||
cp "$SCRIPT_DIR/../openhop-core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "65"; echo "# Setting 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
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR"
|
||||
chmod 750 "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR"
|
||||
# Ensure manage.sh and support files in INSTALL_DIR are accessible
|
||||
chown root:root "$INSTALL_DIR"
|
||||
chmod 755 "$INSTALL_DIR"
|
||||
# Ensure the service user can create subdirectories in their home directory
|
||||
chmod 755 /var/lib/pymc_repeater
|
||||
chmod 755 "$DATA_DIR"
|
||||
# Pre-create the .config directory that the service will need
|
||||
mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config
|
||||
mkdir -p "$DATA_DIR/.config/openhop_repeater"
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR/.config"
|
||||
|
||||
# Configure polkit for passwordless service restart
|
||||
|
||||
@@ -410,38 +550,38 @@ install_repeater() {
|
||||
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'
|
||||
cat > /etc/polkit-1/rules.d/10-openhop-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
action.lookup("unit") == "openhop-repeater.service" &&
|
||||
subject.user == "repeater") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-openhop-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]
|
||||
cat > /etc/polkit-1/localauthority/50-local.d/10-openhop-repeater.pkla <<'EOF'
|
||||
[Allow repeater to restart openhop-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
|
||||
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-openhop-repeater.pkla
|
||||
fi
|
||||
|
||||
# Also configure sudoers as fallback for service restart
|
||||
echo ">>> Configuring sudoers for service management..."
|
||||
mkdir -p /etc/sudoers.d
|
||||
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
|
||||
# Allow repeater user to manage the pymc-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
cat > /etc/sudoers.d/openhop-repeater <<'EOF'
|
||||
# Allow repeater user to manage the openhop-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart openhop-repeater, /usr/bin/systemctl stop openhop-repeater, /usr/bin/systemctl start openhop-repeater, /usr/bin/systemctl status openhop-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/pymc-repeater
|
||||
chmod 0440 /etc/sudoers.d/openhop-repeater
|
||||
|
||||
echo ">>> Installing OTA upgrade wrapper..."
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
@@ -451,7 +591,7 @@ EOF
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
PRETEND_VERSION="${2:-}"
|
||||
VENV_DIR="/opt/pymc_repeater/venv"
|
||||
VENV_DIR="/opt/openhop_repeater/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
# Validate: only allow safe git ref characters
|
||||
@@ -466,25 +606,39 @@ fi
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
fi
|
||||
# ---- Migration: clean up legacy service unit issues ----
|
||||
SVC_UNIT=/etc/systemd/system/pymc-repeater.service
|
||||
SVC_UNIT=/etc/systemd/system/openhop-repeater.service
|
||||
if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT"
|
||||
if grep -q 'WorkingDirectory=/opt/openhop_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/openhop_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater\|WorkingDirectory=/var/lib/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
sed -i 's|WorkingDirectory=/var/lib/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/opt/pymc_repeater/venv/bin/python' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/opt/pymc_repeater/venv/bin/python|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# ---- Remove stale source trees that shadow the venv package ----
|
||||
[ -d /opt/openhop_repeater/repeater ] && rm -rf /opt/openhop_repeater/repeater
|
||||
[ -d /opt/openhop_repeater/openhop-repeater ] && rm -rf /opt/openhop_repeater/openhop-repeater
|
||||
[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater
|
||||
[ -d /opt/pymc_repeater/pymc-repeater ] && rm -rf /opt/pymc_repeater/pymc-repeater
|
||||
# ---- Remove old system-level packages to avoid confusion ----
|
||||
python3 -m pip uninstall -y openhop_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y openhop_core 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
# ---- Try R2 wheels first for faster OTA upgrades ----
|
||||
@@ -500,16 +654,16 @@ 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 --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
|
||||
"$VENV_PYTHON" -m pip install --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 ----
|
||||
if "$VENV_PIP" install \
|
||||
# ---- Install openhop_repeater from git ----
|
||||
if "$VENV_PYTHON" -m pip install \
|
||||
--upgrade \
|
||||
--no-cache-dir \
|
||||
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"; then
|
||||
"openhop_repeater[hardware] @ git+https://github.com/openhop-dev/openhop_repeater.git@${CHANNEL}"; then
|
||||
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
|
||||
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/pyMC_Repeater/${CHANNEL}"
|
||||
RADIO_STORAGE_DIR="/var/lib/pymc_repeater"
|
||||
RADIO_BASE_URL="https://raw.githubusercontent.com/openhop-dev/openhop_repeater/${CHANNEL}"
|
||||
RADIO_STORAGE_DIR="/var/lib/openhop_repeater"
|
||||
mkdir -p "$RADIO_STORAGE_DIR"
|
||||
wget -qO "$RADIO_STORAGE_DIR/radio-settings.json" "${RADIO_BASE_URL}/radio-settings.json" 2>/dev/null || true
|
||||
wget -qO "$RADIO_STORAGE_DIR/radio-presets.json" "${RADIO_BASE_URL}/radio-presets.json" 2>/dev/null || true
|
||||
@@ -523,13 +677,13 @@ UPGRADEEOF
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
|
||||
echo "90"; echo "# Installation files complete..."
|
||||
) | $DIALOG --backtitle "pyMC Repeater Management" --title "Installing" --gauge "Setting up pyMC Repeater..." 8 70 0
|
||||
) | $DIALOG --backtitle "openHop Repeater Management" --title "Installing" --gauge "Setting up openHop Repeater..." 8 70 0
|
||||
|
||||
# Install Python package outside of progress gauge for better error handling
|
||||
clear
|
||||
echo "=== Installing Python Dependencies ==="
|
||||
echo ""
|
||||
echo "Installing pymc_repeater and dependencies (including pymc_core from PyPI)..."
|
||||
echo "Installing openhop_repeater and dependencies (including openhop_core from PyPI)..."
|
||||
echo "This may take a few minutes..."
|
||||
echo ""
|
||||
|
||||
@@ -556,7 +710,7 @@ UPGRADEEOF
|
||||
# Ensure venv exists
|
||||
ensure_venv
|
||||
|
||||
echo "Installing pymc_repeater into venv ($VENV_DIR)..."
|
||||
echo "Installing openhop_repeater into venv ($VENV_DIR)..."
|
||||
|
||||
# Attempt R2 wheels first for faster installation
|
||||
if [ "$R2_ENABLED" -eq 1 ]; then
|
||||
@@ -572,7 +726,7 @@ UPGRADEEOF
|
||||
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 --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
|
||||
"$VENV_PYTHON" -m pip install --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
|
||||
@@ -581,7 +735,7 @@ UPGRADEEOF
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
|
||||
if "$VENV_PYTHON" -m pip install --upgrade --no-cache-dir .[hardware]; then
|
||||
echo ""
|
||||
echo "✓ Python package installation completed successfully!"
|
||||
|
||||
@@ -660,7 +814,7 @@ reset_repeater() {
|
||||
|
||||
local current_version=$(get_version)
|
||||
|
||||
if ask_yes_no "Confirm Reset of pyMC Repeater restoring to default configuration.\n\nContinue?"; then
|
||||
if ask_yes_no "Confirm Reset of openHop Repeater restoring to default configuration.\n\nContinue?"; then
|
||||
|
||||
# Show info that upgrade is starting
|
||||
show_info "Reseting" "Starting reset process...\n\nProgress will be shown in the terminal."
|
||||
@@ -731,7 +885,7 @@ upgrade_repeater() {
|
||||
local current_version=$(get_version)
|
||||
|
||||
if [[ "$silent" != "true" ]]; then
|
||||
if ! ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then
|
||||
if ! ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade openHop Repeater while preserving your configuration.\n\nContinue?"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -745,6 +899,15 @@ upgrade_repeater() {
|
||||
echo "=== Upgrade Progress ==="
|
||||
echo "[1/9] Stopping service..."
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
disable_legacy_services
|
||||
|
||||
echo "[1.5/9] Migrating legacy paths..."
|
||||
migrate_legacy_paths
|
||||
cleanup_stale_source_trees
|
||||
|
||||
echo "[1.6/9] Ensuring required directories..."
|
||||
mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR"
|
||||
ensure_service_user_home
|
||||
|
||||
echo "[2/9] Backing up configuration..."
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
@@ -777,11 +940,11 @@ upgrade_repeater() {
|
||||
|
||||
echo "[4/9] Installing files..."
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
if ! cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/; then
|
||||
if ! cp "$SCRIPT_DIR/openhop-repeater.service" /etc/systemd/system/; then
|
||||
echo " ⚠ Warning: Failed to update service file – old service file may remain"
|
||||
fi
|
||||
cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-presets.json" /var/lib/pymc_repeater/ 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true
|
||||
echo " ✓ Files updated"
|
||||
|
||||
echo "[5/9] Validating and updating configuration..."
|
||||
@@ -797,8 +960,8 @@ upgrade_repeater() {
|
||||
done
|
||||
# Install/update CH341 udev rules
|
||||
SCRIPT_DIR_UPGRADE="$(cd "$(dirname "$0")" && pwd)"
|
||||
if [ -f "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" ]; then
|
||||
cp "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
|
||||
if [ -f "$SCRIPT_DIR_UPGRADE/../openhop-core/99-ch341.rules" ]; then
|
||||
cp "$SCRIPT_DIR_UPGRADE/../openhop-core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules
|
||||
udevadm control --reload-rules 2>/dev/null || true
|
||||
udevadm trigger 2>/dev/null || true
|
||||
echo " ✓ CH341 udev rules updated"
|
||||
@@ -810,15 +973,15 @@ upgrade_repeater() {
|
||||
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 -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" 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
|
||||
chmod 755 "$DATA_DIR" 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
|
||||
mkdir -p "$DATA_DIR/.config/openhop_repeater" 2>/dev/null || true
|
||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR/.config" 2>/dev/null || true
|
||||
|
||||
# Configure polkit for passwordless service restart
|
||||
POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}')
|
||||
@@ -826,36 +989,36 @@ upgrade_repeater() {
|
||||
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'
|
||||
cat > /etc/polkit-1/rules.d/10-openhop-repeater.rules <<'EOF'
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
||||
action.lookup("unit") == "pymc-repeater.service" &&
|
||||
action.lookup("unit") == "openhop-repeater.service" &&
|
||||
subject.user == "repeater") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
EOF
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules
|
||||
chmod 0644 /etc/polkit-1/rules.d/10-openhop-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]
|
||||
cat > /etc/polkit-1/localauthority/50-local.d/10-openhop-repeater.pkla <<'EOF'
|
||||
[Allow repeater to restart openhop-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
|
||||
chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-openhop-repeater.pkla
|
||||
fi
|
||||
# Also configure sudoers as fallback for service restart
|
||||
mkdir -p /etc/sudoers.d
|
||||
cat > /etc/sudoers.d/pymc-repeater <<'EOF'
|
||||
# Allow repeater user to manage the pymc-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
cat > /etc/sudoers.d/openhop-repeater <<'EOF'
|
||||
# Allow repeater user to manage the openhop-repeater service without password
|
||||
repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart openhop-repeater, /usr/bin/systemctl stop openhop-repeater, /usr/bin/systemctl start openhop-repeater, /usr/bin/systemctl status openhop-repeater, /usr/local/bin/pymc-do-upgrade
|
||||
EOF
|
||||
chmod 0440 /etc/sudoers.d/pymc-repeater
|
||||
chmod 0440 /etc/sudoers.d/openhop-repeater
|
||||
# Install / refresh OTA upgrade wrapper
|
||||
cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF'
|
||||
#!/bin/bash
|
||||
@@ -864,7 +1027,7 @@ EOF
|
||||
set -e
|
||||
CHANNEL="${1:-main}"
|
||||
PRETEND_VERSION="${2:-}"
|
||||
VENV_DIR="/opt/pymc_repeater/venv"
|
||||
VENV_DIR="/opt/openhop_repeater/venv"
|
||||
VENV_PIP="$VENV_DIR/bin/pip"
|
||||
VENV_PYTHON="$VENV_DIR/bin/python"
|
||||
# Validate: only allow safe git ref characters
|
||||
@@ -879,25 +1042,39 @@ fi
|
||||
if [ ! -x "$VENV_PYTHON" ]; then
|
||||
echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..."
|
||||
python3 -m venv --system-site-packages "$VENV_DIR"
|
||||
"$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
"$VENV_PYTHON" -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
fi
|
||||
# ---- Migration: clean up legacy service unit issues ----
|
||||
SVC_UNIT=/etc/systemd/system/pymc-repeater.service
|
||||
SVC_UNIT=/etc/systemd/system/openhop-repeater.service
|
||||
if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT"
|
||||
if grep -q 'WorkingDirectory=/opt/openhop_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/openhop_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'WorkingDirectory=/opt/pymc_repeater\|WorkingDirectory=/var/lib/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
sed -i 's|WorkingDirectory=/var/lib/pymc_repeater|WorkingDirectory=/var/lib/openhop_repeater|' "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
if grep -q 'ExecStart=/opt/pymc_repeater/venv/bin/python' "$SVC_UNIT" 2>/dev/null; then
|
||||
sed -i "s|ExecStart=/opt/pymc_repeater/venv/bin/python|ExecStart=$VENV_PYTHON|" "$SVC_UNIT"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
# ---- Remove stale source trees that shadow the venv package ----
|
||||
[ -d /opt/openhop_repeater/repeater ] && rm -rf /opt/openhop_repeater/repeater
|
||||
[ -d /opt/openhop_repeater/openhop-repeater ] && rm -rf /opt/openhop_repeater/openhop-repeater
|
||||
[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater
|
||||
[ -d /opt/pymc_repeater/pymc-repeater ] && rm -rf /opt/pymc_repeater/pymc-repeater
|
||||
# ---- Remove old system-level packages to avoid confusion ----
|
||||
python3 -m pip uninstall -y openhop_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y openhop_core 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true
|
||||
python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
# ---- Try R2 wheels first for faster OTA upgrades ----
|
||||
@@ -913,16 +1090,16 @@ python3 -m pip uninstall -y pymc_core 2>/dev/null || true
|
||||
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 --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
|
||||
"$VENV_PYTHON" -m pip install --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 ----
|
||||
if "$VENV_PIP" install \
|
||||
# ---- Install openhop_repeater from git ----
|
||||
if "$VENV_PYTHON" -m pip install \
|
||||
--upgrade \
|
||||
--no-cache-dir \
|
||||
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"; then
|
||||
"openhop_repeater[hardware] @ git+https://github.com/openhop-dev/openhop_repeater.git@${CHANNEL}"; then
|
||||
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
|
||||
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/pyMC_Repeater/${CHANNEL}"
|
||||
RADIO_STORAGE_DIR="/var/lib/pymc_repeater"
|
||||
RADIO_BASE_URL="https://raw.githubusercontent.com/openhop-dev/openhop_repeater/${CHANNEL}"
|
||||
RADIO_STORAGE_DIR="/var/lib/openhop_repeater"
|
||||
mkdir -p "$RADIO_STORAGE_DIR"
|
||||
wget -qO "$RADIO_STORAGE_DIR/radio-settings.json" "${RADIO_BASE_URL}/radio-settings.json" 2>/dev/null || true
|
||||
wget -qO "$RADIO_STORAGE_DIR/radio-presets.json" "${RADIO_BASE_URL}/radio-presets.json" 2>/dev/null || true
|
||||
@@ -939,7 +1116,7 @@ UPGRADEEOF
|
||||
|
||||
echo "=== Installing Python Dependencies ==="
|
||||
echo ""
|
||||
echo "Updating pymc_repeater and dependencies (including pymc_core from PyPI)..."
|
||||
echo "Updating openhop_repeater and dependencies (including openhop_core from PyPI)..."
|
||||
echo "This may take a few minutes..."
|
||||
echo ""
|
||||
|
||||
@@ -969,7 +1146,7 @@ UPGRADEEOF
|
||||
migrate_to_venv
|
||||
|
||||
# Install into the venv (clean, no system-packages flags needed)
|
||||
echo "Upgrading pymc_repeater into venv ($VENV_DIR)..."
|
||||
echo "Upgrading openhop_repeater into venv ($VENV_DIR)..."
|
||||
|
||||
# Attempt R2 wheels first for faster installation
|
||||
if [ "$R2_ENABLED" -eq 1 ]; then
|
||||
@@ -985,7 +1162,7 @@ UPGRADEEOF
|
||||
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 --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
|
||||
"$VENV_PYTHON" -m pip install --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
|
||||
@@ -994,7 +1171,7 @@ UPGRADEEOF
|
||||
fi
|
||||
fi
|
||||
|
||||
if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then
|
||||
if "$VENV_PYTHON" -m pip install --upgrade --no-cache-dir .[hardware]; then
|
||||
echo ""
|
||||
echo "✓ Package and dependencies upgraded successfully!"
|
||||
else
|
||||
@@ -1091,10 +1268,10 @@ uninstall_repeater() {
|
||||
return
|
||||
fi
|
||||
|
||||
if ask_yes_no "Confirm Uninstall" "This will completely remove pyMC Repeater including:\n\n- Service and files\n- Configuration (backup will be created)\n- Logs and data\n\nThis action cannot be undone!\n\nContinue?"; then
|
||||
if ask_yes_no "Confirm Uninstall" "This will completely remove openHop Repeater including:\n\n- Service and files\n- Configuration (backup will be created)\n- Logs and data\n\nThis action cannot be undone!\n\nContinue?"; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " Uninstalling pyMC Repeater"
|
||||
echo " Uninstalling openHop Repeater"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
@@ -1105,24 +1282,28 @@ uninstall_repeater() {
|
||||
(
|
||||
echo "20"; echo "# Backing up configuration..."
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
cp -r "$CONFIG_DIR" "/tmp/pymc_repeater_config_backup_$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
|
||||
cp -r "$CONFIG_DIR" "/tmp/openhop_repeater_config_backup_$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "40"; echo "# Removing service files..."
|
||||
rm -f /etc/systemd/system/pymc-repeater.service
|
||||
rm -f /etc/systemd/system/openhop-repeater.service
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "50"; echo "# Removing polkit and sudoers 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 /etc/polkit-1/rules.d/10-openhop-repeater.rules || true
|
||||
rm -f /etc/polkit-1/localauthority/50-local.d/10-openhop-repeater.pkla || true
|
||||
rm -f /etc/sudoers.d/openhop-repeater
|
||||
rm -f /usr/local/bin/pymc-do-upgrade
|
||||
|
||||
echo "60"; echo "# Removing installation..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
rm -rf "$CONFIG_DIR"
|
||||
rm -rf "$LOG_DIR"
|
||||
rm -rf /var/lib/pymc_repeater
|
||||
rm -rf "$DATA_DIR"
|
||||
rm -rf "$LEGACY_PYMC_INSTALL_DIR"
|
||||
rm -rf "$LEGACY_PYMC_CONFIG_DIR"
|
||||
rm -rf "$LEGACY_PYMC_LOG_DIR"
|
||||
rm -rf "$LEGACY_PYMC_DATA_DIR"
|
||||
|
||||
echo "80"; echo "# Removing service user..."
|
||||
if id "$SERVICE_USER" &>/dev/null; then
|
||||
@@ -1130,9 +1311,9 @@ uninstall_repeater() {
|
||||
fi
|
||||
|
||||
echo "100"; echo "# Uninstall complete!"
|
||||
) | $DIALOG --backtitle "pyMC Repeater Management" --title "Uninstalling" --gauge "Removing pyMC Repeater..." 8 70 0
|
||||
) | $DIALOG --backtitle "openHop Repeater Management" --title "Uninstalling" --gauge "Removing openHop Repeater..." 8 70 0
|
||||
|
||||
show_info "Uninstall Complete" "\npyMC Repeater has been completely removed.\n\nConfiguration backup saved to /tmp/\n\nThank you for using pyMC Repeater!"
|
||||
show_info "Uninstall Complete" "\nopenHop Repeater has been completely removed.\n\nConfiguration backup saved to /tmp/\n\nThank you for using openHop Repeater!"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -1167,9 +1348,9 @@ manage_service() {
|
||||
systemctl start "$SERVICE_NAME"
|
||||
if is_running; then
|
||||
if [[ "$silent" == "true" ]]; then
|
||||
echo "✓ pyMC Repeater service has been started successfully."
|
||||
echo "✓ openHop Repeater service has been started successfully."
|
||||
else
|
||||
show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully."
|
||||
show_info "Service Started" "\n✓ openHop Repeater service has been started successfully."
|
||||
fi
|
||||
else
|
||||
if [[ "$silent" == "true" ]]; then
|
||||
@@ -1183,18 +1364,18 @@ manage_service() {
|
||||
"stop")
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
if [[ "$silent" == "true" ]]; then
|
||||
echo "✓ pyMC Repeater service has been stopped."
|
||||
echo "✓ openHop Repeater service has been stopped."
|
||||
else
|
||||
show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped."
|
||||
show_info "Service Stopped" "\n✓ openHop Repeater service has been stopped."
|
||||
fi
|
||||
;;
|
||||
"restart")
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
if is_running; then
|
||||
if [[ "$silent" == "true" ]]; then
|
||||
echo "✓ pyMC Repeater service has been restarted successfully."
|
||||
echo "✓ openHop Repeater service has been restarted successfully."
|
||||
else
|
||||
show_info "Service Restarted" "\n✓ pyMC Repeater service has been restarted successfully."
|
||||
show_info "Service Restarted" "\n✓ openHop Repeater service has been restarted successfully."
|
||||
fi
|
||||
else
|
||||
if [[ "$silent" == "true" ]]; then
|
||||
@@ -1254,6 +1435,19 @@ validate_and_update_config() {
|
||||
local example_file="config.yaml.example"
|
||||
local updated_example="$CONFIG_DIR/config.yaml.example"
|
||||
|
||||
normalize_legacy_paths_in_config() {
|
||||
local target_file="$1"
|
||||
[ -f "$target_file" ] || return 0
|
||||
|
||||
sed -i 's|/var/lib/pymc_repeater|/var/lib/openhop_repeater|g' "$target_file" 2>/dev/null || true
|
||||
sed -i 's|/etc/pymc_repeater|/etc/openhop_repeater|g' "$target_file" 2>/dev/null || true
|
||||
sed -i 's|/var/log/pymc_repeater|/var/log/openhop_repeater|g' "$target_file" 2>/dev/null || true
|
||||
sed -i 's|/opt/pymc_repeater|/opt/openhop_repeater|g' "$target_file" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Ensure destination config directory exists before copy/merge steps.
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
# Copy the new example file
|
||||
if [ -f "$example_file" ]; then
|
||||
cp "$example_file" "$updated_example"
|
||||
@@ -1266,6 +1460,7 @@ validate_and_update_config() {
|
||||
if [ ! -f "$config_file" ]; then
|
||||
echo " ⚠ No existing config.yaml found, copying example"
|
||||
cp "$updated_example" "$config_file"
|
||||
normalize_legacy_paths_in_config "$config_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -1306,32 +1501,36 @@ validate_and_update_config() {
|
||||
# Verify the merged file is valid YAML
|
||||
if "$YQ_CMD" eval '.' "$temp_merged" > /dev/null 2>&1; then
|
||||
mv "$temp_merged" "$config_file"
|
||||
normalize_legacy_paths_in_config "$config_file"
|
||||
echo " ✓ Configuration merged successfully"
|
||||
echo " ✓ Legacy pymc_* paths normalized"
|
||||
echo " ✓ User settings preserved, new options added"
|
||||
return 0
|
||||
else
|
||||
echo " ✗ Merged config is invalid, restoring backup"
|
||||
rm -f "$temp_merged"
|
||||
cp "$backup_file" "$config_file"
|
||||
normalize_legacy_paths_in_config "$config_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo " ✗ Config merge failed, keeping original"
|
||||
rm -f "$temp_merged" "$stripped_user"
|
||||
normalize_legacy_paths_in_config "$config_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||
echo "pyMC Repeater Management Script"
|
||||
echo "openHop Repeater Management Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [action]"
|
||||
echo ""
|
||||
echo "Actions:"
|
||||
echo " install - Install pyMC Repeater"
|
||||
echo " install - Install openHop Repeater"
|
||||
echo " upgrade - Upgrade existing installation (CLI is silent by default; use --interactive to show dialogs)"
|
||||
echo " uninstall - Remove pyMC Repeater"
|
||||
echo " uninstall - Remove openHop Repeater"
|
||||
echo " config - Configure radio settings"
|
||||
echo " start - Start the service (CLI is silent by default; use --interactive to show dialogs)"
|
||||
echo " stop - Stop the service (CLI is silent by default; use --interactive to show dialogs)"
|
||||
@@ -1355,7 +1554,7 @@ if [ "$1" = "debug" ]; then
|
||||
echo "Script: $0"
|
||||
echo ""
|
||||
echo "Testing dialog..."
|
||||
$DIALOG --backtitle "pyMC Repeater Management" --title "Test" --msgbox "Dialog test successful!" 8 40
|
||||
$DIALOG --backtitle "openHop Repeater Management" --title "Test" --msgbox "Dialog test successful!" 8 40
|
||||
echo "Dialog test completed."
|
||||
exit 0
|
||||
fi
|
||||
@@ -1393,7 +1592,7 @@ case "$1" in
|
||||
"logs")
|
||||
clear
|
||||
echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[1;37mopenHop Repeater - Live Logs\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m"
|
||||
echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m"
|
||||
echo ""
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#Systemd service file template for Py MC - Meshcore Repeater Daemon.
|
||||
#Install as /etc/systemd/system/pymc-repeater.service
|
||||
#Systemd service file template for openHop - Meshcore Repeater Daemon.
|
||||
#Install as /etc/systemd/system/openhop-repeater.service
|
||||
|
||||
[Unit]
|
||||
Description=pyMC Repeater Daemon
|
||||
Description=openHop Repeater Daemon
|
||||
After=network-online.target dbus.socket
|
||||
Wants=network-online.target
|
||||
Requires=dbus.socket
|
||||
@@ -11,10 +11,10 @@ Requires=dbus.socket
|
||||
Type=simple
|
||||
User=repeater
|
||||
Group=repeater
|
||||
WorkingDirectory=/var/lib/pymc_repeater
|
||||
WorkingDirectory=/var/lib/openhop_repeater
|
||||
|
||||
# Start command - use venv python to avoid system package conflicts
|
||||
ExecStart=/opt/pymc_repeater/venv/bin/python -m repeater.main --config /etc/pymc_repeater/config.yaml
|
||||
ExecStart=/opt/openhop_repeater/venv/bin/python -m repeater.main --config /etc/openhop_repeater/config.yaml
|
||||
|
||||
# Restart on failure
|
||||
Restart=on-failure
|
||||
@@ -29,10 +29,10 @@ MemoryHigh=256M
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=pymc-repeater
|
||||
SyslogIdentifier=openhop-repeater
|
||||
|
||||
# Security (relaxed for service self-restart via sudo)
|
||||
ReadWritePaths=/var/log/pymc_repeater /var/lib/pymc_repeater /etc/pymc_repeater
|
||||
ReadWritePaths=/var/log/openhop_repeater /var/lib/openhop_repeater /etc/openhop_repeater
|
||||
SupplementaryGroups=plugdev dialout
|
||||
|
||||
# Allow GPS time sync to update CLOCK_REALTIME without running as root, also allow changing user via polkit or sudo
|
||||
+5
-5
@@ -3,10 +3,10 @@ requires = ["setuptools>=61.0", "wheel", "setuptools_scm>=8.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pymc_repeater"
|
||||
name = "openhop_repeater"
|
||||
dynamic = ["version"]
|
||||
authors = [
|
||||
{name = "Rightup", email = "rightup@pymc.dev"},
|
||||
{name = "Rightup", email = "rightup@openhop.dev"},
|
||||
]
|
||||
description = "PyMC Repeater Daemon"
|
||||
readme = "README.md"
|
||||
@@ -29,7 +29,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
|
||||
|
||||
|
||||
dependencies = [
|
||||
"pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev",
|
||||
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@feature/publish-workflow-message-handling",
|
||||
"pyyaml>=6.0.0",
|
||||
"cherrypy>=18.0.0",
|
||||
"paho-mqtt>=1.6.0",
|
||||
@@ -45,7 +45,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
# SX1262/SPI support (Linux only; required for Raspberry Pi HATs)
|
||||
hardware = [
|
||||
"pymc_core[hardware] @ git+https://github.com/rightup/pyMC_core.git@dev",
|
||||
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@feature/publish-workflow-message-handling",
|
||||
]
|
||||
# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool)
|
||||
rrd = [
|
||||
@@ -59,7 +59,7 @@ dev = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
pymc-repeater = "repeater.main:main"
|
||||
openhop-repeater = "repeater.main:main"
|
||||
pymc-cli = "repeater.local_cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
+40
-20
@@ -133,35 +133,39 @@
|
||||
"zebra-duo-hat-r0": {
|
||||
"name": "ZebraHatDuo-R0-1W",
|
||||
"bus_id": 0,
|
||||
"busy_pin": 23,
|
||||
"cs_id": 0,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 18,
|
||||
"busy_pin": 23,
|
||||
"irq_pin": 24,
|
||||
"txen_pin": -1,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"en_pin": -1,
|
||||
"irq_pin": 18,
|
||||
"is_waveshare": false,
|
||||
"reset_pin": 24,
|
||||
"rxen_pin": -1,
|
||||
"txled_pin": -1,
|
||||
"rxled_pin": -1,
|
||||
"tx_power": 18,
|
||||
"use_dio3_tcxo": true,
|
||||
"txen_pin": -1,
|
||||
"txled_pin": -1,
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"preamble_length": 32
|
||||
},
|
||||
"zebra-duo-hat-r1": {
|
||||
"name": "ZebraHatDuo-R1-1W",
|
||||
"bus_id": 0,
|
||||
"busy_pin": 27,
|
||||
"cs_id": 1,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 17,
|
||||
"busy_pin": 27,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"en_pin": -1,
|
||||
"irq_pin": 22,
|
||||
"txen_pin": -1,
|
||||
"is_waveshare": false,
|
||||
"reset_pin": 17,
|
||||
"rxen_pin": -1,
|
||||
"txled_pin": -1,
|
||||
"rxled_pin": -1,
|
||||
"tx_power": 18,
|
||||
"use_dio3_tcxo": true,
|
||||
"txen_pin": -1,
|
||||
"txled_pin": -1,
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"preamble_length": 32
|
||||
},
|
||||
"femtofox-1W-SX": {
|
||||
@@ -312,9 +316,7 @@
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"preamble_length": 32,
|
||||
"use_gpiod_backend": true,
|
||||
"gpio_chip": 1
|
||||
"preamble_length": 32
|
||||
},
|
||||
"rak6421-13300x-slot2": {
|
||||
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
|
||||
@@ -333,9 +335,27 @@
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"preamble_length": 32,
|
||||
"use_gpiod_backend": true,
|
||||
"gpio_chip": 1
|
||||
"preamble_length": 32
|
||||
},
|
||||
"rak6421-13300x-slot2": {
|
||||
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
|
||||
"description": "Use this radio settings preset if you have modded your pi hat to work with dual radios see https://discord.com/channels/1495203904898728149/1512614022913200159 .",
|
||||
"bus_id": 0,
|
||||
"cs_id": 1,
|
||||
"cs_pin": -1,
|
||||
"reset_pin": 27,
|
||||
"busy_pin": 19,
|
||||
"irq_pin": 18,
|
||||
"txen_pin": -1,
|
||||
"rxen_pin": -1,
|
||||
"en_pins": [26, 23],
|
||||
"txled_pin": -1,
|
||||
"rxled_pin": -1,
|
||||
"tx_power": 22,
|
||||
"use_dio2_rf": true,
|
||||
"use_dio3_tcxo": true,
|
||||
"dio3_tcxo_voltage": 1.8,
|
||||
"preamble_length": 32
|
||||
},
|
||||
"ultrapeaterzero-e22": {
|
||||
"name": "Zindello Industries UltraPeaterZero E22",
|
||||
@@ -411,7 +431,7 @@
|
||||
},
|
||||
"kiss": {
|
||||
"name": "KISS modem (serial)",
|
||||
"description": "MeshCore KISS modem over serial - requires pyMC_core with KISS support.",
|
||||
"description": "MeshCore KISS modem over serial - requires openhop-core with KISS support.",
|
||||
"connection_type": "usb",
|
||||
"radio_type": "kiss",
|
||||
"tx_power": 14,
|
||||
|
||||
@@ -4,6 +4,6 @@ except ImportError:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("pymc_repeater")
|
||||
__version__ = version("openhop_repeater")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Companion identity support for pyMC Repeater.
|
||||
"""Companion identity support for openHop Repeater.
|
||||
|
||||
Exposes the MeshCore companion frame protocol over TCP for standard clients.
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from pymc_core.companion import CompanionBridge
|
||||
from openhop_core.companion import CompanionBridge
|
||||
|
||||
logger = logging.getLogger("RepeaterCompanionBridge")
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Companion frame protocol constants — re-exported from pyMC_core.
|
||||
"""Companion frame protocol constants — re-exported from openhop-core.
|
||||
|
||||
All protocol constants now live in :mod:`pymc_core.companion.constants`.
|
||||
All protocol constants now live in :mod:`openhop_core.companion.constants`.
|
||||
This module re-exports them so existing repeater imports continue to work.
|
||||
"""
|
||||
|
||||
# Re-exports; F401 ignored for re-exported names.
|
||||
from pymc_core.companion.constants import ( # noqa: F401
|
||||
from openhop_core.companion.constants import ( # noqa: F401
|
||||
ADV_TYPE_CHAT,
|
||||
ADV_TYPE_REPEATER,
|
||||
ADV_TYPE_ROOM,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Repeater-specific CompanionFrameServer with SQLite persistence.
|
||||
|
||||
Thin subclass of :class:`pymc_core.companion.frame_server.CompanionFrameServer`
|
||||
Thin subclass of :class:`openhop_core.companion.frame_server.CompanionFrameServer`
|
||||
that adds SQLite-backed message, contact, and channel persistence via a
|
||||
``sqlite_handler`` dependency.
|
||||
"""
|
||||
@@ -12,9 +12,9 @@ import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from pymc_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES
|
||||
from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer
|
||||
from pymc_core.companion.models import QueuedMessage
|
||||
from openhop_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES
|
||||
from openhop_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer
|
||||
from openhop_core.companion.models import QueuedMessage
|
||||
|
||||
logger = logging.getLogger("CompanionFrameServer")
|
||||
|
||||
@@ -45,8 +45,8 @@ class CompanionFrameServer(_BaseFrameServer):
|
||||
port=port,
|
||||
bind_address=bind_address,
|
||||
client_idle_timeout_sec=client_idle_timeout_sec,
|
||||
device_model="pyMC-Repeater-Companion",
|
||||
device_version=None, # use FIRMWARE_VER_CODE from pyMC_core
|
||||
device_model="openHop-Repeater-Companion",
|
||||
device_version=None, # use FIRMWARE_VER_CODE from openhop-core
|
||||
build_date="13 Feb 2026",
|
||||
local_hash=local_hash,
|
||||
stats_getter=stats_getter,
|
||||
|
||||
@@ -50,7 +50,7 @@ def derive_companion_public_key_hex(identity_key: Any) -> Optional[str]:
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
from openhop_core import LocalIdentity
|
||||
|
||||
identity = LocalIdentity(seed=raw)
|
||||
return identity.get_public_key().hex()
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pymc_core.companion.constants import DEFAULT_MAX_CONTACTS
|
||||
from openhop_core.companion.constants import DEFAULT_MAX_CONTACTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@ _INVALID_NODE_NAME_CHARS = "\n\r\x00"
|
||||
# Optional per-companion RepeaterCompanionBridge constructor settings (power-user).
|
||||
COMPANION_BRIDGE_SETTING_KEYS = frozenset({"max_contacts", "offline_queue_size"})
|
||||
|
||||
# Settings that must not be applied from config (fixed at pymc_core defaults).
|
||||
# Settings that must not be applied from config (fixed at openhop_core defaults).
|
||||
_COMPANION_IGNORED_BRIDGE_KEYS = frozenset({"max_channels", "adv_type"})
|
||||
|
||||
# Contact flag bit 0 marks a favourite (protected from forced-trim eviction).
|
||||
@@ -102,7 +102,7 @@ def parse_companion_bridge_kwargs(settings: dict) -> Dict[str, int]:
|
||||
|
||||
|
||||
def effective_max_contacts(bridge_kwargs: Dict[str, int]) -> int:
|
||||
"""Return max_contacts from parsed kwargs or pymc_core default."""
|
||||
"""Return max_contacts from parsed kwargs or openhop_core default."""
|
||||
return bridge_kwargs.get("max_contacts", DEFAULT_MAX_CONTACTS)
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ def format_companion_bridge_limits(bridge_kwargs: Dict[str, int]) -> str:
|
||||
|
||||
def companion_hash_str_from_identity_key(identity_key: Any) -> str:
|
||||
"""Derive companion_hash storage key (0xHH) from an identity_key config value."""
|
||||
from pymc_core import LocalIdentity
|
||||
from openhop_core import LocalIdentity
|
||||
|
||||
if isinstance(identity_key, str):
|
||||
key_bytes = bytes.fromhex(normalize_companion_identity_key(identity_key))
|
||||
|
||||
+85
-27
@@ -101,11 +101,46 @@ class NullRadio:
|
||||
return True
|
||||
|
||||
|
||||
class BaselineCrcCounterRadio:
|
||||
"""Radio proxy that exposes CRC errors relative to repeater startup.
|
||||
|
||||
pyMC modem transports report the modem firmware's cumulative CRC counter.
|
||||
The SX1262 wrapper's counter starts at process startup, which lets the engine
|
||||
persist deltas without knowing the radio backend. Mirror that wrapper flow
|
||||
here by normalizing the modem's raw counter at the transport boundary.
|
||||
"""
|
||||
|
||||
def __init__(self, radio):
|
||||
self._radio = radio
|
||||
self._crc_baseline = self._read_raw_crc_count()
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self._radio, name)
|
||||
|
||||
@property
|
||||
def crc_error_count(self) -> int:
|
||||
current = self._read_raw_crc_count()
|
||||
if self._crc_baseline <= 0 and current > 0:
|
||||
self._crc_baseline = current
|
||||
return 0
|
||||
return max(0, current - self._crc_baseline)
|
||||
|
||||
@crc_error_count.setter
|
||||
def crc_error_count(self, value: Any) -> None:
|
||||
setattr(self._radio, "crc_error_count", value)
|
||||
|
||||
def _read_raw_crc_count(self) -> int:
|
||||
try:
|
||||
return int(getattr(self._radio, "crc_error_count", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def resolve_storage_dir(
|
||||
config: Dict[str, Any],
|
||||
*,
|
||||
config_path: Optional[str] = None,
|
||||
default: str = "/var/lib/pymc_repeater",
|
||||
default: str = "/var/lib/openhop_repeater",
|
||||
) -> Path:
|
||||
|
||||
storage_dir_cfg = (
|
||||
@@ -160,7 +195,10 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
|
||||
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
if config_path is None:
|
||||
config_path = os.getenv("PYMC_REPEATER_CONFIG", "/etc/pymc_repeater/config.yaml")
|
||||
config_path = os.getenv(
|
||||
"OPENHOP_REPEATER_CONFIG",
|
||||
os.getenv("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml"),
|
||||
)
|
||||
|
||||
# Check if config file exists
|
||||
if not Path(config_path).exists():
|
||||
@@ -192,6 +230,13 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
if "mesh" not in config:
|
||||
config["mesh"] = {}
|
||||
|
||||
if "http" not in config:
|
||||
config["http"] = {
|
||||
"enabled": True,
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
}
|
||||
|
||||
if "glass" not in config:
|
||||
config["glass"] = {
|
||||
"enabled": False,
|
||||
@@ -200,7 +245,7 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"request_timeout_seconds": 10,
|
||||
"verify_tls": True,
|
||||
"api_token": None,
|
||||
"cert_store_dir": "/etc/pymc_repeater/glass",
|
||||
"cert_store_dir": "/etc/openhop_repeater/glass",
|
||||
}
|
||||
|
||||
if "gps" not in config:
|
||||
@@ -214,6 +259,12 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"baud_rate": 9600,
|
||||
"read_timeout_seconds": 1.0,
|
||||
"reconnect_interval_seconds": 5.0,
|
||||
"host": "",
|
||||
"port": 80,
|
||||
"endpoint": "/api/stats",
|
||||
"scheme": "http",
|
||||
"username": "admin",
|
||||
"password": None,
|
||||
"stale_after_seconds": 10.0,
|
||||
"retain_sentences": 25,
|
||||
"validate_checksum": True,
|
||||
@@ -260,10 +311,11 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
else:
|
||||
config["repeater"]["identity_key"] = _load_or_create_identity_key()
|
||||
|
||||
if os.getenv("PYMC_REPEATER_LOG_LEVEL"):
|
||||
env_log_level = os.getenv("OPENHOP_REPEATER_LOG_LEVEL", os.getenv("PYMC_REPEATER_LOG_LEVEL"))
|
||||
if env_log_level:
|
||||
if "logging" not in config:
|
||||
config["logging"] = {}
|
||||
config["logging"]["level"] = os.getenv("PYMC_REPEATER_LOG_LEVEL")
|
||||
config["logging"]["level"] = env_log_level
|
||||
|
||||
config = _load_policy_engine_config(config, config_path)
|
||||
|
||||
@@ -282,7 +334,10 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None)
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = os.getenv("PYMC_REPEATER_CONFIG", "/etc/pymc_repeater/config.yaml")
|
||||
config_path = os.getenv(
|
||||
"OPENHOP_REPEATER_CONFIG",
|
||||
os.getenv("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml"),
|
||||
)
|
||||
|
||||
try:
|
||||
# Create backup of existing config
|
||||
@@ -346,16 +401,16 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
|
||||
|
||||
if path is None:
|
||||
# Check system-wide location first (matches config.yaml location)
|
||||
system_key_path = Path("/etc/pymc_repeater/identity.key")
|
||||
system_key_path = Path("/etc/openhop_repeater/identity.key")
|
||||
if system_key_path.exists():
|
||||
key_path = system_key_path
|
||||
else:
|
||||
# Follow XDG spec
|
||||
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config_home:
|
||||
config_dir = Path(xdg_config_home) / "pymc_repeater"
|
||||
config_dir = Path(xdg_config_home) / "openhop_repeater"
|
||||
else:
|
||||
config_dir = Path.home() / ".config" / "pymc_repeater"
|
||||
config_dir = Path.home() / ".config" / "openhop_repeater"
|
||||
key_path = config_dir / "identity.key"
|
||||
else:
|
||||
key_path = Path(path)
|
||||
@@ -374,8 +429,11 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load identity key: {e}")
|
||||
|
||||
# Generate new random key
|
||||
key = os.urandom(32)
|
||||
# Generate new key via MeshCore-compatible keygen; store 32-byte private scalar.
|
||||
from repeater.keygen import generate_meshcore_keypair
|
||||
|
||||
_, private_key = generate_meshcore_keypair()
|
||||
key = private_key[:32]
|
||||
|
||||
# Save it
|
||||
try:
|
||||
@@ -434,7 +492,7 @@ def get_radio_for_board(board_config: dict):
|
||||
radio_type = "kiss"
|
||||
|
||||
if radio_type in ("sx1262", "sx1262_ch341"):
|
||||
from pymc_core.hardware.sx1262_wrapper import SX1262Radio
|
||||
from openhop_core.hardware.sx1262_wrapper import SX1262Radio
|
||||
|
||||
# Get radio and SPI configuration - all settings must be in config file
|
||||
spi_config = board_config.get("sx1262")
|
||||
@@ -451,8 +509,8 @@ def get_radio_for_board(board_config: dict):
|
||||
if not ch341_cfg:
|
||||
raise ValueError("Missing 'ch341' section in configuration file")
|
||||
|
||||
from pymc_core.hardware.lora.LoRaRF.SX126x import set_spi_transport
|
||||
from pymc_core.hardware.transports.ch341_spi_transport import CH341SPITransport
|
||||
from openhop_core.hardware.lora.LoRaRF.SX126x import set_spi_transport
|
||||
from openhop_core.hardware.transports.ch341_spi_transport import CH341SPITransport
|
||||
|
||||
vid = _parse_int(ch341_cfg.get("vid"), default=0x1A86)
|
||||
pid = _parse_int(ch341_cfg.get("pid"), default=0x5512)
|
||||
@@ -493,7 +551,7 @@ def get_radio_for_board(board_config: dict):
|
||||
combined_config["en_pins"] = en_pins
|
||||
|
||||
# Add optional GPIO parameters if specified in config
|
||||
# These wont be supported by older versions of pymc_core
|
||||
# These wont be supported by older versions of openhop_core
|
||||
if "gpio_chip" in spi_config:
|
||||
combined_config["gpio_chip"] = _parse_int(spi_config["gpio_chip"], default=0)
|
||||
if "use_gpiod_backend" in spi_config:
|
||||
@@ -513,16 +571,16 @@ def get_radio_for_board(board_config: dict):
|
||||
|
||||
elif radio_type == "kiss":
|
||||
try:
|
||||
from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper
|
||||
from openhop_core.hardware.kiss_modem_wrapper import KissModemWrapper
|
||||
except ImportError:
|
||||
try:
|
||||
from pymc_core.hardware.kiss_serial_wrapper import (
|
||||
from openhop_core.hardware.kiss_serial_wrapper import (
|
||||
KissSerialWrapper as KissModemWrapper,
|
||||
)
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"KISS modem support requires pyMC_core with KISS support. "
|
||||
"Install your fork with: pip install -e /path/to/pyMC_core"
|
||||
"KISS modem support requires openhop-core with KISS support. "
|
||||
"Install your fork with: pip install -e /path/to/openhop-core"
|
||||
) from None
|
||||
|
||||
kiss_config = board_config.get("kiss")
|
||||
@@ -572,11 +630,11 @@ def get_radio_for_board(board_config: dict):
|
||||
|
||||
elif radio_type == "pymc_tcp":
|
||||
try:
|
||||
from pymc_core.hardware.tcp_radio import TCPLoRaRadio
|
||||
from openhop_core.hardware.tcp_radio import TCPLoRaRadio
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pymc_tcp radio requires pyMC_core >= the release that includes "
|
||||
"PR pyMC-dev/pyMC_core#68 (merged 2026-05-13). "
|
||||
"pymc_tcp radio requires openhop-core >= the release that includes "
|
||||
"the openhop-core release with TCP modem support. "
|
||||
"Reinstall the [hardware] extra to pick it up."
|
||||
) from None
|
||||
|
||||
@@ -612,15 +670,15 @@ def get_radio_for_board(board_config: dict):
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize pymc_tcp radio: {e}") from e
|
||||
|
||||
return radio
|
||||
return BaselineCrcCounterRadio(radio)
|
||||
|
||||
elif radio_type == "pymc_usb":
|
||||
try:
|
||||
from pymc_core.hardware.usb_radio import USBLoRaRadio
|
||||
from openhop_core.hardware.usb_radio import USBLoRaRadio
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"pymc_usb radio requires pyMC_core >= the release that includes "
|
||||
"PR pyMC-dev/pyMC_core#68 (merged 2026-05-13). "
|
||||
"pymc_usb radio requires openhop-core >= the release that includes "
|
||||
"the openhop-core release with TCP modem support. "
|
||||
"Reinstall the [hardware] extra to pick it up."
|
||||
) from None
|
||||
|
||||
@@ -654,7 +712,7 @@ def get_radio_for_board(board_config: dict):
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize pymc_usb radio: {e}") from e
|
||||
|
||||
return radio
|
||||
return BaselineCrcCounterRadio(radio)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Unknown radio type: {radio_type}. "
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
logger = logging.getLogger("ConfigManager")
|
||||
|
||||
@@ -145,6 +146,52 @@ class ConfigManager:
|
||||
logger.error(f"Failed to apply live radio config: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_bool(value: Any, default: bool = True) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
def _apply_live_http_config(self) -> bool:
|
||||
if not self.daemon:
|
||||
logger.warning("Daemon not available for HTTP live update")
|
||||
return False
|
||||
|
||||
http_server = getattr(self.daemon, "http_server", None)
|
||||
if http_server is None:
|
||||
# Early in daemon lifecycle, there is nothing to control yet.
|
||||
logger.info("HTTP server not initialized yet; skipping live HTTP update")
|
||||
return True
|
||||
|
||||
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
|
||||
enabled = self._parse_bool(http_cfg.get("enabled", True), default=True)
|
||||
host = str(http_cfg.get("host", "0.0.0.0") or "0.0.0.0")
|
||||
|
||||
try:
|
||||
port = int(http_cfg.get("port", 8000))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid http.port=%r, falling back to 8000", http_cfg.get("port"))
|
||||
port = 8000
|
||||
|
||||
# Keep runtime server settings aligned with config before start/restart.
|
||||
http_server.host = host
|
||||
http_server.port = port
|
||||
|
||||
from repeater.service_utils import start_http_server, stop_http_server
|
||||
|
||||
if enabled:
|
||||
success, message = start_http_server(self.daemon)
|
||||
else:
|
||||
success, message = stop_http_server(self.daemon)
|
||||
|
||||
if success:
|
||||
logger.info("Applied live HTTP config: %s", message)
|
||||
else:
|
||||
logger.warning("Failed live HTTP config apply: %s", message)
|
||||
return success
|
||||
|
||||
def save_to_file(self) -> bool:
|
||||
"""
|
||||
Save current config to YAML file.
|
||||
@@ -193,7 +240,7 @@ class ConfigManager:
|
||||
|
||||
# Default sections to update if not specified
|
||||
if sections is None:
|
||||
sections = ["repeater", "delays", "radio", "acl", "identities", "glass"]
|
||||
sections = ["repeater", "delays", "radio", "acl", "identities", "glass", "http"]
|
||||
|
||||
# Update each section
|
||||
for section in sections:
|
||||
@@ -247,6 +294,9 @@ class ConfigManager:
|
||||
if "radio" in sections:
|
||||
live_update_ok = self._apply_live_radio_config() and live_update_ok
|
||||
|
||||
if "http" in sections:
|
||||
live_update_ok = self._apply_live_http_config() and live_update_ok
|
||||
|
||||
return live_update_ok
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -12,6 +13,7 @@ from urllib import error, request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import psutil
|
||||
import yaml
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
@@ -19,6 +21,7 @@ except ImportError:
|
||||
mqtt = None
|
||||
|
||||
from repeater import __version__
|
||||
from repeater.policy_engine import PolicyEngine, SUPPORTED_ACTIONS, default_policy_engine_config
|
||||
from repeater.service_utils import restart_service
|
||||
|
||||
logger = logging.getLogger("GlassHandler")
|
||||
@@ -47,7 +50,7 @@ class GlassHandler:
|
||||
self.verify_tls = True
|
||||
self.api_token = "" # nosec - runtime config value, not a hardcoded credential
|
||||
self.inform_interval_seconds = 30
|
||||
self.cert_store_dir = "/etc/pymc_repeater/glass"
|
||||
self.cert_store_dir = "/etc/openhop_repeater/glass"
|
||||
self._cert_expires_at: Optional[str] = None
|
||||
self.mqtt_enabled = False
|
||||
self.mqtt_broker_host = "localhost"
|
||||
@@ -132,8 +135,8 @@ class GlassHandler:
|
||||
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"
|
||||
glass_cfg.get("cert_store_dir", "/etc/openhop_repeater/glass")
|
||||
or "/etc/openhop_repeater/glass"
|
||||
)
|
||||
self.client_cert_path = (
|
||||
str(glass_cfg.get("client_cert_path")).strip()
|
||||
@@ -289,7 +292,7 @@ class GlassHandler:
|
||||
settings_snapshot = self._build_settings_snapshot()
|
||||
location = self._extract_location_from_settings(settings_snapshot)
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"type": "inform",
|
||||
"version": 1,
|
||||
"node_name": node_name,
|
||||
@@ -321,6 +324,28 @@ class GlassHandler:
|
||||
"settings": settings_snapshot,
|
||||
"command_results": command_results,
|
||||
}
|
||||
sensors_summary = self._collect_sensor_summary()
|
||||
if sensors_summary is not None:
|
||||
payload["sensors"] = sensors_summary
|
||||
return payload
|
||||
|
||||
def _collect_sensor_summary(self) -> Optional[Dict[str, Any]]:
|
||||
sensor_manager = getattr(self.daemon_instance, "sensor_manager", None)
|
||||
if sensor_manager is None:
|
||||
return None
|
||||
try:
|
||||
summary = sensor_manager.get_summary()
|
||||
return summary if isinstance(summary, dict) else None
|
||||
except Exception as exc:
|
||||
logger.debug("Failed collecting sensor summary for Glass inform: %s", exc)
|
||||
return {
|
||||
"enabled": False,
|
||||
"configured": 0,
|
||||
"loaded": 0,
|
||||
"running": False,
|
||||
"readings": [],
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
def _build_settings_snapshot(self) -> Dict[str, Any]:
|
||||
normalized = self._normalize_for_hash(self.config)
|
||||
@@ -594,6 +619,10 @@ class GlassHandler:
|
||||
success, message, details = self._apply_transport_keys_sync(params)
|
||||
return success, message, details
|
||||
|
||||
if action == "policy_sync":
|
||||
success, message, details = self._apply_policy_sync(params)
|
||||
return success, message, details
|
||||
|
||||
if action == "set_radio":
|
||||
radio_values = params.get("radio", params)
|
||||
if not isinstance(radio_values, dict):
|
||||
@@ -717,6 +746,307 @@ class GlassHandler:
|
||||
details["payload_hash"] = payload_hash
|
||||
return True, f"Applied transport key sync ({details['applied_nodes']} nodes)", details
|
||||
|
||||
def _apply_policy_sync(
|
||||
self,
|
||||
params: Dict[str, Any],
|
||||
) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
|
||||
if not isinstance(params, dict):
|
||||
return False, "policy_sync params must be an object", None
|
||||
incoming_policy = params.get("policy")
|
||||
if not isinstance(incoming_policy, dict):
|
||||
return False, "policy_sync payload must include a policy object", None
|
||||
|
||||
mode = str(params.get("mode", "replace") or "replace").lower().strip()
|
||||
if mode not in ("replace", "patch"):
|
||||
return False, f"Unsupported policy_sync mode: {mode}", None
|
||||
validate_only = bool(params.get("validate_only", False))
|
||||
|
||||
existing_doc, _ = self._load_policy_document()
|
||||
existing_policy = self._normalize_policy_engine(existing_doc.get("policy_engine", {}))
|
||||
if mode == "patch":
|
||||
policy_engine_cfg = dict(existing_policy)
|
||||
self._deep_merge(policy_engine_cfg, incoming_policy)
|
||||
else:
|
||||
policy_engine_cfg = incoming_policy
|
||||
|
||||
groups_cfg = params.get("groups", existing_doc.get("groups", {}))
|
||||
doc_to_apply = {
|
||||
"policy_engine": self._normalize_policy_engine(policy_engine_cfg),
|
||||
"groups": self._normalize_policy_groups(groups_cfg),
|
||||
}
|
||||
doc_to_apply = self._sync_policy_engine_objects_from_groups(doc_to_apply)
|
||||
|
||||
try:
|
||||
self._validate_policy_engine(doc_to_apply.get("policy_engine", {}))
|
||||
PolicyEngine(doc_to_apply.get("policy_engine", {}))
|
||||
except Exception as exc:
|
||||
return False, f"Invalid policy: {exc}", None
|
||||
|
||||
details = self._policy_sync_details(doc_to_apply, mode=mode, validate_only=validate_only)
|
||||
if validate_only:
|
||||
return True, "Policy validated", details
|
||||
|
||||
try:
|
||||
self._write_policy_document(doc_to_apply)
|
||||
self._apply_policy_runtime(doc_to_apply.get("policy_engine", {}))
|
||||
except Exception as exc:
|
||||
return False, f"Policy sync failed: {exc}", None
|
||||
return True, "Policy synchronized", details
|
||||
|
||||
def _policy_sync_details(
|
||||
self,
|
||||
doc: Dict[str, Any],
|
||||
*,
|
||||
mode: str,
|
||||
validate_only: bool,
|
||||
) -> Dict[str, Any]:
|
||||
policy_engine_cfg = doc.get("policy_engine", {}) if isinstance(doc, dict) else {}
|
||||
rules = policy_engine_cfg.get("rules", []) if isinstance(policy_engine_cfg, dict) else []
|
||||
return {
|
||||
"policy_file": self._get_policy_file_path(),
|
||||
"mode": mode,
|
||||
"validate_only": validate_only,
|
||||
"rule_count": len(rules) if isinstance(rules, list) else 0,
|
||||
"enabled": bool(policy_engine_cfg.get("enabled", False))
|
||||
if isinstance(policy_engine_cfg, dict)
|
||||
else False,
|
||||
"default_action": str(policy_engine_cfg.get("default_action", "allow"))
|
||||
if isinstance(policy_engine_cfg, dict)
|
||||
else "allow",
|
||||
}
|
||||
|
||||
def _get_policy_file_path(self) -> str:
|
||||
policy_cfg = self.config.get("policy", {}) if isinstance(self.config, dict) else {}
|
||||
policy_file = policy_cfg.get("policy_file", "policy.yaml")
|
||||
if os.path.isabs(str(policy_file)):
|
||||
return str(policy_file)
|
||||
config_path = getattr(self.config_manager, "config_path", None) or self.config.get(
|
||||
"config_path", "/etc/pymc_repeater/config.yaml"
|
||||
)
|
||||
config_dir = os.path.dirname(os.path.abspath(str(config_path)))
|
||||
return os.path.abspath(os.path.join(config_dir, str(policy_file)))
|
||||
|
||||
@staticmethod
|
||||
def _default_policy_document() -> Dict[str, Any]:
|
||||
return {
|
||||
"policy_engine": default_policy_engine_config(),
|
||||
"groups": {"channel_hashes": [], "pubkeys": []},
|
||||
}
|
||||
|
||||
def _load_policy_document(self) -> Tuple[Dict[str, Any], bool]:
|
||||
path = self._get_policy_file_path()
|
||||
if not os.path.exists(path):
|
||||
return self._default_policy_document(), False
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
return self._default_policy_document(), False
|
||||
if "policy_engine" not in data:
|
||||
return {
|
||||
"policy_engine": data,
|
||||
"groups": self._default_policy_document()["groups"],
|
||||
}, True
|
||||
if not isinstance(data.get("policy_engine"), dict):
|
||||
return self._default_policy_document(), False
|
||||
if not isinstance(data.get("groups"), dict):
|
||||
data["groups"] = self._default_policy_document()["groups"]
|
||||
return data, True
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load policy file %s: %s", path, exc)
|
||||
return self._default_policy_document(), False
|
||||
|
||||
def _write_policy_document(self, doc: Dict[str, Any]) -> None:
|
||||
policy_path = self._get_policy_file_path()
|
||||
os.makedirs(os.path.dirname(policy_path), exist_ok=True)
|
||||
with open(policy_path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(
|
||||
doc,
|
||||
f,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=1000000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_policy_engine(engine_cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(engine_cfg, dict):
|
||||
engine_cfg = {}
|
||||
return {
|
||||
"enabled": bool(engine_cfg.get("enabled", False)),
|
||||
"default_action": str(engine_cfg.get("default_action", "allow")),
|
||||
"rules": engine_cfg.get("rules") if isinstance(engine_cfg.get("rules"), list) else [],
|
||||
"objects": (
|
||||
engine_cfg.get("objects") if isinstance(engine_cfg.get("objects"), dict) else {}
|
||||
),
|
||||
}
|
||||
|
||||
def _apply_policy_runtime(self, policy_engine_cfg: Dict[str, Any]) -> None:
|
||||
self.config["policy_engine"] = policy_engine_cfg
|
||||
self.config["policy_file_path"] = self._get_policy_file_path()
|
||||
repeater_handler = getattr(self.daemon_instance, "repeater_handler", None)
|
||||
if repeater_handler is not None:
|
||||
repeater_handler.policy_engine = PolicyEngine.from_runtime_config(self.config)
|
||||
|
||||
def _sync_policy_engine_objects_from_groups(self, doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
policy_engine_cfg = self._normalize_policy_engine(doc.get("policy_engine", {}))
|
||||
groups_cfg = self._normalize_policy_groups(doc.get("groups", {}))
|
||||
objects = policy_engine_cfg.get("objects", {})
|
||||
if not isinstance(objects, dict):
|
||||
objects = {}
|
||||
objects.update(self._policy_objects_from_groups(groups_cfg))
|
||||
policy_engine_cfg["objects"] = objects
|
||||
doc["policy_engine"] = policy_engine_cfg
|
||||
doc["groups"] = groups_cfg
|
||||
return doc
|
||||
|
||||
def _normalize_policy_groups(self, groups_cfg: Dict[str, Any]) -> Dict[str, Any]:
|
||||
normalized = {"channel_hashes": [], "pubkeys": []}
|
||||
if not isinstance(groups_cfg, dict):
|
||||
return normalized
|
||||
for kind in ("channel_hashes", "pubkeys"):
|
||||
source_groups = groups_cfg.get(kind)
|
||||
if not isinstance(source_groups, list):
|
||||
continue
|
||||
seen_group_ids = set()
|
||||
for idx, group in enumerate(source_groups):
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
group_id = self._slugify_policy_id(
|
||||
group.get("id") or group.get("name") or group.get("friendly_name"),
|
||||
f"{kind}_{idx + 1}",
|
||||
)
|
||||
if group_id in seen_group_ids:
|
||||
continue
|
||||
seen_group_ids.add(group_id)
|
||||
entries = []
|
||||
seen_entry_ids = set()
|
||||
for ent_idx, entry in enumerate(group.get("entries") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
entry_value = self._normalize_policy_entry_value(kind, entry.get("value"))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Skipping invalid policy entry at index %d: %s", ent_idx, exc
|
||||
)
|
||||
continue
|
||||
entry_id = self._slugify_policy_id(
|
||||
entry.get("id")
|
||||
or entry.get("name")
|
||||
or entry.get("friendly_name")
|
||||
or entry_value,
|
||||
f"entry_{ent_idx + 1}",
|
||||
)
|
||||
if entry_id in seen_entry_ids:
|
||||
continue
|
||||
seen_entry_ids.add(entry_id)
|
||||
entries.append(
|
||||
{
|
||||
"id": entry_id,
|
||||
"friendly_name": str(
|
||||
entry.get("friendly_name") or entry.get("name") or entry_id
|
||||
),
|
||||
"value": entry_value,
|
||||
}
|
||||
)
|
||||
normalized[kind].append(
|
||||
{
|
||||
"id": group_id,
|
||||
"friendly_name": str(
|
||||
group.get("friendly_name") or group.get("name") or group_id
|
||||
),
|
||||
"description": str(group.get("description") or ""),
|
||||
"entries": entries,
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _slugify_policy_id(value: str, fallback: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
||||
return text or fallback
|
||||
|
||||
def _normalize_policy_entry_value(self, kind: str, value: Any) -> str:
|
||||
if kind == "pubkeys":
|
||||
return self._normalize_pubkey_value(value)
|
||||
if kind == "channel_hashes":
|
||||
return self._normalize_channel_hash_value(value)
|
||||
raise ValueError(f"Unsupported group kind: {kind}")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_pubkey_value(value: Any) -> str:
|
||||
if value is None:
|
||||
raise ValueError("pubkey value is required")
|
||||
raw = value.hex() if isinstance(value, bytes) else str(value).strip().lower()
|
||||
if raw.startswith("0x"):
|
||||
raw = raw[2:]
|
||||
raw = raw.replace(" ", "")
|
||||
if not raw:
|
||||
raise ValueError("pubkey value is required")
|
||||
if not re.fullmatch(r"[0-9a-f]+", raw):
|
||||
raise ValueError("pubkey must be hex")
|
||||
if len(raw) % 2 != 0:
|
||||
raise ValueError("pubkey hex length must be even")
|
||||
return f"0x{raw}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_channel_hash_value(value: Any) -> str:
|
||||
if value is None:
|
||||
raise ValueError("channel hash value is required")
|
||||
if isinstance(value, int):
|
||||
parsed = value
|
||||
else:
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
raise ValueError("channel hash value is required")
|
||||
normalized_hex = raw[2:] if raw.lower().startswith("0x") else raw
|
||||
if len(normalized_hex) in (32, 64) and re.fullmatch(r"[0-9a-fA-F]+", normalized_hex):
|
||||
return f"0x{normalized_hex.upper()}"
|
||||
if raw.lower().startswith("0x"):
|
||||
parsed = int(raw, 16)
|
||||
elif re.fullmatch(r"[0-9]+", raw):
|
||||
parsed = int(raw, 10)
|
||||
else:
|
||||
parsed = int(raw, 16)
|
||||
if parsed < 0:
|
||||
raise ValueError("channel hash must be non-negative")
|
||||
if parsed > 0xFF:
|
||||
raise ValueError("channel hash must be one byte (0x00-0xFF)")
|
||||
return f"0x{parsed:02X}"
|
||||
|
||||
def _policy_objects_from_groups(self, groups_cfg: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
channel_hash_groups = {}
|
||||
pubkey_groups = {}
|
||||
for group in groups_cfg.get("channel_hashes", []):
|
||||
channel_hash_groups[group["id"]] = [
|
||||
entry["value"] for entry in group.get("entries", [])
|
||||
]
|
||||
for group in groups_cfg.get("pubkeys", []):
|
||||
pubkey_groups[group["id"]] = [entry["value"] for entry in group.get("entries", [])]
|
||||
return {"channel_hash_groups": channel_hash_groups, "pubkey_groups": pubkey_groups}
|
||||
|
||||
@staticmethod
|
||||
def _validate_policy_engine(policy_engine_cfg: Dict[str, Any]) -> None:
|
||||
default_action = str(policy_engine_cfg.get("default_action", "allow"))
|
||||
if default_action not in SUPPORTED_ACTIONS:
|
||||
raise ValueError(f"Unsupported default_action: {default_action}")
|
||||
rules = policy_engine_cfg.get("rules", [])
|
||||
if not isinstance(rules, list):
|
||||
raise ValueError("rules must be a list")
|
||||
for idx, rule in enumerate(rules):
|
||||
if not isinstance(rule, dict):
|
||||
raise ValueError(f"rule {idx} must be an object")
|
||||
then_block = rule.get("then", {})
|
||||
action = then_block.get("action") if isinstance(then_block, dict) else then_block
|
||||
if action is None:
|
||||
action = rule.get("action", "allow")
|
||||
action = str(action or "allow")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
raise ValueError(f"Unsupported rule action at index {idx}: {action}")
|
||||
|
||||
def _apply_cert_renewal(self, response: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
client_cert = response.get("client_cert")
|
||||
client_key = response.get("client_key")
|
||||
|
||||
@@ -14,11 +14,15 @@ import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from base64 import b64encode
|
||||
from collections import Counter, deque
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger("GPSService")
|
||||
|
||||
@@ -60,6 +64,20 @@ def _to_int(value: Any) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _to_bool(value: Any) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _is_valid_latitude(value: Optional[float]) -> bool:
|
||||
return value is not None and -90.0 <= value <= 90.0
|
||||
|
||||
@@ -264,6 +282,116 @@ class NMEAParser:
|
||||
for line in lines:
|
||||
self.ingest_sentence(line)
|
||||
|
||||
def ingest_modem_payload(self, payload: Dict[str, Any]) -> bool:
|
||||
"""Ingest a generic pyMC modem HTTP GPS payload.
|
||||
|
||||
This is for modems that expose parsed GPS JSON instead of raw NMEA.
|
||||
The payload may be the whole /api/stats response or the nested gps dict.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
gps = payload.get("gps") if isinstance(payload.get("gps"), dict) else payload
|
||||
position = self._first_dict(
|
||||
gps.get("position"),
|
||||
gps.get("gps_position"),
|
||||
gps.get("location"),
|
||||
payload.get("position"),
|
||||
)
|
||||
fix = self._first_dict(gps.get("fix"), payload.get("fix"))
|
||||
satellites = self._first_dict(gps.get("satellites"), payload.get("satellites"))
|
||||
time_data = self._first_dict(
|
||||
gps.get("time"), gps.get("time_data"), payload.get("time_data")
|
||||
)
|
||||
motion = self._first_dict(gps.get("motion"), payload.get("motion"))
|
||||
|
||||
latitude = _to_float(position.get("latitude"))
|
||||
longitude = _to_float(position.get("longitude"))
|
||||
fix_valid = _to_bool(fix.get("valid", gps.get("fix_valid")))
|
||||
if fix_valid is None:
|
||||
fix_valid = _is_valid_latitude(latitude) and _is_valid_longitude(longitude)
|
||||
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
self.last_update = now
|
||||
self.last_error = None
|
||||
self.nmea["last_sentence"] = None
|
||||
self.nmea["last_sentence_type"] = "MODEM_HTTP"
|
||||
self.nmea["last_talker"] = "MODEM"
|
||||
self.nmea["seen_sentence_types"] = ["MODEM_HTTP"]
|
||||
self._sentence_counters["MODEM_HTTP"] += 1
|
||||
self.nmea["sentence_counters"] = dict(self._sentence_counters)
|
||||
|
||||
if latitude is not None:
|
||||
self.position["latitude"] = latitude
|
||||
if longitude is not None:
|
||||
self.position["longitude"] = longitude
|
||||
altitude = _to_float(position.get("altitude_m"))
|
||||
if altitude is not None:
|
||||
self.position["altitude_m"] = altitude
|
||||
|
||||
self.fix["valid"] = bool(fix_valid)
|
||||
quality = _to_int(fix.get("quality", gps.get("fix_quality")))
|
||||
if quality is not None:
|
||||
self.fix["quality"] = quality
|
||||
self.fix["quality_label"] = FIX_QUALITY_LABELS.get(quality, f"quality {quality}")
|
||||
elif fix_valid:
|
||||
self.fix["quality_label"] = "modem valid"
|
||||
|
||||
used = _to_int(satellites.get("used_count", satellites.get("satellites_used")))
|
||||
if used is not None:
|
||||
self.satellites["used_count"] = used
|
||||
in_view = _to_int(satellites.get("in_view_count", satellites.get("satellites_in_view")))
|
||||
if in_view is not None:
|
||||
self.satellites["in_view_count"] = in_view
|
||||
satellite_details = satellites.get("in_view")
|
||||
if isinstance(satellite_details, list):
|
||||
normalized = []
|
||||
for satellite in satellite_details:
|
||||
if not isinstance(satellite, dict):
|
||||
continue
|
||||
prn = satellite.get("prn", satellite.get("id"))
|
||||
if prn in (None, ""):
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"prn": str(prn),
|
||||
"elevation_degrees": _to_int(
|
||||
satellite.get("elevation_degrees", satellite.get("elevation"))
|
||||
),
|
||||
"azimuth_degrees": _to_int(
|
||||
satellite.get("azimuth_degrees", satellite.get("azimuth"))
|
||||
),
|
||||
"snr_db": _to_float(satellite.get("snr_db", satellite.get("snr"))),
|
||||
}
|
||||
)
|
||||
self.satellites["in_view"] = normalized
|
||||
|
||||
for key in ("datetime_utc", "utc_time", "date"):
|
||||
value = time_data.get(key) or payload.get(key)
|
||||
if value:
|
||||
self.time_data[key] = str(value)
|
||||
|
||||
speed_kmh = _to_float(motion.get("speed_kmh", payload.get("speed_kmh")))
|
||||
if speed_kmh is not None:
|
||||
self.motion["speed_kmh"] = speed_kmh
|
||||
self.motion["speed_knots"] = round(speed_kmh / 1.852, 3)
|
||||
course = _to_float(motion.get("course_degrees", payload.get("course_degrees")))
|
||||
if course is not None:
|
||||
self.motion["course_degrees"] = course
|
||||
|
||||
self.raw_attributes["MODEM_HTTP"] = {
|
||||
"gps_enabled": gps.get("enabled"),
|
||||
"gps_seen": gps.get("seen"),
|
||||
"battery_voltage_mv": payload.get("battery_voltage_mv"),
|
||||
"battery_voltage_v": payload.get("battery_voltage_v"),
|
||||
"solar_charge_rate_percent_per_hour": payload.get(
|
||||
"solar_charge_rate_percent_per_hour"
|
||||
),
|
||||
}
|
||||
self._refresh_derived_unlocked()
|
||||
return True
|
||||
|
||||
def ingest_sentence(self, sentence: str) -> bool:
|
||||
parsed = self._split_sentence(sentence)
|
||||
with self._lock:
|
||||
@@ -513,6 +641,13 @@ class NMEAParser:
|
||||
def _field(fields: List[str], index: int) -> str:
|
||||
return fields[index] if index < len(fields) else ""
|
||||
|
||||
@staticmethod
|
||||
def _first_dict(*values: Any) -> Dict[str, Any]:
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
def _normalize_raw_attributes_unlocked(self):
|
||||
if self._unhandled_sentence_types:
|
||||
self.raw_attributes["unhandled_sentence_types"] = sorted(self._unhandled_sentence_types)
|
||||
@@ -617,6 +752,14 @@ class GPSService:
|
||||
self.reconnect_interval_seconds = float(gps_config.get("reconnect_interval_seconds", 5.0))
|
||||
self.poll_interval_seconds = float(gps_config.get("poll_interval_seconds", 2.0))
|
||||
self.source_path = gps_config.get("source_path") or gps_config.get("snapshot_path")
|
||||
self.modem_http_host = str(gps_config.get("host", "") or "").strip()
|
||||
self.modem_http_port = int(gps_config.get("port", 80))
|
||||
self.modem_http_endpoint = str(gps_config.get("endpoint", "/api/stats") or "/api/stats")
|
||||
if not self.modem_http_endpoint.startswith("/"):
|
||||
self.modem_http_endpoint = "/" + self.modem_http_endpoint
|
||||
self.modem_http_scheme = str(gps_config.get("scheme", "http") or "http").lower()
|
||||
self.modem_http_username = str(gps_config.get("username", "admin") or "admin")
|
||||
self.modem_http_password = gps_config.get("password")
|
||||
self.repeater_config = repeater_config
|
||||
self.time_sync_enabled = bool(gps_config.get("time_sync_enabled", True))
|
||||
self.time_sync_interval_seconds = max(
|
||||
@@ -687,7 +830,12 @@ class GPSService:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
|
||||
target = self._run_file_loop if self.source == "file" else self._run_serial_loop
|
||||
if self.source == "file":
|
||||
target = self._run_file_loop
|
||||
elif self.source in ("modem_http", "pymc_modem", "http"):
|
||||
target = self._run_modem_http_loop
|
||||
else:
|
||||
target = self._run_serial_loop
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=target, name="gps-service", daemon=True)
|
||||
self._thread.start()
|
||||
@@ -800,6 +948,15 @@ class GPSService:
|
||||
"device": self.device if self.source == "serial" else None,
|
||||
"baud_rate": self.baud_rate if self.source == "serial" else None,
|
||||
"source_path": self.source_path if self.source == "file" else None,
|
||||
"host": self.modem_http_host
|
||||
if self.source in ("modem_http", "pymc_modem", "http")
|
||||
else None,
|
||||
"port": self.modem_http_port
|
||||
if self.source in ("modem_http", "pymc_modem", "http")
|
||||
else None,
|
||||
"endpoint": self.modem_http_endpoint
|
||||
if self.source in ("modem_http", "pymc_modem", "http")
|
||||
else None,
|
||||
"read_timeout_seconds": self.read_timeout_seconds,
|
||||
"poll_interval_seconds": self.poll_interval_seconds,
|
||||
"stale_after_seconds": self.parser.stale_after_seconds,
|
||||
@@ -1165,6 +1322,55 @@ class GPSService:
|
||||
|
||||
self._running = False
|
||||
|
||||
def _run_modem_http_loop(self):
|
||||
if not self.modem_http_host:
|
||||
self._set_source_error("gps.host is required for modem_http source")
|
||||
self._running = False
|
||||
return
|
||||
|
||||
url = f"{self.modem_http_scheme}://{self.modem_http_host}:{self.modem_http_port}{self.modem_http_endpoint}"
|
||||
if not self._is_safe_modem_http_url(url):
|
||||
self._set_source_error(
|
||||
"gps modem_http URL scheme must be http or https and include a host"
|
||||
)
|
||||
self._running = False
|
||||
return
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
if self.modem_http_password not in (None, ""):
|
||||
raw_auth = f"{self.modem_http_username}:{self.modem_http_password}".encode("utf-8")
|
||||
request.add_header("Authorization", "Basic " + b64encode(raw_auth).decode("ascii"))
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.read_timeout_seconds) as response: # nosec B310
|
||||
status = int(getattr(response, "status", 200) or 200)
|
||||
body = response.read()
|
||||
if status < 200 or status >= 300:
|
||||
raise RuntimeError(f"modem HTTP {status} reading {url}")
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("modem GPS response was not a JSON object")
|
||||
if not self.parser.ingest_modem_payload(payload):
|
||||
raise RuntimeError("modem GPS response did not contain a usable GPS payload")
|
||||
self._set_source_error(None)
|
||||
self._maybe_sync_system_time()
|
||||
self._maybe_update_repeater_location()
|
||||
except urllib.error.HTTPError as exc:
|
||||
self._set_source_error(f"modem HTTP {exc.code} reading {url}")
|
||||
except urllib.error.URLError as exc:
|
||||
self._set_source_error(f"modem GPS request failed: {exc.reason}")
|
||||
except Exception as exc:
|
||||
self._set_source_error(f"{type(exc).__name__}: {exc}")
|
||||
self._stop_event.wait(self.poll_interval_seconds)
|
||||
|
||||
self._running = False
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_modem_http_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
@staticmethod
|
||||
def _extract_file_sentences(content: str) -> List[str]:
|
||||
stripped = content.strip()
|
||||
|
||||
@@ -12,6 +12,7 @@ except ImportError:
|
||||
psutil = None
|
||||
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("HardwareStats")
|
||||
@@ -57,6 +58,7 @@ class HardwareStatsCollector:
|
||||
# System boot time
|
||||
boot_time = psutil.boot_time()
|
||||
system_uptime = now - boot_time
|
||||
system_info = self._get_system_info()
|
||||
|
||||
# Temperature (if available)
|
||||
temperatures = {}
|
||||
@@ -96,7 +98,13 @@ class HardwareStatsCollector:
|
||||
"packets_sent": net_io.packets_sent,
|
||||
"packets_recv": net_io.packets_recv,
|
||||
},
|
||||
"system": {"uptime": system_uptime, "boot_time": boot_time},
|
||||
"system": {
|
||||
"uptime": system_uptime,
|
||||
"boot_time": boot_time,
|
||||
"os": system_info["os"],
|
||||
"kernel": system_info["kernel"],
|
||||
"arch": system_info["arch"],
|
||||
},
|
||||
}
|
||||
|
||||
# Add temperatures if available
|
||||
@@ -109,6 +117,25 @@ class HardwareStatsCollector:
|
||||
logger.error(f"Error collecting hardware stats: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
@staticmethod
|
||||
def _get_system_info(os_release_path="/etc/os-release"):
|
||||
os_name = None
|
||||
try:
|
||||
with open(os_release_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
key, sep, value = line.partition("=")
|
||||
if sep and key == "PRETTY_NAME":
|
||||
os_name = value.strip().strip('"')
|
||||
break
|
||||
except OSError:
|
||||
os_name = None
|
||||
|
||||
return {
|
||||
"os": os_name or platform.system(),
|
||||
"kernel": platform.release(),
|
||||
"arch": platform.machine(),
|
||||
}
|
||||
|
||||
def get_processes_summary(self, limit=10):
|
||||
"""
|
||||
Get top processes by CPU and memory usage.
|
||||
|
||||
@@ -255,7 +255,7 @@ class _BrokerConnection:
|
||||
)
|
||||
self.base_topic = f"meshcore/{self.iata_code}/{self.public_key}"
|
||||
|
||||
from pymc_core.protocol.utils import PAYLOAD_TYPES
|
||||
from openhop_core.protocol.utils import PAYLOAD_TYPES
|
||||
|
||||
disallowed_types = broker.get("disallowed_packet_types", [])
|
||||
type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()}
|
||||
@@ -973,7 +973,7 @@ class MeshCoreToMqttPusher:
|
||||
"model": "PyMC-Repeater",
|
||||
"firmware_version": self.app_version,
|
||||
"radio": radio_config or self.radio_config,
|
||||
"client_version": f"pyMC_repeater/{self.app_version}",
|
||||
"client_version": f"openhop_repeater/{self.app_version}",
|
||||
"stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,16 @@ class SQLiteHandler:
|
||||
self._api_token_last_used_interval_sec = 300
|
||||
self._hot_cache_ttl_sec = 60
|
||||
self._packet_stats_cache = {}
|
||||
self._packet_type_stats_cache = {}
|
||||
self._neighbors_cache = {"timestamp": 0.0, "value": None}
|
||||
# Short time-based cache for the per-packet cumulative-counts aggregate
|
||||
# (two full-table scans). The storage writer thread calls this once per
|
||||
# recorded packet/duplicate; a few seconds of staleness is fine for the
|
||||
# RRD/UI counters and stops a full scan running on every packet.
|
||||
# Intentionally NOT cleared by _invalidate_hot_caches() — that runs on
|
||||
# every write, which would defeat the cache under load.
|
||||
self._cumulative_counts_cache = {"timestamp": 0.0, "value": None}
|
||||
self._cumulative_counts_ttl_sec = 3.0
|
||||
# Thread-local storage for persistent SQLite connections.
|
||||
# Opening a new connection on every DB call is expensive on SD-card
|
||||
# storage: each sqlite3.connect() call triggers file-system operations
|
||||
@@ -662,7 +671,7 @@ class SQLiteHandler:
|
||||
except Exception:
|
||||
fwd_path_val = str(fwd_path)
|
||||
|
||||
conn.execute(
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO packets (
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
@@ -705,6 +714,7 @@ class SQLiteHandler:
|
||||
),
|
||||
)
|
||||
self._invalidate_hot_caches()
|
||||
return cursor.lastrowid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to store packet in SQLite: {e}")
|
||||
@@ -983,6 +993,7 @@ class SQLiteHandler:
|
||||
packets = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
transport_codes, payload, payload_length,
|
||||
@@ -1035,6 +1046,7 @@ class SQLiteHandler:
|
||||
|
||||
base_query = """
|
||||
SELECT
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
transport_codes, payload, payload_length,
|
||||
@@ -1167,6 +1179,7 @@ class SQLiteHandler:
|
||||
packet = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
header, transport_codes, payload, payload_length,
|
||||
@@ -1184,13 +1197,43 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get packet by hash: {e}")
|
||||
return None
|
||||
|
||||
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
packet = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
timestamp, type, route, length, rssi, snr, score,
|
||||
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
|
||||
header, transport_codes, payload, payload_length,
|
||||
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
|
||||
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
|
||||
FROM packets
|
||||
WHERE id = ?
|
||||
""",
|
||||
(packet_id,),
|
||||
).fetchone()
|
||||
|
||||
return dict(packet) if packet else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get packet by id: {e}")
|
||||
return None
|
||||
|
||||
def get_packet_type_stats(self, hours: int = 24) -> dict:
|
||||
try:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
now = time.time()
|
||||
cached = self._packet_type_stats_cache.get(hours)
|
||||
if cached and (now - cached["timestamp"]) < self._hot_cache_ttl_sec:
|
||||
return cached["value"]
|
||||
cutoff = now - (hours * 3600)
|
||||
|
||||
# Align with pyMC_core feat/newRadios PAYLOAD_TYPES (0x0B = CONTROL)
|
||||
# Align with openhop-core feat/newRadios PAYLOAD_TYPES (0x0B = CONTROL)
|
||||
try:
|
||||
from pymc_core.protocol.utils import PAYLOAD_TYPES as _PT
|
||||
from openhop_core.protocol.utils import PAYLOAD_TYPES as _PT
|
||||
|
||||
_human = {
|
||||
"REQ": "Request",
|
||||
@@ -1262,13 +1305,15 @@ class SQLiteHandler:
|
||||
if other_count > 0:
|
||||
type_counts["Other Types (>15)"] = other_count
|
||||
|
||||
return {
|
||||
result = {
|
||||
"hours": hours,
|
||||
"packet_type_totals": type_counts,
|
||||
"total_packets": sum(type_counts.values()),
|
||||
"period": f"{hours} hours",
|
||||
"data_source": "sqlite",
|
||||
}
|
||||
self._packet_type_stats_cache[hours] = {"timestamp": now, "value": result}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get packet type stats from SQLite: {e}")
|
||||
@@ -1602,6 +1647,11 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to cleanup old data: {e}")
|
||||
|
||||
def get_cumulative_counts(self) -> dict:
|
||||
now = time.time()
|
||||
cached = self._cumulative_counts_cache.get("value")
|
||||
cached_ts = float(self._cumulative_counts_cache.get("timestamp", 0.0))
|
||||
if cached is not None and (now - cached_ts) < self._cumulative_counts_ttl_sec:
|
||||
return cached
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -1630,12 +1680,14 @@ class SQLiteHandler:
|
||||
"""
|
||||
).fetchone()
|
||||
|
||||
return {
|
||||
result = {
|
||||
"rx_total": int(totals["rx_total"] or 0),
|
||||
"tx_total": int(totals["tx_total"] or 0),
|
||||
"drop_total": int(totals["drop_total"] or 0),
|
||||
"type_counts": type_counts,
|
||||
}
|
||||
self._cumulative_counts_cache = {"timestamp": now, "value": result}
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get cumulative counts: {e}")
|
||||
@@ -1742,7 +1794,7 @@ class SQLiteHandler:
|
||||
A base64-encoded transport key derived from the name
|
||||
"""
|
||||
try:
|
||||
from pymc_core.protocol.transport_keys import get_auto_key_for
|
||||
from openhop_core.protocol.transport_keys import get_auto_key_for
|
||||
|
||||
key_bytes = get_auto_key_for(name)
|
||||
|
||||
@@ -2117,29 +2169,32 @@ class SQLiteHandler:
|
||||
return []
|
||||
|
||||
def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool:
|
||||
"""Insert or update client sync state using single upsert operation."""
|
||||
"""Insert or update client sync state without clobbering unspecified fields."""
|
||||
try:
|
||||
with self._connect() as conn:
|
||||
now = time.time()
|
||||
kwargs["updated_at"] = now
|
||||
update_fields = dict(kwargs)
|
||||
update_fields["updated_at"] = now
|
||||
|
||||
# Set defaults for insert path
|
||||
kwargs.setdefault("sync_since", 0)
|
||||
kwargs.setdefault("pending_ack_crc", 0)
|
||||
kwargs.setdefault("push_post_timestamp", 0)
|
||||
kwargs.setdefault("ack_timeout_time", 0)
|
||||
kwargs.setdefault("push_failures", 0)
|
||||
kwargs.setdefault("last_activity", now)
|
||||
# INSERT must satisfy NOT NULL columns (last_activity), while
|
||||
# ON CONFLICT updates should only touch supplied fields.
|
||||
insert_fields = dict(update_fields)
|
||||
if insert_fields.get("last_activity") is None:
|
||||
insert_fields["last_activity"] = now
|
||||
|
||||
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys())
|
||||
columns = ["room_hash", "client_pubkey"] + list(insert_fields.keys())
|
||||
placeholders = ["?"] * len(columns)
|
||||
values = [room_hash, client_pubkey] + list(kwargs.values())
|
||||
values = [room_hash, client_pubkey] + list(insert_fields.values())
|
||||
|
||||
# Use INSERT OR REPLACE for single atomic upsert
|
||||
# Update only supplied columns on conflict so partial updates don't
|
||||
# reset counters/state such as push_failures.
|
||||
update_set = ", ".join(f"{col}=excluded.{col}" for col in update_fields.keys())
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT OR REPLACE INTO room_client_sync ({", ".join(columns)})
|
||||
INSERT INTO room_client_sync ({", ".join(columns)})
|
||||
VALUES ({", ".join(placeholders)})
|
||||
ON CONFLICT(room_hash, client_pubkey)
|
||||
DO UPDATE SET {update_set}
|
||||
""",
|
||||
values,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
@@ -20,17 +22,32 @@ class StorageCollector:
|
||||
self.glass_publish_callback = None
|
||||
self._pending_tasks = set()
|
||||
|
||||
# Dedicated single writer thread for all blocking storage work (the SQLite
|
||||
# write, the cumulative-counts aggregate, RRD updates, and network
|
||||
# publishing). This keeps that work off the asyncio event loop, which it
|
||||
# was previously stalling for seconds per packet on a busy mesh — starving
|
||||
# every other coroutine (e.g. send_advert would time out). One worker
|
||||
# preserves packet write ordering and reuses a single thread-local SQLite
|
||||
# connection (no WAL writer contention, no connection fan-out).
|
||||
self._db_executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix="storage-writer"
|
||||
)
|
||||
|
||||
self.storage_dir = resolve_storage_dir(config)
|
||||
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.sqlite_handler = SQLiteHandler(self.storage_dir)
|
||||
self.rrd_handler = RRDToolHandler(self.storage_dir)
|
||||
|
||||
# Initialize MQTT handler if configured
|
||||
# Initialize MQTT handler only when at least one broker is configured
|
||||
self.mqtt_handler = None
|
||||
if (
|
||||
config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})
|
||||
) and local_identity:
|
||||
mqtt_brokers_config = config.get("mqtt_brokers", {}) or {}
|
||||
letsmesh_config = config.get("letsmesh", {}) or {}
|
||||
mqtt_config = config.get("mqtt", {}) or {}
|
||||
has_brokers_configured = (
|
||||
bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config)
|
||||
)
|
||||
if has_brokers_configured and local_identity:
|
||||
try:
|
||||
# Pass local_identity directly (supports both standard and firmware keys)
|
||||
self.mqtt_handler = MeshCoreToMqttPusher(
|
||||
@@ -45,6 +62,8 @@ class StorageCollector:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize MQTT handler: {e}")
|
||||
self.mqtt_handler = None
|
||||
else:
|
||||
logger.info("MQTT handler disabled - no brokers configured")
|
||||
|
||||
# Initialize hardware stats collector
|
||||
from .hardware_stats import HardwareStatsCollector
|
||||
@@ -55,8 +74,9 @@ class StorageCollector:
|
||||
# Initialize WebSocket handler for real-time updates
|
||||
self.websocket_available = False
|
||||
self.websocket_has_connected_clients = lambda: False
|
||||
self._last_ws_stats_broadcast: float = 0.0
|
||||
self._ws_stats_broadcast_interval_sec: float = 5.0
|
||||
self._stats_stop_event = threading.Event()
|
||||
self._stats_thread: Optional[threading.Thread] = None
|
||||
try:
|
||||
from .websocket_handler import (
|
||||
broadcast_packet,
|
||||
@@ -69,6 +89,19 @@ class StorageCollector:
|
||||
self.websocket_has_connected_clients = has_connected_clients
|
||||
self.websocket_available = True
|
||||
logger.info("WebSocket handler initialized for real-time updates")
|
||||
|
||||
# Broadcast aggregate stats on a fixed cadence rather than inline on the
|
||||
# per-packet write path. get_packet_stats(24h) is a multi-second aggregate;
|
||||
# running it inside _record_packet_blocking made the storage writer thread
|
||||
# spend ~1-2s of every 5s on it, competing with packet inserts. A dedicated
|
||||
# tick keeps the writer doing only fast writes and only runs the aggregate
|
||||
# when a dashboard client is actually connected.
|
||||
self._stats_thread = threading.Thread(
|
||||
target=self._stats_broadcast_loop,
|
||||
name="stats-broadcast",
|
||||
daemon=True,
|
||||
)
|
||||
self._stats_thread.start()
|
||||
except ImportError:
|
||||
logger.debug("WebSocket handler not available")
|
||||
|
||||
@@ -145,7 +178,12 @@ class StorageCollector:
|
||||
return stats
|
||||
|
||||
def record_packet(self, packet_record: dict, skip_mqtt_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT
|
||||
"""Record a packet to storage and publish it.
|
||||
|
||||
All blocking work — the SQLite write, the cumulative-counts aggregate, the
|
||||
RRD update, and network publishing — runs on the dedicated writer thread so
|
||||
it never blocks the asyncio event loop. Callers treat this as
|
||||
fire-and-forget (the previous synchronous version blocked the loop).
|
||||
|
||||
Args:
|
||||
packet_record: Dictionary containing packet information
|
||||
@@ -155,59 +193,79 @@ class StorageCollector:
|
||||
f"Recording packet: type={packet_record.get('type')}, "
|
||||
f"transmitted={packet_record.get('transmitted')}"
|
||||
)
|
||||
self._submit_db(self._record_packet_blocking, packet_record, skip_mqtt_if_invalid)
|
||||
|
||||
# HOT PATH: Store to local databases only (fast, non-blocking)
|
||||
self.sqlite_handler.store_packet(packet_record)
|
||||
def _submit_db(self, fn, *args):
|
||||
"""Run a blocking storage operation on the dedicated writer thread.
|
||||
|
||||
Falls back to running inline only if the executor has already been shut
|
||||
down (process teardown), so late records are not silently dropped.
|
||||
"""
|
||||
try:
|
||||
self._db_executor.submit(self._run_db_task, fn, *args)
|
||||
except RuntimeError:
|
||||
self._run_db_task(fn, *args)
|
||||
|
||||
def _run_db_task(self, fn, *args):
|
||||
"""Execute a writer-thread task, logging (not raising) on failure."""
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception as e:
|
||||
logger.error(f"Storage writer task failed: {e}", exc_info=True)
|
||||
|
||||
def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Store, aggregate, update metrics, and publish one packet (writer thread)."""
|
||||
packet_id = self.sqlite_handler.store_packet(packet_record)
|
||||
if packet_id is not None:
|
||||
packet_record["id"] = packet_id
|
||||
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
|
||||
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
|
||||
|
||||
# DEFERRED: Publish to network sinks and WebSocket in background tasks
|
||||
# This prevents network latency from blocking packet processing
|
||||
self._schedule_background(
|
||||
self._deferred_publish,
|
||||
packet_record,
|
||||
skip_mqtt_if_invalid,
|
||||
sync_fallback=self._publish_packet_sync,
|
||||
)
|
||||
|
||||
async def _deferred_publish(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Deferred background task for all network publishing operations."""
|
||||
try:
|
||||
self._publish_packet_sync(packet_record, skip_mqtt)
|
||||
except Exception as e:
|
||||
logger.error(f"Deferred publish failed: {e}", exc_info=True)
|
||||
self._publish_packet_sync(packet_record, skip_mqtt)
|
||||
|
||||
def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool):
|
||||
"""Publish packet updates synchronously (used when no asyncio loop is active)."""
|
||||
"""Publish a single packet (glass, per-packet WebSocket event, MQTT).
|
||||
|
||||
Only fast, per-packet work runs here. The aggregate stats broadcast is
|
||||
driven separately by _stats_broadcast_loop so the writer thread is not
|
||||
held by the multi-second get_packet_stats(24h) query.
|
||||
"""
|
||||
self._publish_to_glass(packet_record, "packet")
|
||||
|
||||
if self.websocket_available:
|
||||
try:
|
||||
self.websocket_broadcast_packet(packet_record)
|
||||
if self.websocket_has_connected_clients():
|
||||
now_mono = time.monotonic()
|
||||
if (
|
||||
now_mono - self._last_ws_stats_broadcast
|
||||
>= self._ws_stats_broadcast_interval_sec
|
||||
):
|
||||
self._last_ws_stats_broadcast = now_mono
|
||||
packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24)
|
||||
uptime_seconds = (
|
||||
time.time() - self.repeater_handler.start_time
|
||||
if self.repeater_handler
|
||||
else 0
|
||||
)
|
||||
self.websocket_broadcast_stats(
|
||||
{
|
||||
"packet_stats": packet_stats_24h,
|
||||
"system_stats": {"uptime_seconds": uptime_seconds},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
||||
|
||||
self._publish_packet_to_mqtt(packet_record)
|
||||
|
||||
def _broadcast_stats_once(self) -> None:
|
||||
"""Compute the 24h aggregate and broadcast it to WebSocket clients."""
|
||||
packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24)
|
||||
uptime_seconds = (
|
||||
time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
|
||||
)
|
||||
self.websocket_broadcast_stats(
|
||||
{
|
||||
"packet_stats": packet_stats_24h,
|
||||
"system_stats": {"uptime_seconds": uptime_seconds},
|
||||
}
|
||||
)
|
||||
|
||||
def _stats_broadcast_loop(self) -> None:
|
||||
"""Broadcast aggregate stats every interval while clients are connected.
|
||||
|
||||
Runs on its own thread (off the event loop and off the storage writer) so
|
||||
the heavy get_packet_stats(24h) aggregate never sits in the packet write
|
||||
path. Skips the query entirely when no dashboard client is connected.
|
||||
"""
|
||||
while not self._stats_stop_event.wait(self._ws_stats_broadcast_interval_sec):
|
||||
try:
|
||||
if self.websocket_has_connected_clients():
|
||||
self._broadcast_stats_once()
|
||||
except Exception as e:
|
||||
logger.debug(f"Stats broadcast failed: {e}")
|
||||
|
||||
def _publish_packet_to_mqtt(self, packet_record: dict):
|
||||
"""Publish packet to mqtt broker if enabled and allowed.
|
||||
|
||||
@@ -366,6 +424,9 @@ class StorageCollector:
|
||||
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
|
||||
return self.sqlite_handler.get_packet_by_hash(packet_hash)
|
||||
|
||||
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
|
||||
return self.sqlite_handler.get_packet_by_id(packet_id)
|
||||
|
||||
def get_rrd_data(
|
||||
self,
|
||||
start_time: Optional[int] = None,
|
||||
@@ -421,6 +482,15 @@ class StorageCollector:
|
||||
return self.sqlite_handler.get_noise_floor_stats(hours)
|
||||
|
||||
def close(self):
|
||||
# Stop the stats broadcast thread.
|
||||
self._stats_stop_event.set()
|
||||
if self._stats_thread is not None:
|
||||
self._stats_thread.join(timeout=2)
|
||||
|
||||
# Drain and stop the storage writer thread first so pending writes and
|
||||
# publishes complete before MQTT and the DB connections are torn down.
|
||||
self._db_executor.shutdown(wait=True)
|
||||
|
||||
# Cancel all pending background tasks
|
||||
for task in self._pending_tasks:
|
||||
if not task.done():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Storage utility classes and functions for data acquisition."""
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class PacketRecord:
|
||||
|
||||
# Extract timestamp and format date/time
|
||||
timestamp = packet_record.get("timestamp", 0)
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
||||
|
||||
# Format route type (1=Flood->F, 2=Direct->D, etc)
|
||||
route_map = {1: "F", 2: "D"}
|
||||
|
||||
@@ -24,6 +24,7 @@ _connected_clients = set()
|
||||
PING_INTERVAL = 30 # seconds
|
||||
_heartbeat_thread = None
|
||||
_heartbeat_running = False
|
||||
_websocket_plugin = None
|
||||
|
||||
|
||||
class PacketWebSocket(WebSocket):
|
||||
@@ -152,9 +153,20 @@ def _heartbeat_loop():
|
||||
|
||||
def init_websocket():
|
||||
"""Initialize WebSocket plugin and start heartbeat"""
|
||||
global _heartbeat_thread, _heartbeat_running
|
||||
global _heartbeat_thread, _heartbeat_running, _websocket_plugin
|
||||
|
||||
WebSocketPlugin(cherrypy.engine).subscribe()
|
||||
# Re-initialize plugin safely across CherryPy stop/start cycles.
|
||||
# ws4py's manager thread cannot be started twice, so always tear down
|
||||
# any previously subscribed plugin instance before creating a new one.
|
||||
if _websocket_plugin is not None:
|
||||
try:
|
||||
_websocket_plugin.unsubscribe()
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket plugin unsubscribe during init failed: {e}")
|
||||
_websocket_plugin = None
|
||||
|
||||
_websocket_plugin = WebSocketPlugin(cherrypy.engine)
|
||||
_websocket_plugin.subscribe()
|
||||
cherrypy.tools.websocket = WebSocketTool()
|
||||
|
||||
# Start heartbeat thread
|
||||
@@ -165,3 +177,19 @@ def init_websocket():
|
||||
logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat")
|
||||
else:
|
||||
logger.info("WebSocket initialized")
|
||||
|
||||
|
||||
def shutdown_websocket():
|
||||
"""Stop websocket heartbeat and unsubscribe plugin for clean restart."""
|
||||
global _heartbeat_running, _heartbeat_thread, _websocket_plugin
|
||||
|
||||
_heartbeat_running = False
|
||||
_heartbeat_thread = None
|
||||
_connected_clients.clear()
|
||||
|
||||
if _websocket_plugin is not None:
|
||||
try:
|
||||
_websocket_plugin.unsubscribe()
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket plugin unsubscribe failed: {e}")
|
||||
_websocket_plugin = None
|
||||
|
||||
+13
-5
@@ -6,9 +6,9 @@ import time
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from pymc_core.node.handlers.base import BaseHandler
|
||||
from pymc_core.protocol import Packet
|
||||
from pymc_core.protocol.constants import (
|
||||
from openhop_core.node.handlers.base import BaseHandler
|
||||
from openhop_core.protocol import Packet
|
||||
from openhop_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
PAYLOAD_TYPE_ADVERT,
|
||||
PAYLOAD_TYPE_ANON_REQ,
|
||||
@@ -19,7 +19,7 @@ from pymc_core.protocol.constants import (
|
||||
ROUTE_TYPE_TRANSPORT_DIRECT,
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD,
|
||||
)
|
||||
from pymc_core.protocol.packet_utils import PacketHeaderUtils, PathUtils
|
||||
from openhop_core.protocol.packet_utils import PacketHeaderUtils, PathUtils
|
||||
|
||||
from repeater.airtime import AirtimeManager
|
||||
from repeater.data_acquisition import StorageCollector
|
||||
@@ -400,6 +400,14 @@ class RepeaterHandler(BaseHandler):
|
||||
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
||||
self.direct_dup_count += 1
|
||||
|
||||
# Expose effective drop reason to PacketRouter.
|
||||
# Some Packet implementations are slot-based and cannot accept dynamic attrs.
|
||||
metadata["_repeater_drop_reason"] = drop_reason
|
||||
try:
|
||||
setattr(packet, "_repeater_drop_reason", drop_reason)
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
display_hashes = (
|
||||
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
|
||||
)
|
||||
@@ -820,7 +828,7 @@ class RepeaterHandler(BaseHandler):
|
||||
return False, "No storage available for transport key validation"
|
||||
|
||||
try:
|
||||
from pymc_core.protocol.transport_keys import calc_transport_code
|
||||
from openhop_core.protocol.transport_keys import calc_transport_code
|
||||
|
||||
# Check cache validity
|
||||
current_time = time.time()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Handler helper modules for pyMC Repeater."""
|
||||
"""Handler helper modules for openHop Repeater."""
|
||||
|
||||
from .advert import AdvertHelper
|
||||
from .discovery import DiscoveryHelper
|
||||
|
||||
@@ -2,8 +2,8 @@ import logging
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pymc_core.protocol import Identity
|
||||
from pymc_core.protocol.constants import PUB_KEY_SIZE
|
||||
from openhop_core.protocol import Identity
|
||||
from openhop_core.protocol.constants import PUB_KEY_SIZE
|
||||
|
||||
logger = logging.getLogger("ACL")
|
||||
|
||||
@@ -48,6 +48,30 @@ class ACL:
|
||||
self.allow_read_only = allow_read_only
|
||||
self.clients: Dict[bytes, ClientInfo] = {}
|
||||
|
||||
def _is_replay(self, client: ClientInfo, timestamp: int) -> bool:
|
||||
if timestamp <= client.last_timestamp:
|
||||
logger.warning(
|
||||
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _touch_client_session(
|
||||
self,
|
||||
client: ClientInfo,
|
||||
shared_secret: bytes,
|
||||
timestamp: int,
|
||||
sync_since: int = None,
|
||||
) -> None:
|
||||
now = int(time.time())
|
||||
client.last_timestamp = timestamp
|
||||
client.last_activity = now
|
||||
client.last_login_success = now
|
||||
client.shared_secret = shared_secret
|
||||
if sync_since is not None:
|
||||
client.sync_since = sync_since
|
||||
logger.debug(f"Stored sync_since={sync_since} for client")
|
||||
|
||||
def authenticate_client(
|
||||
self,
|
||||
client_identity: Identity,
|
||||
@@ -106,13 +130,23 @@ class ACL:
|
||||
if not password:
|
||||
client = self.clients.get(pub_key)
|
||||
if client is None:
|
||||
if self.allow_read_only:
|
||||
logger.info("Blank password, allowing read-only guest access")
|
||||
return True, PERM_ACL_GUEST
|
||||
else:
|
||||
if not self.allow_read_only:
|
||||
logger.info("Blank password, sender not in ACL and read-only disabled")
|
||||
return False, 0
|
||||
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
|
||||
if len(self.clients) >= self.max_clients:
|
||||
logger.warning("ACL full, cannot add client")
|
||||
return False, 0
|
||||
client = ClientInfo(client_identity, PERM_ACL_GUEST)
|
||||
self.clients[pub_key] = client
|
||||
logger.info("Blank password, allowing read-only guest access")
|
||||
else:
|
||||
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
|
||||
|
||||
if self._is_replay(client, timestamp):
|
||||
return False, 0
|
||||
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
|
||||
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
|
||||
client.permissions |= PERM_ACL_GUEST
|
||||
return True, client.permissions
|
||||
|
||||
permissions = 0
|
||||
@@ -120,9 +154,6 @@ class ACL:
|
||||
logger.debug(
|
||||
f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}"
|
||||
)
|
||||
logger.debug(
|
||||
f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)"
|
||||
)
|
||||
if admin_pwd and password == admin_pwd:
|
||||
permissions = PERM_ACL_ADMIN
|
||||
logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'")
|
||||
@@ -143,23 +174,11 @@ class ACL:
|
||||
self.clients[pub_key] = client
|
||||
logger.info(f"Added new client {pub_key[:6].hex()}...")
|
||||
|
||||
if timestamp <= client.last_timestamp:
|
||||
logger.warning(
|
||||
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
|
||||
)
|
||||
if self._is_replay(client, timestamp):
|
||||
return False, 0
|
||||
|
||||
client.last_timestamp = timestamp
|
||||
client.last_activity = int(time.time())
|
||||
client.last_login_success = int(time.time())
|
||||
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
|
||||
client.permissions &= ~PERM_ACL_ROLE_MASK
|
||||
client.permissions |= permissions
|
||||
client.shared_secret = shared_secret
|
||||
|
||||
# Store sync_since for room server clients
|
||||
if sync_since is not None:
|
||||
client.sync_since = sync_since
|
||||
logger.debug(f"Stored sync_since={sync_since} for client")
|
||||
|
||||
logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}")
|
||||
return True, client.permissions
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Advertisement packet handling helper for pyMC Repeater.
|
||||
Advertisement packet handling helper for openHop Repeater.
|
||||
|
||||
This module processes advertisement packets for neighbor tracking and discovery.
|
||||
Includes adaptive rate limiting based on mesh activity.
|
||||
@@ -13,7 +13,7 @@ from collections import OrderedDict, deque
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from pymc_core.node.handlers.advert import AdvertHandler
|
||||
from openhop_core.node.handlers.advert import AdvertHandler
|
||||
|
||||
logger = logging.getLogger("AdvertHelper")
|
||||
|
||||
@@ -603,7 +603,7 @@ class AdvertHelper:
|
||||
return
|
||||
|
||||
# Get route type from packet header
|
||||
from pymc_core.protocol.constants import PH_ROUTE_MASK
|
||||
from openhop_core.protocol.constants import PH_ROUTE_MASK
|
||||
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Discovery request/response handling helper for pyMC Repeater.
|
||||
Discovery request/response handling helper for openHop Repeater.
|
||||
|
||||
This module handles the processing and response to discovery requests,
|
||||
allowing other nodes to discover repeaters on the mesh network.
|
||||
@@ -8,8 +8,12 @@ allowing other nodes to discover repeaters on the mesh network.
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from pymc_core.node.handlers.control import ControlHandler
|
||||
from openhop_core.node.handlers.control import ControlHandler
|
||||
|
||||
logger = logging.getLogger("DiscoveryHelper")
|
||||
|
||||
@@ -22,6 +26,15 @@ logger = logging.getLogger("DiscoveryHelper")
|
||||
# 60s (firmware pending_discover_until = futureMillis(60000)).
|
||||
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
|
||||
|
||||
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 10.0
|
||||
DISCOVERY_EVENT_BACKLOG_LIMIT = 512
|
||||
|
||||
NODE_TYPE_NAMES = {
|
||||
1: "Chat Node",
|
||||
2: "Repeater",
|
||||
3: "Room Server",
|
||||
}
|
||||
|
||||
|
||||
class DiscoveryHelper:
|
||||
"""Helper class for processing discovery requests in the repeater."""
|
||||
@@ -60,11 +73,257 @@ class DiscoveryHelper:
|
||||
debug_log_fn=debug_log_fn,
|
||||
)
|
||||
self._pending_tasks = set()
|
||||
self._sessions: dict[str, dict[str, Any]] = {}
|
||||
self._sessions_lock = threading.Lock()
|
||||
|
||||
# Set up the request callback
|
||||
self.control_handler.set_request_callback(self._on_discovery_request)
|
||||
logger.debug("Discovery handler initialized")
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
*,
|
||||
timeout: float = DEFAULT_DISCOVERY_TIMEOUT_SECONDS,
|
||||
filter_mask: int,
|
||||
since: int = 0,
|
||||
prefix_only: bool = False,
|
||||
result_enricher: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new discovery session and return its public metadata."""
|
||||
session_id = uuid.uuid4().hex
|
||||
tag = secrets.randbits(32)
|
||||
created_at = time.time()
|
||||
session = {
|
||||
"session_id": session_id,
|
||||
"tag": tag,
|
||||
"timeout": max(1.0, float(timeout)),
|
||||
"filter_mask": int(filter_mask) & 0xFF,
|
||||
"since": max(0, int(since)),
|
||||
"prefix_only": bool(prefix_only),
|
||||
"created_at": created_at,
|
||||
"started_at": None,
|
||||
"completed_at": None,
|
||||
"status": "created",
|
||||
"results": {},
|
||||
"events": [],
|
||||
"next_event_id": 1,
|
||||
"error": None,
|
||||
"result_enricher": result_enricher,
|
||||
}
|
||||
with self._sessions_lock:
|
||||
self._sessions[session_id] = session
|
||||
return self.get_session_snapshot(session_id) or {}
|
||||
|
||||
def get_session_snapshot(self, session_id: str) -> Optional[dict[str, Any]]:
|
||||
"""Return a public snapshot for a discovery session."""
|
||||
with self._sessions_lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return None
|
||||
return self._public_session_snapshot(session)
|
||||
|
||||
def get_events_since(self, session_id: str, last_event_id: int = 0) -> Optional[dict[str, Any]]:
|
||||
"""Return all session events newer than last_event_id."""
|
||||
with self._sessions_lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return None
|
||||
events = [event for event in session["events"] if event["id"] > last_event_id]
|
||||
return {
|
||||
"events": events,
|
||||
"status": session["status"],
|
||||
"completed": session["status"] in {"completed", "timed_out", "error", "cancelled"},
|
||||
"latest_event_id": session["next_event_id"] - 1,
|
||||
}
|
||||
|
||||
async def execute_session(self, session_id: str) -> None:
|
||||
"""Send a discovery request and stream responses into the session."""
|
||||
session = self._get_session(session_id)
|
||||
if not session:
|
||||
raise ValueError(f"Unknown discovery session: {session_id}")
|
||||
|
||||
if session["status"] != "created":
|
||||
return
|
||||
|
||||
session["started_at"] = time.time()
|
||||
session["status"] = "running"
|
||||
self._emit_event(
|
||||
session_id,
|
||||
"started",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tag": session["tag"],
|
||||
"timeout": session["timeout"],
|
||||
"filter_mask": session["filter_mask"],
|
||||
"since": session["since"],
|
||||
"prefix_only": session["prefix_only"],
|
||||
"started_at": session["started_at"],
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
from openhop_core.protocol.packet_builder import PacketBuilder
|
||||
|
||||
packet = PacketBuilder.create_discovery_request(
|
||||
tag=session["tag"],
|
||||
filter_mask=session["filter_mask"],
|
||||
since=session["since"],
|
||||
prefix_only=session["prefix_only"],
|
||||
)
|
||||
|
||||
def _response_callback(response_data: dict[str, Any]) -> None:
|
||||
self._record_response(session_id, response_data)
|
||||
|
||||
self.control_handler.set_response_callback(session["tag"], _response_callback)
|
||||
|
||||
if not self.packet_injector:
|
||||
raise RuntimeError("No packet injector available")
|
||||
|
||||
success = await self.packet_injector(packet, wait_for_ack=False)
|
||||
if not success:
|
||||
raise RuntimeError("Failed to send discovery request")
|
||||
|
||||
logger.info(
|
||||
"Discovery request sent for session %s tag 0x%08X filter=0x%02X",
|
||||
session_id,
|
||||
session["tag"],
|
||||
session["filter_mask"],
|
||||
)
|
||||
|
||||
await asyncio.sleep(session["timeout"])
|
||||
self._finish_session(session_id, "completed")
|
||||
except asyncio.CancelledError:
|
||||
self._finish_session(session_id, "cancelled")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Discovery session %s failed: %s", session_id, e, exc_info=True)
|
||||
self._finish_session(session_id, "error", error=str(e))
|
||||
finally:
|
||||
self.control_handler.clear_response_callback(session["tag"])
|
||||
|
||||
def start_session_task(self, session_id: str) -> None:
|
||||
"""Schedule a discovery session on the current event loop."""
|
||||
task = asyncio.create_task(self.execute_session(session_id))
|
||||
self._track_task(task)
|
||||
|
||||
def cleanup_sessions(self, max_age_seconds: int = 120) -> None:
|
||||
"""Remove old completed sessions to keep memory bounded."""
|
||||
cutoff = time.time() - max_age_seconds
|
||||
with self._sessions_lock:
|
||||
stale_ids = [
|
||||
session_id
|
||||
for session_id, session in self._sessions.items()
|
||||
if session["status"] in {"completed", "timed_out", "error", "cancelled"}
|
||||
and (session.get("completed_at") or session.get("created_at", 0)) < cutoff
|
||||
]
|
||||
for session_id in stale_ids:
|
||||
self._sessions.pop(session_id, None)
|
||||
|
||||
def _get_session(self, session_id: str) -> Optional[dict[str, Any]]:
|
||||
with self._sessions_lock:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def _record_response(self, session_id: str, response_data: dict[str, Any]) -> None:
|
||||
with self._sessions_lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session or session["status"] != "running":
|
||||
return
|
||||
|
||||
result = dict(response_data)
|
||||
result["node_type_name"] = NODE_TYPE_NAMES.get(
|
||||
result.get("node_type"), f"Unknown({result.get('node_type', 0)})"
|
||||
)
|
||||
result["discovered_at"] = time.time()
|
||||
|
||||
enricher = session.get("result_enricher")
|
||||
if enricher:
|
||||
try:
|
||||
result = enricher(result)
|
||||
except Exception as e:
|
||||
logger.debug("Discovery result enrichment failed: %s", e)
|
||||
|
||||
result_key = str(result.get("pub_key") or "")
|
||||
if not result_key:
|
||||
return
|
||||
|
||||
existing = session["results"].get(result_key)
|
||||
session["results"][result_key] = result
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"tag": session["tag"],
|
||||
"result": result,
|
||||
"is_update": existing is not None,
|
||||
"count": len(session["results"]),
|
||||
}
|
||||
self._append_event_unlocked(session, "discovery_result", payload)
|
||||
|
||||
def _finish_session(self, session_id: str, status: str, error: Optional[str] = None) -> None:
|
||||
with self._sessions_lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session or session["status"] in {"completed", "timed_out", "error", "cancelled"}:
|
||||
return
|
||||
|
||||
session["completed_at"] = time.time()
|
||||
session["status"] = status
|
||||
session["error"] = error
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"tag": session["tag"],
|
||||
"status": status,
|
||||
"error": error,
|
||||
"count": len(session["results"]),
|
||||
"duration_ms": round(
|
||||
(
|
||||
(session["completed_at"] or session["created_at"])
|
||||
- (session["started_at"] or session["created_at"])
|
||||
)
|
||||
* 1000,
|
||||
2,
|
||||
),
|
||||
"completed_at": session["completed_at"],
|
||||
"results": list(session["results"].values()),
|
||||
}
|
||||
event_type = "error" if status == "error" else "completed"
|
||||
self._append_event_unlocked(session, event_type, payload)
|
||||
|
||||
def _emit_event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> None:
|
||||
with self._sessions_lock:
|
||||
session = self._sessions.get(session_id)
|
||||
if not session:
|
||||
return
|
||||
self._append_event_unlocked(session, event_type, payload)
|
||||
|
||||
def _append_event_unlocked(
|
||||
self, session: dict[str, Any], event_type: str, payload: dict[str, Any]
|
||||
) -> None:
|
||||
event_id = session["next_event_id"]
|
||||
session["next_event_id"] += 1
|
||||
session["events"].append(
|
||||
{
|
||||
"id": event_id,
|
||||
"event": event_type,
|
||||
"data": payload,
|
||||
}
|
||||
)
|
||||
if len(session["events"]) > DISCOVERY_EVENT_BACKLOG_LIMIT:
|
||||
session["events"] = session["events"][-DISCOVERY_EVENT_BACKLOG_LIMIT:]
|
||||
|
||||
def _public_session_snapshot(self, session: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": session["session_id"],
|
||||
"tag": session["tag"],
|
||||
"status": session["status"],
|
||||
"timeout": session["timeout"],
|
||||
"filter_mask": session["filter_mask"],
|
||||
"since": session["since"],
|
||||
"prefix_only": session["prefix_only"],
|
||||
"created_at": session["created_at"],
|
||||
"started_at": session["started_at"],
|
||||
"completed_at": session["completed_at"],
|
||||
"count": len(session["results"]),
|
||||
"error": session["error"],
|
||||
}
|
||||
|
||||
def _track_task(self, task: asyncio.Task) -> None:
|
||||
self._pending_tasks.add(task)
|
||||
|
||||
@@ -133,7 +392,7 @@ class DiscoveryHelper:
|
||||
try:
|
||||
our_pub_key = self.local_identity.get_public_key()
|
||||
|
||||
from pymc_core.protocol.packet_builder import PacketBuilder
|
||||
from openhop_core.protocol.packet_builder import PacketBuilder
|
||||
|
||||
response_packet = PacketBuilder.create_discovery_response(
|
||||
tag=tag,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Login/ANON_REQ packet handling helper for pyMC Repeater.
|
||||
Login/ANON_REQ packet handling helper for openHop Repeater.
|
||||
|
||||
This module processes login requests and manages authentication for all identities.
|
||||
"""
|
||||
@@ -8,9 +8,9 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.anon_request import AnonRateLimiter, AnonRequestHandler
|
||||
from pymc_core.node.handlers.login_server import LoginServerHandler
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ
|
||||
from openhop_core.node.handlers.anon_request import AnonRateLimiter, AnonRequestHandler
|
||||
from openhop_core.node.handlers.login_server import LoginServerHandler
|
||||
from openhop_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ
|
||||
|
||||
logger = logging.getLogger("LoginHelper")
|
||||
|
||||
@@ -122,7 +122,7 @@ class LoginHelper:
|
||||
def auth_callback_with_context(
|
||||
client_identity, shared_secret, password, timestamp, sync_since=None
|
||||
):
|
||||
return identity_acl.authenticate_client(
|
||||
success, permissions = identity_acl.authenticate_client(
|
||||
client_identity=client_identity,
|
||||
shared_secret=shared_secret,
|
||||
password=password,
|
||||
@@ -132,6 +132,26 @@ class LoginHelper:
|
||||
target_identity_name=name,
|
||||
target_identity_config=config,
|
||||
)
|
||||
if success and identity_type == "room_server" and self.sqlite_handler is not None:
|
||||
try:
|
||||
sync_kwargs = {}
|
||||
if sync_since is not None:
|
||||
sync_kwargs["sync_since"] = sync_since
|
||||
self.sqlite_handler.upsert_client_sync(
|
||||
room_hash=f"0x{hash_byte:02X}",
|
||||
client_pubkey=client_identity.get_public_key().hex(),
|
||||
pending_ack_crc=0,
|
||||
push_post_timestamp=0,
|
||||
ack_timeout_time=0,
|
||||
push_failures=0,
|
||||
last_activity=time.time(),
|
||||
**sync_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to reset room sync guard state after login for hash=0x{hash_byte:02X}: {e}"
|
||||
)
|
||||
return success, permissions
|
||||
|
||||
handler = LoginServerHandler(
|
||||
local_identity=identity,
|
||||
@@ -167,7 +187,7 @@ class LoginHelper:
|
||||
emit the ``*`` wildcard region first (unless unscoped flood is denied),
|
||||
then each allow-flood named region with a leading ``#`` stripped, with no
|
||||
trailing comma. The firmware wildcard is the always-present default flood
|
||||
scope; pyMC_repeater models that via ``mesh.unscoped_flood_allow``
|
||||
scope; openhop_repeater models that via ``mesh.unscoped_flood_allow``
|
||||
(falling back to ``mesh.global_flood_allow``, default allow).
|
||||
"""
|
||||
parts = []
|
||||
|
||||
@@ -2,7 +2,6 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -81,6 +80,10 @@ class MeshCLI:
|
||||
return self._cmd_clock(command)
|
||||
elif command.startswith("time "):
|
||||
return self._cmd_time(command)
|
||||
elif command == "http start":
|
||||
return self._cmd_http_start()
|
||||
elif command == "http stop":
|
||||
return self._cmd_http_stop()
|
||||
elif command == "start ota":
|
||||
return "Error: OTA not supported in Python repeater"
|
||||
elif command.startswith("password "):
|
||||
@@ -156,6 +159,8 @@ class MeshCLI:
|
||||
" advert Send self advertisement",
|
||||
" clock Show current UTC time",
|
||||
" clock sync Sync clock (no-op, uses system time)",
|
||||
" http start Start the HTTP server",
|
||||
" http stop Stop the HTTP server",
|
||||
" ver Show version info",
|
||||
" password <pw> Change admin password",
|
||||
" clear stats Clear statistics",
|
||||
@@ -226,6 +231,9 @@ class MeshCLI:
|
||||
"reboot": "Restart the repeater service via systemd.",
|
||||
"advert": "Trigger a self-advertisement flood packet.",
|
||||
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
|
||||
"http": "http start|stop - Control the HTTP server.",
|
||||
"http start": "Start the HTTP server.",
|
||||
"http stop": "Stop the HTTP server.",
|
||||
"ver": "Show repeater version and identity type.",
|
||||
"password": "password <new_password> \u2014 Change the admin password.",
|
||||
"tempradio": (
|
||||
@@ -296,6 +304,26 @@ class MeshCLI:
|
||||
"""Set time - not supported in Python (use system time)."""
|
||||
return "Error: Time setting not supported (system time is used)"
|
||||
|
||||
def _cmd_http_start(self) -> str:
|
||||
"""Start HTTP server."""
|
||||
from repeater.service_utils import start_http_server
|
||||
|
||||
daemon_instance = getattr(self.config_manager, "daemon", None)
|
||||
success, message = start_http_server(daemon_instance)
|
||||
if success:
|
||||
return f"OK - {message}"
|
||||
return f"Error: {message}"
|
||||
|
||||
def _cmd_http_stop(self) -> str:
|
||||
"""Stop HTTP server."""
|
||||
from repeater.service_utils import stop_http_server
|
||||
|
||||
daemon_instance = getattr(self.config_manager, "daemon", None)
|
||||
success, message = stop_http_server(daemon_instance)
|
||||
if success:
|
||||
return f"OK - {message}"
|
||||
return f"Error: {message}"
|
||||
|
||||
def _cmd_password(self, command: str) -> str:
|
||||
"""Change admin password."""
|
||||
new_password = command[9:].strip()
|
||||
@@ -329,8 +357,8 @@ class MeshCLI:
|
||||
def _cmd_version(self) -> str:
|
||||
"""Get version information."""
|
||||
role = "room_server" if self.identity_type == "room_server" else "repeater"
|
||||
version = self.config.get("version", "1.0.0")
|
||||
return f"pyMC_{role} v{version}"
|
||||
version = self.config.get("version", "13")
|
||||
return f"openHop_{role} v{version}"
|
||||
|
||||
# ==================== Get Commands ====================
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
@@ -5,14 +6,31 @@ logger = logging.getLogger("PathHelper")
|
||||
|
||||
|
||||
class PathHelper:
|
||||
def __init__(self, acl_dict=None, log_fn=None):
|
||||
def __init__(self, acl_dict=None, log_fn=None, ack_received_callback=None):
|
||||
|
||||
self.acl_dict = acl_dict or {}
|
||||
self.log_fn = log_fn or logger.info
|
||||
self.ack_received_callback = ack_received_callback
|
||||
|
||||
async def _register_ack_crc(self, ack_crc: int) -> None:
|
||||
"""Propagate an ACK CRC to the configured callback."""
|
||||
if ack_crc is None:
|
||||
return
|
||||
callback = self.ack_received_callback
|
||||
if callback is None:
|
||||
return
|
||||
try:
|
||||
result = callback(ack_crc)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
logger.debug(f"ACK callback failed for CRC {ack_crc:08X}: {e}")
|
||||
|
||||
async def process_path_packet(self, packet):
|
||||
|
||||
from pymc_core.protocol.crypto import CryptoUtils
|
||||
from openhop_core.protocol.constants import PAYLOAD_TYPE_ACK
|
||||
from openhop_core.protocol.crypto import CryptoUtils
|
||||
from openhop_core.protocol.packet_utils import PathUtils
|
||||
|
||||
try:
|
||||
if len(packet.payload) < 2:
|
||||
@@ -60,30 +78,54 @@ class PathHelper:
|
||||
return False
|
||||
|
||||
# Parse decrypted PATH data
|
||||
# Format: path_len(1) + path[path_len] + extra_type(1) + extra[...]
|
||||
# Format: path_len(1) + path[path_byte_len] + extra_type(1) + extra[...]
|
||||
if len(decrypted) < 1:
|
||||
logger.debug("Decrypted PATH data too short")
|
||||
return False
|
||||
path_len_byte = decrypted[0]
|
||||
if PathUtils.is_valid_path_len(path_len_byte):
|
||||
path_byte_len = PathUtils.get_path_byte_len(path_len_byte)
|
||||
path_hops = PathUtils.get_path_hash_count(path_len_byte)
|
||||
else:
|
||||
# Legacy fallback for malformed/old packets: treat first byte as raw path bytes.
|
||||
path_byte_len = path_len_byte
|
||||
path_hops = path_byte_len
|
||||
|
||||
path_len = decrypted[0]
|
||||
if len(decrypted) < 1 + path_len:
|
||||
if len(decrypted) < 1 + path_byte_len:
|
||||
logger.debug(
|
||||
f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}"
|
||||
f"PATH data truncated: need {1 + path_byte_len} bytes, got {len(decrypted)}"
|
||||
)
|
||||
return False
|
||||
|
||||
path_data = decrypted[1 : 1 + path_len]
|
||||
path_data = decrypted[1 : 1 + path_byte_len]
|
||||
|
||||
# Update client's out_path (same as C++ memcpy)
|
||||
client.out_path = bytearray(path_data)
|
||||
client.out_path_len = path_len
|
||||
client.out_path_len = (
|
||||
path_len_byte if PathUtils.is_valid_path_len(path_len_byte) else path_byte_len
|
||||
)
|
||||
client.last_activity = int(time.time())
|
||||
|
||||
logger.info(
|
||||
f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: "
|
||||
f"path_len={path_len}, path={[hex(b) for b in path_data]}"
|
||||
f"path_len_byte=0x{path_len_byte:02X}, hops={path_hops}, "
|
||||
f"path={[hex(b) for b in path_data]}"
|
||||
)
|
||||
|
||||
# Handle bundled ACK in PATH extra section.
|
||||
ack_crc = None
|
||||
extra_start = 1 + path_byte_len
|
||||
if len(decrypted) > extra_start:
|
||||
extra_type = decrypted[extra_start] & 0x0F
|
||||
extra_payload = decrypted[extra_start + 1 :]
|
||||
if extra_type == PAYLOAD_TYPE_ACK and len(extra_payload) >= 4:
|
||||
ack_crc = int.from_bytes(extra_payload[:4], "little")
|
||||
logger.info(
|
||||
f"PATH bundled ACK extracted for client 0x{src_hash:02X}: CRC={ack_crc:08X}"
|
||||
)
|
||||
|
||||
if ack_crc is not None:
|
||||
await self._register_ack_crc(ack_crc)
|
||||
# Don't mark as do_not_retransmit - let it forward normally
|
||||
return False
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Protocol request (REQ) handling helper for pyMC Repeater.
|
||||
Protocol request (REQ) handling helper for openHop Repeater.
|
||||
|
||||
Provides repeater-specific callbacks for status and telemetry requests.
|
||||
"""
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import struct
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.protocol_request import (
|
||||
from openhop_core.node.handlers.protocol_request import (
|
||||
REQ_TYPE_GET_ACCESS_LIST,
|
||||
REQ_TYPE_GET_NEIGHBOURS,
|
||||
REQ_TYPE_GET_OWNER_INFO,
|
||||
@@ -72,6 +72,7 @@ class ProtocolRequestHelper:
|
||||
local_identity=identity,
|
||||
contacts=acl_contacts,
|
||||
get_client_fn=lambda src_hash: self._get_client_from_acl(identity_acl, src_hash),
|
||||
get_clients_fn=lambda src_hash: self._get_clients_from_acl(identity_acl, src_hash),
|
||||
request_handlers=request_handlers,
|
||||
log_fn=logger.info,
|
||||
)
|
||||
@@ -100,10 +101,16 @@ class ProtocolRequestHelper:
|
||||
|
||||
def _get_client_from_acl(self, acl, src_hash: int):
|
||||
"""Get client from ACL by source hash."""
|
||||
clients = self._get_clients_from_acl(acl, src_hash)
|
||||
return clients[0] if clients else None
|
||||
|
||||
def _get_clients_from_acl(self, acl, src_hash: int):
|
||||
"""Get all ACL clients whose public-key first byte matches source hash."""
|
||||
matches = []
|
||||
for client_info in acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
return client_info
|
||||
return None
|
||||
matches.append(client_info)
|
||||
return matches
|
||||
|
||||
async def process_request_packet(self, packet):
|
||||
|
||||
@@ -361,14 +368,14 @@ class ProtocolRequestHelper:
|
||||
Matches C++ simple_repeater: sprintf("%s\\n%s\\n%s", FIRMWARE_VERSION, node_name, owner_info)
|
||||
"""
|
||||
repeater_cfg = self.config.get("repeater", {})
|
||||
node_name = repeater_cfg.get("node_name", "pyMC_Repeater")
|
||||
node_name = repeater_cfg.get("node_name", "openhop-repeater")
|
||||
owner_info = repeater_cfg.get("owner_info", "")
|
||||
|
||||
# Version: use package version if available, fallback to "pyMC"
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version
|
||||
|
||||
fw_version = pkg_version("pymc-repeater")
|
||||
fw_version = pkg_version("openhop-repeater")
|
||||
except Exception:
|
||||
fw_version = "pyMC"
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import secrets
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
from pymc_core.protocol import CryptoUtils, PacketBuilder
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
from openhop_core.protocol import CryptoUtils, PacketBuilder
|
||||
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
from openhop_core.protocol.packet_utils import PathUtils
|
||||
|
||||
logger = logging.getLogger("RoomServer")
|
||||
|
||||
@@ -102,8 +103,8 @@ class RoomServer:
|
||||
return False
|
||||
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import (
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_ROOM_SERVER,
|
||||
)
|
||||
@@ -328,6 +329,12 @@ class RoomServer:
|
||||
|
||||
if sync_state:
|
||||
failures = sync_state.get("push_failures", 0)
|
||||
if failures >= MAX_PUSH_FAILURES:
|
||||
logger.debug(
|
||||
f"Room '{self.room_name}': Client 0x{client_info.id.get_public_key()[0]:02X} "
|
||||
f"at max failures ({failures}), skipping push"
|
||||
)
|
||||
return False
|
||||
if failures > 0:
|
||||
# Apply exponential backoff
|
||||
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
|
||||
@@ -356,11 +363,9 @@ class RoomServer:
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
|
||||
)
|
||||
|
||||
# Calculate expected ACK (same algorithm as pymc_core)
|
||||
attempt = 0
|
||||
pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes)
|
||||
ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4]
|
||||
# Calculate expected ACK (MeshCore signed text):
|
||||
# sha256(timestamp + flags + author_prefix + text + recipient_pubkey)[:4]
|
||||
ack_hash = CryptoUtils.sha256(plaintext + client_info.id.get_public_key())[:4]
|
||||
expected_ack_crc = int.from_bytes(ack_hash, "little")
|
||||
|
||||
# Determine routing based on stored out_path
|
||||
@@ -378,29 +383,52 @@ class RoomServer:
|
||||
|
||||
# Add stored path for direct routing
|
||||
if route_type == "direct" and len(client_info.out_path) > 0:
|
||||
packet.path = bytearray(client_info.out_path[: client_info.out_path_len])
|
||||
packet.path_len = client_info.out_path_len
|
||||
if PathUtils.is_valid_path_len(client_info.out_path_len):
|
||||
path_byte_len = PathUtils.get_path_byte_len(client_info.out_path_len)
|
||||
packet.path = bytearray(client_info.out_path[:path_byte_len])
|
||||
packet.path_len = client_info.out_path_len
|
||||
else:
|
||||
# Legacy fallback: treat stored path as 1-byte-hop path and clamp to
|
||||
# valid encoded range (0-63 hops).
|
||||
legacy_hops = min(len(client_info.out_path), 63)
|
||||
packet.path = bytearray(client_info.out_path[:legacy_hops])
|
||||
packet.path_len = legacy_hops
|
||||
|
||||
# Calculate ACK timeout
|
||||
if route_type == "flood":
|
||||
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
|
||||
else:
|
||||
path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0
|
||||
if PathUtils.is_valid_path_len(client_info.out_path_len):
|
||||
path_len = PathUtils.get_path_hash_count(client_info.out_path_len)
|
||||
else:
|
||||
path_len = min(len(client_info.out_path), 63)
|
||||
ack_timeout = (
|
||||
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
|
||||
) / 1000.0
|
||||
|
||||
# Update client sync state with pending ACK
|
||||
current_sync_since = (
|
||||
sync_state.get("sync_since", 0)
|
||||
if sync_state
|
||||
else getattr(client_info, "sync_since", 0)
|
||||
)
|
||||
self.db.upsert_client_sync(
|
||||
room_hash=f"0x{self.room_hash:02X}",
|
||||
client_pubkey=client_info.id.get_public_key().hex(),
|
||||
sync_since=current_sync_since,
|
||||
pending_ack_crc=expected_ack_crc,
|
||||
push_post_timestamp=post["post_timestamp"],
|
||||
ack_timeout_time=time.time() + ack_timeout,
|
||||
last_activity=time.time(),
|
||||
)
|
||||
# Send packet (dispatcher will track ACK automatically)
|
||||
# This blocks for the entire transmission duration (0.5-9 seconds)
|
||||
success = await self.packet_injector(packet, wait_for_ack=True)
|
||||
success = await self.packet_injector(
|
||||
packet,
|
||||
wait_for_ack=True,
|
||||
expected_crc=expected_ack_crc,
|
||||
ack_timeout_s=ack_timeout,
|
||||
)
|
||||
|
||||
# SAFETY: Release transmission lock AFTER send completes
|
||||
self.global_limiter.release()
|
||||
@@ -460,7 +488,7 @@ class RoomServer:
|
||||
pending_ack_crc=0,
|
||||
)
|
||||
|
||||
if failures >= 3:
|
||||
if failures >= MAX_PUSH_FAILURES:
|
||||
logger.warning(
|
||||
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
|
||||
f"has {failures} consecutive failures"
|
||||
@@ -625,7 +653,7 @@ class RoomServer:
|
||||
)
|
||||
continue
|
||||
|
||||
if push_failures >= 3:
|
||||
if push_failures >= MAX_PUSH_FAILURES:
|
||||
logger.debug(
|
||||
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Text message (TXT_MSG) handling helper for pyMC Repeater.
|
||||
Text message (TXT_MSG) handling helper for openHop Repeater.
|
||||
|
||||
This module processes incoming text messages for all managed identities
|
||||
(repeater identity + identity manager identities).
|
||||
@@ -10,7 +10,8 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.text import TextMessageHandler
|
||||
from openhop_core.node.handlers.text import TextMessageHandler
|
||||
from openhop_core.protocol import CryptoUtils, Identity
|
||||
|
||||
from .mesh_cli import MeshCLI
|
||||
from .room_server import RoomServer
|
||||
@@ -29,8 +30,8 @@ class TextHelper:
|
||||
packet_injector=None,
|
||||
acl_dict=None,
|
||||
log_fn=None,
|
||||
config_path: str = None,
|
||||
config: dict = None,
|
||||
config_path: str | None = None,
|
||||
config: dict | None = None,
|
||||
config_manager=None,
|
||||
sqlite_handler=None,
|
||||
send_advert_callback=None,
|
||||
@@ -158,8 +159,8 @@ class TextHelper:
|
||||
packet_injector=self.packet_injector,
|
||||
acl=identity_acl,
|
||||
max_posts=max_posts,
|
||||
config_path=self.config_path,
|
||||
config=self.config,
|
||||
config_path=self.config_path or "",
|
||||
config=self.config or {},
|
||||
config_manager=self.config_manager,
|
||||
)
|
||||
|
||||
@@ -273,6 +274,7 @@ class TextHelper:
|
||||
# Extract decrypted message if available
|
||||
if hasattr(packet, "decrypted") and packet.decrypted:
|
||||
message_text = packet.decrypted.get("text", "<unknown>")
|
||||
sender_client = self._resolve_sender_client(dest_hash, src_hash, packet)
|
||||
|
||||
# Clean message text - remove null bytes and trailing whitespace
|
||||
message_text = message_text.rstrip("\x00").rstrip()
|
||||
@@ -290,7 +292,7 @@ class TextHelper:
|
||||
try:
|
||||
# Check admin permission
|
||||
is_admin = self._check_admin_permission_for_identity(
|
||||
src_hash, dest_hash
|
||||
sender_client, dest_hash
|
||||
)
|
||||
|
||||
if not is_admin:
|
||||
@@ -300,13 +302,9 @@ class TextHelper:
|
||||
return
|
||||
|
||||
# Get sender's full pubkey
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if identity_acl:
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
if sender_client is not None:
|
||||
sender_pubkey = sender_client.id.get_public_key()
|
||||
|
||||
# Handle CLI command
|
||||
reply = room_server.cli.handle_command(
|
||||
@@ -320,7 +318,12 @@ class TextHelper:
|
||||
# Send reply back to sender
|
||||
handler_info = self.handlers.get(dest_hash)
|
||||
if handler_info:
|
||||
await self._send_cli_reply(packet, reply, handler_info)
|
||||
await self._send_cli_reply(
|
||||
packet,
|
||||
reply,
|
||||
handler_info,
|
||||
sender_client=sender_client,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -333,13 +336,9 @@ class TextHelper:
|
||||
# NOT a CLI command - store as regular room post
|
||||
try:
|
||||
# Get sender's full pubkey
|
||||
identity_acl = self.acl_dict.get(dest_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if identity_acl:
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
if sender_client is not None:
|
||||
sender_pubkey = sender_client.id.get_public_key()
|
||||
|
||||
# Store message as post
|
||||
sender_timestamp = int(time.time())
|
||||
@@ -363,9 +362,13 @@ class TextHelper:
|
||||
# Check if this is a CLI command to the repeater (AFTER decryption)
|
||||
if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text):
|
||||
try:
|
||||
repeater_hash = self.repeater_hash
|
||||
if repeater_hash is None:
|
||||
return
|
||||
|
||||
# Check admin permission
|
||||
is_admin = self._check_admin_permission_for_identity(
|
||||
src_hash, self.repeater_hash
|
||||
sender_client, repeater_hash
|
||||
)
|
||||
|
||||
# If not admin, log and return without sending reply
|
||||
@@ -376,13 +379,9 @@ class TextHelper:
|
||||
return
|
||||
|
||||
# Get client for full public key
|
||||
repeater_acl = self.acl_dict.get(self.repeater_hash)
|
||||
sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default
|
||||
if repeater_acl:
|
||||
for client_info in repeater_acl.get_all_clients():
|
||||
if client_info.id.get_public_key()[0] == src_hash:
|
||||
sender_pubkey = client_info.id.get_public_key()
|
||||
break
|
||||
if sender_client is not None:
|
||||
sender_pubkey = sender_client.id.get_public_key()
|
||||
|
||||
# Handle CLI command
|
||||
reply = self.cli.handle_command(
|
||||
@@ -396,7 +395,12 @@ class TextHelper:
|
||||
# Send reply back to sender
|
||||
handler_info = self.handlers.get(dest_hash)
|
||||
if handler_info:
|
||||
await self._send_cli_reply(packet, reply, handler_info)
|
||||
await self._send_cli_reply(
|
||||
packet,
|
||||
reply,
|
||||
handler_info,
|
||||
sender_client=sender_client,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing CLI command: {e}", exc_info=True)
|
||||
@@ -453,6 +457,7 @@ class TextHelper:
|
||||
"advert",
|
||||
"clock",
|
||||
"time ",
|
||||
"http ",
|
||||
"password ",
|
||||
"clear ",
|
||||
"ver",
|
||||
@@ -473,20 +478,44 @@ class TextHelper:
|
||||
|
||||
def _check_admin_permission(self, src_hash: int) -> bool:
|
||||
"""Check if sender has admin permissions for repeater (legacy method)."""
|
||||
return self._check_admin_permission_for_identity(src_hash, self.repeater_hash)
|
||||
|
||||
def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool:
|
||||
"""Check if sender has admin permissions (bit 0x02) for a specific identity."""
|
||||
# Get the identity's ACL
|
||||
identity_acl = self.acl_dict.get(identity_hash)
|
||||
if not identity_acl:
|
||||
repeater_hash = self.repeater_hash
|
||||
if repeater_hash is None:
|
||||
return False
|
||||
|
||||
# Get client by hash byte
|
||||
clients = identity_acl.get_all_clients()
|
||||
for client_info in clients:
|
||||
identity_acl = self.acl_dict.get(repeater_hash)
|
||||
if not identity_acl:
|
||||
return False
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
return self._check_admin_permission_for_identity(client_info, repeater_hash)
|
||||
return False
|
||||
|
||||
def _check_admin_permission_for_identity(self, sender_client, identity_hash: int) -> bool:
|
||||
"""Check admin permissions for a specific identity.
|
||||
|
||||
Accepts either a resolved ACL client object (preferred) or a legacy
|
||||
``src_hash`` int for backwards-compatible helper/unit-test calls.
|
||||
"""
|
||||
# Get the identity's ACL
|
||||
identity_acl = self.acl_dict.get(identity_hash)
|
||||
if not identity_acl or sender_client is None:
|
||||
return False
|
||||
|
||||
# Legacy compatibility: caller provided src_hash int instead of client.
|
||||
if isinstance(sender_client, int):
|
||||
src_hash = sender_client & 0xFF
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
permissions = getattr(client_info, "permissions", 0)
|
||||
PERM_ACL_ADMIN = 0x02
|
||||
return (permissions & 0x02) == PERM_ACL_ADMIN
|
||||
return False
|
||||
|
||||
sender_pubkey = sender_client.id.get_public_key()
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
if client_info.id.get_public_key() == sender_pubkey:
|
||||
# Check admin bit (0x02 = PERM_ACL_ADMIN)
|
||||
permissions = getattr(client_info, "permissions", 0)
|
||||
PERM_ACL_ADMIN = 0x02
|
||||
@@ -494,7 +523,75 @@ class TextHelper:
|
||||
|
||||
return False
|
||||
|
||||
async def _send_cli_reply(self, original_packet, reply_text: str, handler_info: dict):
|
||||
def _get_shared_secret_for_client(self, client_info, identity) -> bytes:
|
||||
"""Return shared secret for a client, deriving it when ACL cache is absent."""
|
||||
shared_secret = getattr(client_info, "shared_secret", b"") or b""
|
||||
if shared_secret:
|
||||
return bytes(shared_secret)
|
||||
|
||||
if not identity:
|
||||
return b""
|
||||
|
||||
try:
|
||||
peer_pubkey = client_info.id.get_public_key()
|
||||
peer_identity = Identity(peer_pubkey)
|
||||
return peer_identity.calc_shared_secret(identity.get_private_key())
|
||||
except Exception:
|
||||
return b""
|
||||
|
||||
def _resolve_sender_client(
|
||||
self,
|
||||
identity_hash: int,
|
||||
src_hash: int,
|
||||
packet,
|
||||
local_identity=None,
|
||||
allow_hash_fallback: bool = False,
|
||||
):
|
||||
"""Resolve sender client by trying hash-collision candidates until decrypt succeeds."""
|
||||
identity_acl = self.acl_dict.get(identity_hash)
|
||||
if local_identity is None:
|
||||
handler_info = self.handlers.get(identity_hash)
|
||||
local_identity = handler_info.get("identity") if handler_info else None
|
||||
|
||||
if not identity_acl:
|
||||
return None
|
||||
|
||||
# Optional compatibility path for direct helper calls/tests where we don't
|
||||
# have decryptable payload bytes available; only accept a UNIQUE hash match.
|
||||
if allow_hash_fallback and (not local_identity or len(packet.payload) < 4):
|
||||
matches = [
|
||||
c for c in identity_acl.get_all_clients() if c.id.get_public_key()[0] == src_hash
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
if not local_identity or len(packet.payload) < 4:
|
||||
return None
|
||||
|
||||
encrypted_data = bytes(packet.payload[2:])
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] != src_hash:
|
||||
continue
|
||||
|
||||
shared_secret = self._get_shared_secret_for_client(client_info, local_identity)
|
||||
if len(shared_secret) < 16:
|
||||
continue
|
||||
|
||||
try:
|
||||
CryptoUtils.mac_then_decrypt(shared_secret[:16], shared_secret, encrypted_data)
|
||||
return client_info
|
||||
except Exception: # nosec B112
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def _send_cli_reply(
|
||||
self,
|
||||
original_packet,
|
||||
reply_text: str,
|
||||
handler_info: dict,
|
||||
sender_client=None,
|
||||
):
|
||||
"""
|
||||
Send CLI reply back to sender using TXT_MSG datagram.
|
||||
|
||||
@@ -507,8 +604,8 @@ class TextHelper:
|
||||
"""
|
||||
import time
|
||||
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
|
||||
|
||||
try:
|
||||
src_hash = original_packet.payload[1]
|
||||
@@ -526,12 +623,13 @@ class TextHelper:
|
||||
logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply")
|
||||
return
|
||||
|
||||
client = None
|
||||
for client_info in identity_acl.get_all_clients():
|
||||
pubkey = client_info.id.get_public_key()
|
||||
if pubkey[0] == src_hash:
|
||||
client = client_info
|
||||
break
|
||||
client = sender_client or self._resolve_sender_client(
|
||||
dest_hash,
|
||||
src_hash,
|
||||
original_packet,
|
||||
local_identity=handler_info.get("identity"),
|
||||
allow_hash_fallback=True,
|
||||
)
|
||||
|
||||
if not client:
|
||||
logger.error(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Trace packet handling helper for pyMC Repeater.
|
||||
Trace packet handling helper for openHop Repeater.
|
||||
|
||||
This module handles the processing and forwarding of trace packets,
|
||||
which are used for network diagnostics to track the path and SNR
|
||||
@@ -11,10 +11,10 @@ import logging
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pymc_core.hardware.signal_utils import snr_register_to_db
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
|
||||
from pymc_core.protocol.packet_utils import PathUtils
|
||||
from openhop_core.hardware.signal_utils import snr_register_to_db
|
||||
from openhop_core.node.handlers.trace import TraceHandler
|
||||
from openhop_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT
|
||||
from openhop_core.protocol.packet_utils import PathUtils
|
||||
|
||||
logger = logging.getLogger("TraceHelper")
|
||||
|
||||
|
||||
+27
-14
@@ -14,6 +14,11 @@ from typing import Optional, Tuple
|
||||
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
|
||||
|
||||
|
||||
def _has_disallowed_public_prefix(public_key: bytes) -> bool:
|
||||
"""Return True when public key starts with a forbidden prefix byte."""
|
||||
return bool(public_key) and public_key[0] in (0x00, 0xFF)
|
||||
|
||||
|
||||
def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
|
||||
"""Generate a MeshCore-compatible Ed25519 keypair.
|
||||
|
||||
@@ -21,25 +26,30 @@ def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
|
||||
(public_key, private_key) as raw bytes.
|
||||
public_key is 32 bytes, private_key is 64 bytes.
|
||||
"""
|
||||
# 1. Random 32-byte seed
|
||||
seed = secrets.token_bytes(32)
|
||||
while True:
|
||||
# 1. Random 32-byte seed
|
||||
seed = secrets.token_bytes(32)
|
||||
|
||||
# 2. SHA-512 hash
|
||||
digest = hashlib.sha512(seed).digest()
|
||||
# 2. SHA-512 hash
|
||||
digest = hashlib.sha512(seed).digest()
|
||||
|
||||
# 3. Ed25519 scalar clamping on first 32 bytes
|
||||
clamped = bytearray(digest[:32])
|
||||
clamped[0] &= 248 # Clear bottom 3 bits
|
||||
clamped[31] &= 63 # Clear top 2 bits
|
||||
clamped[31] |= 64 # Set bit 6
|
||||
# 3. Ed25519 scalar clamping on first 32 bytes
|
||||
clamped = bytearray(digest[:32])
|
||||
clamped[0] &= 248 # Clear bottom 3 bits
|
||||
clamped[31] &= 63 # Clear top 2 bits
|
||||
clamped[31] |= 64 # Set bit 6
|
||||
|
||||
# 4. Derive public key
|
||||
public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped))
|
||||
# 4. Derive public key
|
||||
public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped))
|
||||
|
||||
# 5. Private key = [clamped_scalar][sha512_upper_half]
|
||||
private_key = bytes(clamped) + digest[32:64]
|
||||
# Disallow keys with 0x00 / 0xFF leading byte.
|
||||
if _has_disallowed_public_prefix(public_key):
|
||||
continue
|
||||
|
||||
return public_key, private_key
|
||||
# 5. Private key = [clamped_scalar][sha512_upper_half]
|
||||
private_key = bytes(clamped) + digest[32:64]
|
||||
|
||||
return public_key, private_key
|
||||
|
||||
|
||||
def generate_vanity_key(
|
||||
@@ -57,6 +67,9 @@ def generate_vanity_key(
|
||||
"""
|
||||
target = prefix.upper()
|
||||
|
||||
if target.startswith("00") or target.startswith("FF"):
|
||||
return None
|
||||
|
||||
for attempt in range(1, max_iterations + 1):
|
||||
pub, priv = generate_meshcore_keypair()
|
||||
if pub.hex().upper().startswith(target):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
CLI client for pyMC Repeater.
|
||||
CLI client for openHop Repeater.
|
||||
Connects to an already-running repeater daemon via its HTTP API.
|
||||
Reads admin password and HTTP port from the local config.yaml automatically.
|
||||
"""
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
CONFIG_PATHS = [
|
||||
"/etc/pymc_repeater/config.yaml",
|
||||
"/etc/openhop_repeater/config.yaml",
|
||||
"config.yaml",
|
||||
]
|
||||
|
||||
@@ -79,7 +79,7 @@ def run_client_cli(host: str = "127.0.0.1", port: int = 8000, password: Optional
|
||||
print("Error: Authentication failed. Check password or repeater status.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\npyMC Repeater CLI (connected to {base_url})")
|
||||
print(f"\nopenHop Repeater CLI (connected to {base_url})")
|
||||
print("Type 'help' for available commands, 'exit' to quit.\n")
|
||||
|
||||
while True:
|
||||
@@ -123,7 +123,7 @@ def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Connect to a running pyMC Repeater and issue CLI commands"
|
||||
description="Connect to a running openHop Repeater and issue CLI commands"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
|
||||
+61
-29
@@ -175,11 +175,15 @@ class RepeaterDaemon:
|
||||
self.radio = NullRadio()
|
||||
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
from pymc_core.node.dispatcher import Dispatcher
|
||||
from openhop_core import LocalIdentity
|
||||
from openhop_core.node.dispatcher import Dispatcher
|
||||
|
||||
self.dispatcher = Dispatcher(self.radio)
|
||||
dedupe_enabled = bool(
|
||||
self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False)
|
||||
)
|
||||
self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled)
|
||||
logger.info("Dispatcher initialized")
|
||||
logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled)
|
||||
|
||||
# Initialize Identity Manager for additional identities (e.g., room servers)
|
||||
self.identity_manager = IdentityManager(self.config)
|
||||
@@ -307,7 +311,7 @@ class RepeaterDaemon:
|
||||
|
||||
# Initialize ConfigManager for centralized config management
|
||||
self.config_manager = ConfigManager(
|
||||
config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"),
|
||||
config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"),
|
||||
config=self.config,
|
||||
daemon_instance=self,
|
||||
)
|
||||
@@ -373,6 +377,11 @@ class RepeaterDaemon:
|
||||
self.path_helper = PathHelper(
|
||||
acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs
|
||||
log_fn=logger.info,
|
||||
ack_received_callback=(
|
||||
self.dispatcher._register_ack_received
|
||||
if self.dispatcher and hasattr(self.dispatcher, "_register_ack_received")
|
||||
else None
|
||||
),
|
||||
)
|
||||
logger.info("PATH packet processing helper initialized")
|
||||
|
||||
@@ -395,7 +404,7 @@ class RepeaterDaemon:
|
||||
# Load companion identities (CompanionBridge + frame server per companion)
|
||||
await self._load_companion_identities()
|
||||
|
||||
# Subscribe to raw RX in pyMC_core so we can push PUSH_CODE_LOG_RX_DATA to companion clients
|
||||
# Subscribe to raw RX in openhop-core so we can push PUSH_CODE_LOG_RX_DATA to companion clients
|
||||
self.dispatcher.add_raw_rx_subscriber(self._on_raw_rx_for_companions)
|
||||
n = len(getattr(self, "companion_frame_servers", []))
|
||||
logger.info(
|
||||
@@ -403,9 +412,7 @@ class RepeaterDaemon:
|
||||
n,
|
||||
)
|
||||
|
||||
# Subscribe to parsed packets (pre-dedup) so duplicate path variants
|
||||
# still appear in the web UI even though the Dispatcher blocks them.
|
||||
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
|
||||
self._register_duplicate_logging_hook(dedupe_enabled)
|
||||
|
||||
# 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
|
||||
@@ -431,7 +438,7 @@ class RepeaterDaemon:
|
||||
raise
|
||||
|
||||
async def _load_additional_identities(self):
|
||||
from pymc_core import LocalIdentity
|
||||
from openhop_core import LocalIdentity
|
||||
|
||||
identities_config = self.config.get("identities", {})
|
||||
|
||||
@@ -493,8 +500,8 @@ class RepeaterDaemon:
|
||||
|
||||
async def _load_companion_identities(self) -> None:
|
||||
"""Load companion identities from config and create CompanionBridge + frame server for each."""
|
||||
from pymc_core import LocalIdentity
|
||||
from pymc_core.companion.models import Channel
|
||||
from openhop_core import LocalIdentity
|
||||
from openhop_core.companion.models import Channel
|
||||
|
||||
from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge
|
||||
|
||||
@@ -651,7 +658,7 @@ class RepeaterDaemon:
|
||||
for msg_dict in sqlite_handler.companion_load_messages(
|
||||
companion_hash_str, limit=retention or 100
|
||||
):
|
||||
from pymc_core.companion.models import QueuedMessage
|
||||
from openhop_core.companion.models import QueuedMessage
|
||||
|
||||
sk = msg_dict.get("sender_key", b"")
|
||||
if isinstance(sk, str):
|
||||
@@ -717,8 +724,8 @@ class RepeaterDaemon:
|
||||
Creates RepeaterCompanionBridge, CompanionFrameServer, starts the server,
|
||||
and registers with identity_manager. Raises on error.
|
||||
"""
|
||||
from pymc_core import LocalIdentity
|
||||
from pymc_core.companion.models import Channel
|
||||
from openhop_core import LocalIdentity
|
||||
from openhop_core.companion.models import Channel
|
||||
|
||||
from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge
|
||||
from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET
|
||||
@@ -837,7 +844,7 @@ class RepeaterDaemon:
|
||||
for msg_dict in sqlite_handler.companion_load_messages(
|
||||
companion_hash_str, limit=retention or 100
|
||||
):
|
||||
from pymc_core.companion.models import QueuedMessage
|
||||
from openhop_core.companion.models import QueuedMessage
|
||||
|
||||
sk = msg_dict.get("sender_key", b"")
|
||||
if isinstance(sk, str):
|
||||
@@ -909,6 +916,14 @@ class RepeaterDaemon:
|
||||
except Exception as e:
|
||||
logger.debug("Push RX raw to companion: %s", e)
|
||||
|
||||
def _register_duplicate_logging_hook(self, dedupe_enabled: bool) -> None:
|
||||
"""Register pre-dedup duplicate logging only when dispatcher dedupe is active."""
|
||||
if not self.dispatcher or not dedupe_enabled:
|
||||
return
|
||||
# When dispatcher dedupe is disabled, duplicates still flow through
|
||||
# router -> repeater_handler and are already recorded there.
|
||||
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
|
||||
|
||||
def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None:
|
||||
"""Record duplicate packets for UI visibility.
|
||||
|
||||
@@ -1139,8 +1154,11 @@ class RepeaterDaemon:
|
||||
return False
|
||||
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_REPEATER,
|
||||
)
|
||||
|
||||
# Get node name and location from config
|
||||
repeater_config = self.config.get("repeater", {})
|
||||
@@ -1332,7 +1350,7 @@ class RepeaterDaemon:
|
||||
radio_type_raw = self.config.get("radio_type")
|
||||
radio_type = "" if radio_type_raw is None else str(radio_type_raw).lower()
|
||||
if radio_type == "sx1262_ch341":
|
||||
from pymc_core.hardware.ch341.ch341_async import CH341Async
|
||||
from openhop_core.hardware.ch341.ch341_async import CH341Async
|
||||
|
||||
CH341Async.reset_instance()
|
||||
except Exception as e:
|
||||
@@ -1375,8 +1393,19 @@ class RepeaterDaemon:
|
||||
await self.initialize()
|
||||
|
||||
# Start HTTP stats server
|
||||
http_port = self.config.get("http", {}).get("port", 8000)
|
||||
http_host = self.config.get("http", {}).get("host", "0.0.0.0") # nosec B104
|
||||
http_config = self.config.get("http", {})
|
||||
http_port = http_config.get("port", 8000)
|
||||
http_host = http_config.get("host", "0.0.0.0") # nosec B104
|
||||
http_enabled_raw = http_config.get("enabled", True)
|
||||
if isinstance(http_enabled_raw, str):
|
||||
http_enabled = http_enabled_raw.strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
else:
|
||||
http_enabled = bool(http_enabled_raw)
|
||||
|
||||
node_name = self.config.get("repeater", {}).get("node_name", "Repeater")
|
||||
|
||||
@@ -1402,15 +1431,18 @@ class RepeaterDaemon:
|
||||
config=self.config,
|
||||
event_loop=current_loop,
|
||||
daemon_instance=self,
|
||||
config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"),
|
||||
config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"),
|
||||
)
|
||||
|
||||
try:
|
||||
self.http_server.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start HTTP server: {e}")
|
||||
if http_enabled:
|
||||
try:
|
||||
self.http_server.start()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start HTTP server: {e}")
|
||||
else:
|
||||
logger.info("HTTP server startup skipped (http.enabled=false)")
|
||||
|
||||
# Run dispatcher (handles RX/TX via pymc_core)
|
||||
# Run dispatcher (handles RX/TX via openhop_core)
|
||||
try:
|
||||
await self.dispatcher.run_forever()
|
||||
except asyncio.CancelledError:
|
||||
@@ -1441,10 +1473,10 @@ def main():
|
||||
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="pyMC Repeater Daemon")
|
||||
parser = argparse.ArgumentParser(description="openHop Repeater Daemon")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
help="Path to config file (default: /etc/pymc_repeater/config.yaml)",
|
||||
help="Path to config file (default: /etc/openhop_repeater/config.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
@@ -1456,7 +1488,7 @@ def main():
|
||||
|
||||
# Load configuration
|
||||
config = load_config(args.config)
|
||||
config_path = args.config if args.config else "/etc/pymc_repeater/config.yaml"
|
||||
config_path = args.config if args.config else "/etc/openhop_repeater/config.yaml"
|
||||
|
||||
if args.log_level:
|
||||
if "logging" not in config:
|
||||
|
||||
+66
-19
@@ -2,18 +2,19 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from pymc_core.node.handlers.ack import AckHandler
|
||||
from pymc_core.node.handlers.advert import AdvertHandler
|
||||
from pymc_core.node.handlers.control import ControlHandler
|
||||
from pymc_core.node.handlers.group_text import GroupTextHandler
|
||||
from pymc_core.node.handlers.login_response import LoginResponseHandler
|
||||
from pymc_core.node.handlers.login_server import LoginServerHandler
|
||||
from pymc_core.node.handlers.path import PathHandler
|
||||
from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler
|
||||
from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler
|
||||
from pymc_core.node.handlers.text import TextMessageHandler
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
from pymc_core.protocol.constants import (
|
||||
from openhop_core.node.handlers.ack import AckHandler
|
||||
from openhop_core.node.handlers.advert import AdvertHandler
|
||||
from openhop_core.node.handlers.control import ControlHandler
|
||||
from openhop_core.node.handlers.group_text import GroupTextHandler
|
||||
from openhop_core.node.handlers.login_response import LoginResponseHandler
|
||||
from openhop_core.node.handlers.login_server import LoginServerHandler
|
||||
from openhop_core.node.handlers.multipart import MultipartAckHandler
|
||||
from openhop_core.node.handlers.path import PathHandler
|
||||
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
|
||||
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
|
||||
from openhop_core.node.handlers.text import TextMessageHandler
|
||||
from openhop_core.node.handlers.trace import TraceHandler
|
||||
from openhop_core.protocol.constants import (
|
||||
PH_ROUTE_MASK,
|
||||
ROUTE_TYPE_DIRECT,
|
||||
ROUTE_TYPE_TRANSPORT_DIRECT,
|
||||
@@ -261,6 +262,16 @@ class PacketRouter:
|
||||
except Exception as e:
|
||||
logger.debug("Record for UI failed: %s", e)
|
||||
|
||||
async def _register_ack_with_dispatcher(self, ack_crc: int, context: str) -> None:
|
||||
"""Best-effort ACK CRC registration with the dispatcher waiter path."""
|
||||
dispatcher = getattr(self.daemon, "dispatcher", None)
|
||||
if dispatcher is None or not hasattr(dispatcher, "_register_ack_received"):
|
||||
return
|
||||
try:
|
||||
await dispatcher._register_ack_received(ack_crc)
|
||||
except Exception as e:
|
||||
logger.debug("Dispatcher %s registration error: %s", context, e)
|
||||
|
||||
async def enqueue(self, packet):
|
||||
"""Add packet to router queue."""
|
||||
if self.queue.full():
|
||||
@@ -271,7 +282,14 @@ class PacketRouter:
|
||||
pass
|
||||
await self.queue.put(packet)
|
||||
|
||||
async def inject_packet(self, packet, wait_for_ack: bool = False, origin_hash=None):
|
||||
async def inject_packet(
|
||||
self,
|
||||
packet,
|
||||
wait_for_ack: bool = False,
|
||||
expected_crc=None,
|
||||
origin_hash=None,
|
||||
ack_timeout_s: float = 5.0,
|
||||
):
|
||||
try:
|
||||
metadata = {
|
||||
"rssi": getattr(packet, "rssi", 0),
|
||||
@@ -327,11 +345,20 @@ class PacketRouter:
|
||||
dispatcher = getattr(self.daemon, "dispatcher", None)
|
||||
if dispatcher and hasattr(dispatcher, "wait_for_ack"):
|
||||
try:
|
||||
expected_crc = packet.get_crc()
|
||||
ack_ok = await dispatcher.wait_for_ack(expected_crc, timeout=5.0)
|
||||
wait_crc = (
|
||||
expected_crc if expected_crc is not None else packet.get_crc()
|
||||
)
|
||||
wait_timeout = (
|
||||
float(ack_timeout_s)
|
||||
if isinstance(ack_timeout_s, (int, float)) and ack_timeout_s > 0
|
||||
else 5.0
|
||||
)
|
||||
ack_ok = await dispatcher.wait_for_ack(wait_crc, timeout=wait_timeout)
|
||||
if not ack_ok:
|
||||
logger.warning(
|
||||
"Injected packet ACK timeout (crc=%08X)", expected_crc
|
||||
"Injected packet ACK timeout (crc=%08X, timeout=%.1fs)",
|
||||
wait_crc,
|
||||
wait_timeout,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -463,6 +490,10 @@ class PacketRouter:
|
||||
self._record_for_ui(packet, metadata)
|
||||
|
||||
elif payload_type == AckHandler.payload_type():
|
||||
# Ensure ACK CRC reaches dispatcher waiter path even when only router fallback is active.
|
||||
if len(getattr(packet, "payload", b"")) >= 4:
|
||||
ack_crc = int.from_bytes(packet.payload[:4], "little")
|
||||
await self._register_ack_with_dispatcher(ack_crc, "ACK")
|
||||
# ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed.
|
||||
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
|
||||
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
|
||||
@@ -472,6 +503,15 @@ class PacketRouter:
|
||||
except Exception as e:
|
||||
logger.debug(f"Companion bridge ACK error: {e}")
|
||||
|
||||
elif payload_type == MultipartAckHandler.payload_type():
|
||||
# MULTIPART ACK wrapper: low nibble of first byte is embedded payload type.
|
||||
if (
|
||||
len(getattr(packet, "payload", b"")) >= 5
|
||||
and (packet.payload[0] & 0x0F) == AckHandler.payload_type()
|
||||
):
|
||||
ack_crc = int.from_bytes(packet.payload[1:5], "little")
|
||||
await self._register_ack_with_dispatcher(ack_crc, "multi-ACK")
|
||||
|
||||
elif payload_type == TextMessageHandler.payload_type():
|
||||
dest_hash = packet.payload[0] if packet.payload else None
|
||||
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
|
||||
@@ -486,6 +526,13 @@ class PacketRouter:
|
||||
self._record_for_ui(packet, metadata)
|
||||
|
||||
elif payload_type == PathHandler.payload_type():
|
||||
# Always let PathHelper inspect/decrypt PATH first so out_path and bundled ACK state
|
||||
# are updated even when companion routing fan-out also happens for this packet.
|
||||
if self.daemon.path_helper:
|
||||
try:
|
||||
await self.daemon.path_helper.process_path_packet(packet)
|
||||
except Exception as e:
|
||||
logger.debug(f"Path helper processing error: {e}")
|
||||
dest_hash = packet.payload[0] if packet.payload else None
|
||||
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
|
||||
if dest_hash is not None and dest_hash in companion_bridges:
|
||||
@@ -506,8 +553,6 @@ class PacketRouter:
|
||||
len(companion_bridges),
|
||||
)
|
||||
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
|
||||
elif self.daemon.path_helper:
|
||||
await self.daemon.path_helper.process_path_packet(packet)
|
||||
|
||||
elif payload_type == LoginResponseHandler.payload_type():
|
||||
# PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted.
|
||||
@@ -620,7 +665,9 @@ class PacketRouter:
|
||||
if self.daemon.repeater_handler and not processed_by_injection:
|
||||
sent = await self.daemon.repeater_handler(packet, metadata)
|
||||
if sent is False:
|
||||
drop_reason = getattr(packet, "_repeater_drop_reason", None)
|
||||
drop_reason = metadata.get("_repeater_drop_reason")
|
||||
if not isinstance(drop_reason, str):
|
||||
drop_reason = getattr(packet, "_repeater_drop_reason", None)
|
||||
if not isinstance(drop_reason, str):
|
||||
drop_reason = _drop_reason_from_recent_packets(
|
||||
self.daemon.repeater_handler, packet
|
||||
|
||||
@@ -5,8 +5,8 @@ import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from pymc_core.protocol.constants import PAYLOAD_TYPE_GRP_DATA, PAYLOAD_TYPE_GRP_TXT
|
||||
from pymc_core.protocol.crypto import CryptoUtils
|
||||
from openhop_core.protocol.constants import PAYLOAD_TYPE_GRP_DATA, PAYLOAD_TYPE_GRP_TXT
|
||||
from openhop_core.protocol.crypto import CryptoUtils
|
||||
|
||||
logger = logging.getLogger("PolicyEngine")
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Meshat.se MC2MQTT broker preset.
|
||||
#
|
||||
# Meshat.se is a Swedish community MeshCore visualization and analytics
|
||||
# platform. Their single ingest broker speaks the standard MeshCoreToMQTT
|
||||
# (MC2MQTT) protocol with the LetsMesh-compatible topic structure
|
||||
# (meshcore/{IATA}/{PUBLIC_KEY}/...).
|
||||
#
|
||||
# Reference all Meshat.se endpoints with: brokers: [{preset: meshat-se}]
|
||||
#
|
||||
# Optional UI metadata. Consumed by the GET /api/broker_presets endpoint
|
||||
# so the React/Vue admin can render this preset in the "From Template"
|
||||
# dropdown. `display_name` and `website` are advisory only -- they are
|
||||
# never read by the runtime broker connection code.
|
||||
display_name: "Meshat.se"
|
||||
website: "https://meshat.se"
|
||||
brokers:
|
||||
- name: "Meshat.se"
|
||||
enabled: true
|
||||
host: meshcore-mqtt.meshat.se
|
||||
port: 443
|
||||
transport: "websockets"
|
||||
audience: "meshcore-mqtt.meshat.se"
|
||||
use_jwt_auth: true
|
||||
format: letsmesh
|
||||
retain_status: false
|
||||
tls:
|
||||
enabled: true
|
||||
insecure: false
|
||||
@@ -0,0 +1,44 @@
|
||||
# MeshCore Canada MC2MQTT broker preset.
|
||||
#
|
||||
# MeshCore Canada is the operator of the public MeshCore Packet Analyzer
|
||||
# infrastructure. These brokers speak the MeshCoreToMQTT (MC2MQTT)
|
||||
# protocol with the "letsmesh" format flavor, which today shares the
|
||||
# canonical MC2MQTT topic structure (meshcore/{IATA}/{PUBLIC_KEY}/...).
|
||||
#
|
||||
# Reference all MeshCore Canada endpoints with: brokers: [{preset: letsmesh}]
|
||||
#
|
||||
# Note: order matters for backward compatibility with the legacy
|
||||
# letsmesh.broker_index field. Index 0 is Europe, index 1 is US West.
|
||||
#
|
||||
# Optional UI metadata. Consumed by the GET /api/broker_presets endpoint
|
||||
# so the React/Vue admin can render this preset in the "From Template"
|
||||
# dropdown. `display_name` and `website` are advisory only — they are
|
||||
# never read by the runtime broker connection code.
|
||||
display_name: "MeshCore.CA"
|
||||
website: "https://meshcore.ca"
|
||||
brokers:
|
||||
- name: "MeshCore.CA Primary"
|
||||
enabled: true
|
||||
host: mqtt1.meshcore.ca
|
||||
port: 443
|
||||
transport: "websockets"
|
||||
audience: "mqtt1.meshcore.ca"
|
||||
use_jwt_auth: true
|
||||
format: letsmesh
|
||||
retain_status: false
|
||||
tls:
|
||||
enabled: true
|
||||
insecure: false
|
||||
|
||||
- name: "MeshCore.CA Backup"
|
||||
enabled: true
|
||||
host: mqtt2.meshcore.ca
|
||||
port: 443
|
||||
transport: "websockets"
|
||||
audience: "mqtt2.meshcore.ca"
|
||||
use_jwt_auth: true
|
||||
format: letsmesh
|
||||
retain_status: false
|
||||
tls:
|
||||
enabled: true
|
||||
insecure: false
|
||||
+48
-18
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .registry import SensorRegistry
|
||||
@@ -112,22 +113,32 @@ class SensorManager:
|
||||
def read_all(self) -> List[Dict[str, Any]]:
|
||||
readings: List[Dict[str, Any]] = []
|
||||
for sensor in self.sensors:
|
||||
try:
|
||||
readings.append(sensor.read())
|
||||
except Exception as exc:
|
||||
self.log.warning("Sensor manager caught read error for %s: %s", sensor.name, exc)
|
||||
readings.append(
|
||||
{
|
||||
"name": sensor.name,
|
||||
"type": getattr(sensor, "sensor_type", "sensor"),
|
||||
"ok": False,
|
||||
"timestamp": None,
|
||||
"data": {},
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
)
|
||||
readings.append(self._read_sensor(sensor))
|
||||
return readings
|
||||
|
||||
def _read_sensor(self, sensor) -> Dict[str, Any]:
|
||||
try:
|
||||
return sensor.read()
|
||||
except Exception as exc:
|
||||
self.log.warning("Sensor manager caught read error for %s: %s", sensor.name, exc)
|
||||
return {
|
||||
"name": sensor.name,
|
||||
"type": getattr(sensor, "sensor_type", "sensor"),
|
||||
"ok": False,
|
||||
"timestamp": None,
|
||||
"data": {},
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sensor_poll_interval(sensor, default_interval: float) -> float:
|
||||
raw = getattr(sensor, "poll_interval_seconds", default_interval)
|
||||
try:
|
||||
interval = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
interval = default_interval
|
||||
return max(0.1, interval)
|
||||
|
||||
def _poll_loop(self) -> None:
|
||||
"""Background thread: poll sensors at configured interval and cache readings."""
|
||||
section = self.config.get("sensors", {})
|
||||
@@ -140,16 +151,35 @@ class SensorManager:
|
||||
|
||||
self.log.debug("Sensor polling loop started (interval=%.1f sec)", poll_interval)
|
||||
|
||||
next_read_at: Dict[str, float] = {}
|
||||
latest_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
now = time.monotonic()
|
||||
next_due_in = poll_interval
|
||||
try:
|
||||
readings = self.read_all()
|
||||
with self._readings_lock:
|
||||
self._latest_readings = readings
|
||||
updated = False
|
||||
for sensor in self.sensors:
|
||||
name = sensor.name
|
||||
interval = self._sensor_poll_interval(sensor, poll_interval)
|
||||
due_at = next_read_at.get(name, 0.0)
|
||||
if now >= due_at:
|
||||
latest_by_name[name] = self._read_sensor(sensor)
|
||||
next_read_at[name] = now + interval
|
||||
updated = True
|
||||
next_due_in = min(next_due_in, max(0.0, next_read_at[name] - now))
|
||||
|
||||
if updated:
|
||||
readings = [
|
||||
latest_by_name[s.name] for s in self.sensors if s.name in latest_by_name
|
||||
]
|
||||
with self._readings_lock:
|
||||
self._latest_readings = readings
|
||||
except Exception as exc:
|
||||
self.log.warning("Sensor poll cycle failed: %s", exc)
|
||||
|
||||
# Wait for next poll or stop signal
|
||||
self._stop_event.wait(poll_interval)
|
||||
self._stop_event.wait(max(0.1, next_due_in))
|
||||
|
||||
self.log.debug("Sensor polling loop stopped")
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from .base import SensorBase
|
||||
from .registry import SensorRegistry
|
||||
|
||||
|
||||
def _single_cell_voltage_to_percent(voltage_v: float) -> int:
|
||||
"""Piecewise linear SoC estimate for a single Li-ion/LiPo cell."""
|
||||
if voltage_v >= 4.20:
|
||||
return 100
|
||||
if voltage_v >= 4.00:
|
||||
return int(85 + (voltage_v - 4.00) / 0.20 * 15)
|
||||
if voltage_v >= 3.80:
|
||||
return int(60 + (voltage_v - 3.80) / 0.20 * 25)
|
||||
if voltage_v >= 3.70:
|
||||
return int(40 + (voltage_v - 3.70) / 0.10 * 20)
|
||||
if voltage_v >= 3.50:
|
||||
return int(15 + (voltage_v - 3.50) / 0.20 * 25)
|
||||
if voltage_v >= 3.00:
|
||||
return int((voltage_v - 3.00) / 0.50 * 15)
|
||||
return 0
|
||||
|
||||
|
||||
@SensorRegistry.register("pymc_modem")
|
||||
class PymcModemSensor(SensorBase):
|
||||
"""Read diagnostics exposed by a pyMC modem HTTP API."""
|
||||
|
||||
sensor_type = "pymc_modem"
|
||||
|
||||
def __init__(self, name: str, config: Optional[Dict[str, Any]] = None, log=None):
|
||||
super().__init__(name=name, config=config, log=log)
|
||||
self.poll_interval_seconds = float(self.settings.get("poll_interval_seconds", 60.0))
|
||||
self.timeout_seconds = float(self.settings.get("timeout_seconds", 2.0))
|
||||
self.endpoint = str(self.settings.get("endpoint", "/api/stats") or "/api/stats")
|
||||
self.url = self._build_url()
|
||||
self.username = str(self.settings.get("username", "admin") or "admin")
|
||||
self.password = self.settings.get("password")
|
||||
|
||||
def _build_url(self) -> str:
|
||||
base_url = self.settings.get("base_url")
|
||||
if base_url:
|
||||
base = str(base_url).rstrip("/") + "/"
|
||||
return self._validate_url(urljoin(base, self.endpoint.lstrip("/")))
|
||||
|
||||
host = str(self.settings.get("host", "") or "").strip()
|
||||
if not host:
|
||||
raise ValueError("pymc_modem requires settings.host or settings.base_url")
|
||||
scheme = str(self.settings.get("scheme", "http") or "http").lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
raise ValueError("pymc_modem scheme must be http or https")
|
||||
port = self.settings.get("port")
|
||||
netloc = host
|
||||
if port not in (None, ""):
|
||||
netloc = f"{host}:{int(port)}"
|
||||
return self._validate_url(
|
||||
f"{scheme}://{netloc}{self.endpoint if self.endpoint.startswith('/') else '/' + self.endpoint}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("pymc_modem URL scheme must be http or https")
|
||||
if not parsed.netloc:
|
||||
raise ValueError("pymc_modem URL must include a host")
|
||||
return url
|
||||
|
||||
def _read(self) -> Dict[str, Any]:
|
||||
request = urllib.request.Request(self.url, headers={"Accept": "application/json"})
|
||||
if self.password not in (None, ""):
|
||||
raw = f"{self.username}:{self.password}".encode("utf-8")
|
||||
request.add_header("Authorization", "Basic " + base64.b64encode(raw).decode("ascii"))
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: # nosec B310
|
||||
status = int(getattr(response, "status", 200) or 200)
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise RuntimeError(f"pyMC modem HTTP {exc.code} reading {self.url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"pyMC modem request failed: {exc.reason}") from exc
|
||||
|
||||
if status < 200 or status >= 300:
|
||||
raise RuntimeError(f"pyMC modem HTTP {status} reading {self.url}")
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise RuntimeError("pyMC modem response was not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("pyMC modem response was not a JSON object")
|
||||
|
||||
return self._normalize_payload(payload)
|
||||
|
||||
def _normalize_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw_gps = payload.get("gps")
|
||||
gps: Dict[str, Any] = raw_gps if isinstance(raw_gps, dict) else {}
|
||||
position = self._first_dict(
|
||||
gps.get("position"),
|
||||
gps.get("gps_position"),
|
||||
gps.get("location"),
|
||||
payload.get("gps_position"),
|
||||
payload.get("position"),
|
||||
payload,
|
||||
)
|
||||
fix = self._first_dict(gps.get("fix"), payload.get("fix"))
|
||||
satellites = self._first_dict(gps.get("satellites"), payload.get("satellites"))
|
||||
time_data = self._first_dict(
|
||||
gps.get("time"), gps.get("time_data"), payload.get("time_data")
|
||||
)
|
||||
motion = self._first_dict(gps.get("motion"), payload.get("motion"))
|
||||
|
||||
out: Dict[str, Any] = {
|
||||
"source": "pymc_modem",
|
||||
"url": self.url,
|
||||
"gps_enabled": self._bool_or_none(gps.get("enabled")),
|
||||
"gps_seen": self._bool_or_none(gps.get("seen")),
|
||||
"latitude": self._float(position.get("latitude")),
|
||||
"longitude": self._float(position.get("longitude")),
|
||||
"altitude_m": self._float(position.get("altitude_m")),
|
||||
"fix_valid": self._bool_or_none(fix.get("valid")),
|
||||
"fix_quality": self._int(fix.get("quality")),
|
||||
"satellites_used": self._int(
|
||||
satellites.get("used_count", satellites.get("satellites_used"))
|
||||
),
|
||||
"satellites_in_view": self._int(
|
||||
satellites.get("in_view_count", satellites.get("satellites_in_view"))
|
||||
),
|
||||
"datetime_utc": time_data.get("datetime_utc") or payload.get("datetime_utc"),
|
||||
"speed_kmh": self._float(motion.get("speed_kmh", payload.get("speed_kmh"))),
|
||||
"course_degrees": self._float(
|
||||
motion.get("course_degrees", payload.get("course_degrees"))
|
||||
),
|
||||
}
|
||||
|
||||
for key in (
|
||||
"battery_voltage_mv",
|
||||
"battery_voltage_v",
|
||||
"battery_percent",
|
||||
"battery_percentage",
|
||||
"solar_charge_rate_percent_per_hour",
|
||||
):
|
||||
if key in payload:
|
||||
out[key] = payload[key]
|
||||
|
||||
if "battery_percent" not in out:
|
||||
battery_voltage_v = self._battery_voltage_v(payload)
|
||||
if battery_voltage_v is not None:
|
||||
out["battery_percent"] = _single_cell_voltage_to_percent(battery_voltage_v)
|
||||
|
||||
return {key: value for key, value in out.items() if value is not None}
|
||||
|
||||
def _battery_voltage_v(self, payload: Dict[str, Any]) -> Optional[float]:
|
||||
voltage_v = self._float(payload.get("battery_voltage_v"))
|
||||
if voltage_v is not None:
|
||||
return voltage_v
|
||||
voltage_mv = self._float(payload.get("battery_voltage_mv"))
|
||||
if voltage_mv is not None:
|
||||
return voltage_mv / 1000.0
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _first_dict(*values: Any) -> Dict[str, Any]:
|
||||
for value in values:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _float(value: Any) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _int(value: Any) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _bool_or_none(value: Any) -> Optional[bool]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if lowered in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(value)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Service management utilities for pyMC Repeater.
|
||||
Service management utilities for openHop Repeater.
|
||||
Provides functions for service control operations like restart.
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,7 @@ import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("ServiceUtils")
|
||||
INIT_SCRIPT = "/etc/init.d/S80pymc-repeater"
|
||||
INIT_SCRIPT = "/etc/init.d/S80openhop-repeater"
|
||||
BUILDROOT_METADATA_PATH = "/etc/pymc-image-build-id"
|
||||
_CONTAINER_RESTART_DELAY_SECONDS = 1.0
|
||||
_SH_BIN = shutil.which("sh") or "sh"
|
||||
@@ -91,14 +91,14 @@ def get_container_restart_message() -> str:
|
||||
"""Return the user-facing restart message for containerized installs."""
|
||||
return (
|
||||
"Container restart initiated. "
|
||||
"If you are running pyMC Repeater via Docker or Home Assistant, pull or rebuild "
|
||||
"If you are running openHop Repeater via Docker or Home Assistant, pull or rebuild "
|
||||
"a newer image for packaged image updates to take effect."
|
||||
)
|
||||
|
||||
|
||||
def restart_service() -> Tuple[bool, str]:
|
||||
"""
|
||||
Restart the pymc-repeater service.
|
||||
Restart the openhop-repeater service.
|
||||
|
||||
On Buildroot/Luckfox, use the shipped init script directly.
|
||||
On systemd hosts, try polkit-based restart first (plain systemctl), then
|
||||
@@ -135,7 +135,7 @@ def restart_service() -> Tuple[bool, str]:
|
||||
# Try polkit-based restart first (works on bare metal / VMs with polkit running)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_SYSTEMCTL_BIN, "restart", "pymc-repeater"],
|
||||
[_SYSTEMCTL_BIN, "restart", "openhop-repeater"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
@@ -162,10 +162,10 @@ def restart_service() -> Tuple[bool, str]:
|
||||
except Exception as e:
|
||||
logger.warning(f"Polkit restart attempt failed: {e}")
|
||||
|
||||
# Fallback: use sudo (requires /etc/sudoers.d/pymc-repeater rule)
|
||||
# Fallback: use sudo (requires /etc/sudoers.d/openhop-repeater rule)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[_SUDO_BIN, "--non-interactive", _SYSTEMCTL_BIN, "restart", "pymc-repeater"],
|
||||
[_SUDO_BIN, "--non-interactive", _SYSTEMCTL_BIN, "restart", "openhop-repeater"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
@@ -188,3 +188,57 @@ def restart_service() -> Tuple[bool, str]:
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing sudo restart: {e}")
|
||||
return False, f"Restart command failed: {str(e)}"
|
||||
|
||||
|
||||
def _is_cherrypy_engine_running() -> Optional[bool]:
|
||||
"""Return CherryPy engine running state when available."""
|
||||
try:
|
||||
import cherrypy
|
||||
from cherrypy.process import wspbus
|
||||
|
||||
state = cherrypy.engine.state
|
||||
return state in (wspbus.states.STARTING, wspbus.states.STARTED)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def stop_http_server(daemon_instance) -> Tuple[bool, str]:
|
||||
"""Stop the in-process HTTP stats server."""
|
||||
if not daemon_instance:
|
||||
return False, "Daemon instance not available"
|
||||
|
||||
http_server = getattr(daemon_instance, "http_server", None)
|
||||
if not http_server:
|
||||
return False, "HTTP server not initialized"
|
||||
|
||||
running = _is_cherrypy_engine_running()
|
||||
if running is False:
|
||||
return True, "HTTP server already stopped"
|
||||
|
||||
try:
|
||||
http_server.stop()
|
||||
return True, "HTTP server stopped"
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to stop HTTP server: {exc}", exc_info=True)
|
||||
return False, f"Failed to stop HTTP server: {exc}"
|
||||
|
||||
|
||||
def start_http_server(daemon_instance) -> Tuple[bool, str]:
|
||||
"""Start the in-process HTTP stats server."""
|
||||
if not daemon_instance:
|
||||
return False, "Daemon instance not available"
|
||||
|
||||
http_server = getattr(daemon_instance, "http_server", None)
|
||||
if not http_server:
|
||||
return False, "HTTP server not initialized"
|
||||
|
||||
running = _is_cherrypy_engine_running()
|
||||
if running is True:
|
||||
return True, "HTTP server already running"
|
||||
|
||||
try:
|
||||
http_server.start()
|
||||
return True, "HTTP server started"
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to start HTTP server: {exc}", exc_info=True)
|
||||
return False, f"Failed to start HTTP server: {exc}"
|
||||
|
||||
+336
-19
@@ -4,12 +4,13 @@ import os
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from concurrent.futures import TimeoutError as FutureTimeoutError
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cherrypy
|
||||
import yaml
|
||||
from pymc_core.protocol import CryptoUtils
|
||||
from openhop_core.protocol import CryptoUtils
|
||||
|
||||
from repeater import __version__
|
||||
from repeater.companion.identity_resolve import (
|
||||
@@ -121,6 +122,9 @@ POLICY_GROUP_KINDS = {
|
||||
# GET /api/unscoped_flood_policy - Get unscoped flood policy
|
||||
# POST /api/unscoped_flood_policy - Update unscoped flood policy
|
||||
# POST /api/ping_neighbor - Ping a neighbor node
|
||||
# POST /api/discover_neighbors_start - Start a live repeater discovery session
|
||||
# GET /api/discover_neighbors_stream?session_id=X - Stream repeater discovery results over SSE
|
||||
# POST /api/add_discovered_neighbor - Persist a discovered node into the neighbors/adverts table
|
||||
|
||||
# Identity Management
|
||||
# GET /api/identities - List all identities
|
||||
@@ -198,7 +202,7 @@ class APIEndpoints:
|
||||
self.config = config or {}
|
||||
self.event_loop = event_loop
|
||||
self.daemon_instance = daemon_instance
|
||||
self._config_path = config_path or "/etc/pymc_repeater/config.yaml"
|
||||
self._config_path = config_path or "/etc/openhop_repeater/config.yaml"
|
||||
self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop)
|
||||
|
||||
# Initialize ConfigManager for centralized config management
|
||||
@@ -302,6 +306,81 @@ class APIEndpoints:
|
||||
end_time = int(time.time())
|
||||
return end_time - (hours * 3600), end_time
|
||||
|
||||
def _encode_sse_event(
|
||||
self, payload, event_name: Optional[str] = None, event_id: Optional[int] = None
|
||||
) -> str:
|
||||
lines = []
|
||||
if event_name:
|
||||
lines.append(f"event: {event_name}")
|
||||
if event_id is not None:
|
||||
lines.append(f"id: {event_id}")
|
||||
lines.append(f"data: {json.dumps(payload, default=str)}")
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_discovery_node_name(value) -> Optional[str]:
|
||||
"""Normalize placeholder discovery names to None.
|
||||
|
||||
Some peers explicitly advertise "Unknown"-style placeholder names.
|
||||
Treat those as missing so callers can fall back to hash/prefix labels.
|
||||
"""
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
normalized = text.lower()
|
||||
if normalized in {"unknown", "unknown node"}:
|
||||
return None
|
||||
|
||||
return text
|
||||
|
||||
def _enrich_discovery_result(self, result: dict) -> dict:
|
||||
enriched = dict(result)
|
||||
enriched["node_name"] = self._normalize_discovery_node_name(enriched.get("node_name"))
|
||||
pub_key = str(enriched.get("pub_key") or "").lower()
|
||||
if not pub_key:
|
||||
return enriched
|
||||
|
||||
try:
|
||||
enriched["node_hash"] = self._fmt_hash(bytes.fromhex(pub_key))
|
||||
except ValueError:
|
||||
enriched["node_hash"] = None
|
||||
|
||||
try:
|
||||
storage = self._get_storage()
|
||||
except Exception:
|
||||
storage = None
|
||||
|
||||
if not storage:
|
||||
enriched["known_neighbor"] = False
|
||||
return enriched
|
||||
|
||||
neighbor_info = {}
|
||||
try:
|
||||
if hasattr(storage, "get_neighbors"):
|
||||
neighbor_info = storage.get_neighbors().get(pub_key, {}) or {}
|
||||
except Exception as exc:
|
||||
logger.debug("Discovery enrichment could not load neighbors: %s", exc)
|
||||
|
||||
if not neighbor_info:
|
||||
try:
|
||||
node_name = storage.get_node_name_by_pubkey(pub_key)
|
||||
except Exception:
|
||||
node_name = None
|
||||
node_name = self._normalize_discovery_node_name(node_name)
|
||||
enriched["known_neighbor"] = bool(node_name)
|
||||
if node_name:
|
||||
enriched["node_name"] = node_name
|
||||
return enriched
|
||||
|
||||
enriched["known_neighbor"] = True
|
||||
enriched["node_name"] = self._normalize_discovery_node_name(neighbor_info.get("node_name"))
|
||||
enriched["contact_type"] = neighbor_info.get("contact_type")
|
||||
enriched["zero_hop"] = neighbor_info.get("zero_hop")
|
||||
enriched["last_seen"] = neighbor_info.get("last_seen")
|
||||
enriched["advert_count"] = neighbor_info.get("advert_count")
|
||||
return enriched
|
||||
|
||||
def _process_counter_data(self, data_points, timestamps_ms):
|
||||
rates = []
|
||||
prev_value = None
|
||||
@@ -1159,8 +1238,8 @@ class APIEndpoints:
|
||||
return {"success": False, "error": "Radio preset selection is required"}
|
||||
|
||||
admin_password = data.get("admin_password", "").strip()
|
||||
if not admin_password or len(admin_password) < 6:
|
||||
return {"success": False, "error": "Admin password must be at least 6 characters"}
|
||||
if not admin_password or len(admin_password) < 8:
|
||||
return {"success": False, "error": "Admin password must be at least 8 characters"}
|
||||
|
||||
import json
|
||||
|
||||
@@ -1427,9 +1506,9 @@ class APIEndpoints:
|
||||
stats["site_name"] = self.config.get("web", {}).get("site_name", "")
|
||||
stats["version"] = __version__
|
||||
try:
|
||||
import pymc_core
|
||||
import openhop_core
|
||||
|
||||
stats["core_version"] = pymc_core.__version__
|
||||
stats["core_version"] = openhop_core.__version__
|
||||
except ImportError:
|
||||
stats["core_version"] = "unknown"
|
||||
image_info = get_buildroot_image_info()
|
||||
@@ -1579,6 +1658,7 @@ class APIEndpoints:
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
future = None
|
||||
try:
|
||||
self._require_post()
|
||||
if not self.send_advert_func:
|
||||
@@ -1594,11 +1674,19 @@ class APIEndpoints:
|
||||
if result
|
||||
else self._error("Failed to send advert")
|
||||
)
|
||||
except FutureTimeoutError:
|
||||
logger.error(
|
||||
"Error sending advert: timeout waiting for advert transmission to complete",
|
||||
exc_info=True,
|
||||
)
|
||||
if future is not None:
|
||||
future.cancel()
|
||||
return self._error("Timed out waiting for advert transmission after 10 seconds")
|
||||
except cherrypy.HTTPError:
|
||||
# Re-raise HTTP errors (like 405 Method Not Allowed) without logging
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending advert: {e}", exc_info=True)
|
||||
logger.error("Error sending advert: %s", e, exc_info=True)
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@@ -2162,7 +2250,7 @@ class APIEndpoints:
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def restart_service(self):
|
||||
"""Restart the pymc-repeater service via systemctl."""
|
||||
"""Restart the openhop-repeater service via systemctl."""
|
||||
# Enable CORS for this endpoint only if configured
|
||||
self._set_cors_headers()
|
||||
|
||||
@@ -2521,7 +2609,6 @@ class APIEndpoints:
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"level": "INFO",
|
||||
"logger": "HTTPServer",
|
||||
"raw_message": "No logs available",
|
||||
}
|
||||
]
|
||||
)
|
||||
@@ -2953,6 +3040,18 @@ class APIEndpoints:
|
||||
logger.error(f"Error getting packet by hash: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def packet_by_id(self, packet_id=None):
|
||||
try:
|
||||
if packet_id is None:
|
||||
return self._error("packet_id parameter required")
|
||||
packet = self._get_storage().get_packet_by_id(int(packet_id))
|
||||
return self._success(packet) if packet else self._error("Packet not found")
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting packet by id: {e}")
|
||||
return self._error(e)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def rrd_data(self):
|
||||
@@ -3820,7 +3919,7 @@ class APIEndpoints:
|
||||
self.config["mesh"]["unscoped_flood_allow"] = unscoped_flood_allow
|
||||
|
||||
# Get the actual config path from daemon instance (same as CAD settings)
|
||||
config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml")
|
||||
config_path = getattr(self, "_config_path", "/etc/openhop_repeater/config.yaml")
|
||||
if self.daemon_instance and hasattr(self.daemon_instance, "config_path"):
|
||||
config_path = self.daemon_instance.config_path
|
||||
|
||||
@@ -3933,7 +4032,7 @@ class APIEndpoints:
|
||||
trace_tag = secrets.randbits(32)
|
||||
|
||||
# Create trace packet
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
|
||||
path_bytes = list(target_hash.to_bytes(byte_count, "big"))
|
||||
packet = PacketBuilder.create_trace(
|
||||
@@ -4023,6 +4122,222 @@ class APIEndpoints:
|
||||
logger.error(f"Error pinging neighbor: {e}", exc_info=True)
|
||||
return self._error(str(e))
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
def discover_neighbors_start(self):
|
||||
|
||||
self._set_cors_headers()
|
||||
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
try:
|
||||
self._require_post()
|
||||
data = cherrypy.request.json or {}
|
||||
timeout = float(data.get("timeout", 5))
|
||||
filter_mask = int(data.get("filter_mask", 1 << 2))
|
||||
since = int(data.get("since", 0))
|
||||
prefix_only = bool(data.get("prefix_only", False))
|
||||
|
||||
if timeout < 1 or timeout > 60:
|
||||
return self._error("timeout must be between 1 and 60 seconds")
|
||||
if filter_mask < 0 or filter_mask > 0xFF:
|
||||
return self._error("filter_mask must be between 0x00 and 0xFF")
|
||||
if since < 0:
|
||||
return self._error("since must be non-negative")
|
||||
|
||||
if self.event_loop is None:
|
||||
return self._error("Event loop not available")
|
||||
|
||||
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
|
||||
if not discovery_helper:
|
||||
return self._error("Discovery helper not available")
|
||||
|
||||
discovery_helper.cleanup_sessions()
|
||||
session = discovery_helper.create_session(
|
||||
timeout=timeout,
|
||||
filter_mask=filter_mask,
|
||||
since=since,
|
||||
prefix_only=prefix_only,
|
||||
result_enricher=self._enrich_discovery_result,
|
||||
)
|
||||
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
discovery_helper.start_session_task, session["session_id"]
|
||||
)
|
||||
|
||||
return self._success(
|
||||
session,
|
||||
message="Discovery session started",
|
||||
)
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error starting discovery session: %s", e, exc_info=True)
|
||||
return self._error(str(e))
|
||||
|
||||
@cherrypy.expose
|
||||
def discover_neighbors_stream(self, session_id=None, last_event_id: Optional[str] = None):
|
||||
self._set_cors_headers()
|
||||
cherrypy.response.headers["Content-Type"] = "text/event-stream"
|
||||
cherrypy.response.headers["Cache-Control"] = "no-cache"
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
||||
|
||||
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
|
||||
|
||||
try:
|
||||
cursor = int(last_event_id) if last_event_id is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
cursor = 0
|
||||
|
||||
def generate():
|
||||
if not session_id:
|
||||
yield self._encode_sse_event(
|
||||
{"type": "error", "error": "Missing session_id"},
|
||||
event_name="error",
|
||||
)
|
||||
return
|
||||
|
||||
if not discovery_helper:
|
||||
yield self._encode_sse_event(
|
||||
{"type": "error", "error": "Discovery helper not available"},
|
||||
event_name="error",
|
||||
)
|
||||
return
|
||||
|
||||
snapshot = discovery_helper.get_session_snapshot(session_id)
|
||||
if not snapshot:
|
||||
yield self._encode_sse_event(
|
||||
{"type": "error", "error": f"Unknown discovery session: {session_id}"},
|
||||
event_name="error",
|
||||
)
|
||||
return
|
||||
|
||||
yield self._encode_sse_event(
|
||||
{
|
||||
"type": "connected",
|
||||
"session": snapshot,
|
||||
},
|
||||
event_name="connected",
|
||||
)
|
||||
|
||||
current_cursor = cursor
|
||||
try:
|
||||
while True:
|
||||
event_state = discovery_helper.get_events_since(session_id, current_cursor)
|
||||
if event_state is None:
|
||||
yield self._encode_sse_event(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Unknown discovery session: {session_id}",
|
||||
},
|
||||
event_name="error",
|
||||
)
|
||||
return
|
||||
|
||||
events = event_state.get("events", [])
|
||||
if events:
|
||||
for event in events:
|
||||
current_cursor = max(current_cursor, int(event.get("id", 0)))
|
||||
yield self._encode_sse_event(
|
||||
event.get("data", {}),
|
||||
event_name=event.get("event"),
|
||||
event_id=event.get("id"),
|
||||
)
|
||||
if event_state.get("completed"):
|
||||
return
|
||||
else:
|
||||
if event_state.get("completed"):
|
||||
return
|
||||
yield self._encode_sse_event(
|
||||
{"type": "keepalive", "session_id": session_id},
|
||||
event_name="keepalive",
|
||||
event_id=current_cursor if current_cursor > 0 else None,
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
except GeneratorExit:
|
||||
logger.debug("Discovery SSE stream closed by client for session %s", session_id)
|
||||
except Exception as exc:
|
||||
logger.error("Discovery SSE stream error: %s", exc, exc_info=True)
|
||||
yield self._encode_sse_event(
|
||||
{"type": "error", "error": str(exc), "session_id": session_id},
|
||||
event_name="error",
|
||||
event_id=current_cursor if current_cursor > 0 else None,
|
||||
)
|
||||
|
||||
return generate()
|
||||
|
||||
discover_neighbors_stream._cp_config = {"response.stream": True}
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in()
|
||||
@require_auth
|
||||
def add_discovered_neighbor(self):
|
||||
|
||||
self._set_cors_headers()
|
||||
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
|
||||
try:
|
||||
self._require_post()
|
||||
data = cherrypy.request.json or {}
|
||||
pub_key = str(data.get("pub_key") or "").strip().lower()
|
||||
node_name = self._normalize_discovery_node_name(data.get("node_name"))
|
||||
node_type = int(data.get("node_type", 0))
|
||||
rssi = data.get("rssi")
|
||||
snr = data.get("response_snr", data.get("snr"))
|
||||
|
||||
if not pub_key:
|
||||
return self._error("pub_key is required")
|
||||
if not re.fullmatch(r"[0-9a-f]{16}|[0-9a-f]{64}", pub_key):
|
||||
return self._error("pub_key must be 8-byte or 32-byte hex")
|
||||
|
||||
contact_type = {
|
||||
1: "Chat Node",
|
||||
2: "Repeater",
|
||||
3: "Room Server",
|
||||
}.get(node_type, "Unknown")
|
||||
|
||||
advert_record = {
|
||||
"timestamp": time.time(),
|
||||
"pubkey": pub_key,
|
||||
"node_name": node_name,
|
||||
"is_repeater": node_type == 2,
|
||||
"route_type": 2,
|
||||
"contact_type": contact_type,
|
||||
"latitude": None,
|
||||
"longitude": None,
|
||||
"rssi": int(rssi) if rssi is not None else None,
|
||||
"snr": float(snr) if snr is not None else None,
|
||||
"is_new_neighbor": True,
|
||||
"zero_hop": True,
|
||||
}
|
||||
|
||||
storage = self._get_storage()
|
||||
storage.record_advert(advert_record)
|
||||
|
||||
enriched = self._enrich_discovery_result(
|
||||
{
|
||||
"pub_key": pub_key,
|
||||
"node_name": node_name,
|
||||
"node_type": node_type,
|
||||
"node_type_name": contact_type,
|
||||
"rssi": advert_record["rssi"],
|
||||
"response_snr": advert_record["snr"],
|
||||
}
|
||||
)
|
||||
return self._success(enriched, message="Neighbor added to adverts database")
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error adding discovered neighbor: %s", e, exc_info=True)
|
||||
return self._error(str(e))
|
||||
|
||||
# ========== Identity Management Endpoints ==========
|
||||
|
||||
@cherrypy.expose
|
||||
@@ -4247,9 +4562,11 @@ class APIEndpoints:
|
||||
key_was_generated = False
|
||||
if not identity_key:
|
||||
try:
|
||||
# Generate a new random 32-byte key (same method as config.py)
|
||||
random_key = os.urandom(32)
|
||||
identity_key = random_key.hex()
|
||||
# Use MeshCore-compatible keygen and store 32-byte private scalar hex.
|
||||
from repeater.keygen import generate_meshcore_keypair
|
||||
|
||||
_, private_key = generate_meshcore_keypair()
|
||||
identity_key = private_key[:32].hex()
|
||||
key_was_generated = True
|
||||
logger.info(f"Auto-generated identity key for '{name}': {identity_key[:16]}...")
|
||||
except Exception as gen_error:
|
||||
@@ -4350,7 +4667,7 @@ class APIEndpoints:
|
||||
companion_activation_error = None
|
||||
if identity_type == "room_server" and self.daemon_instance:
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
from openhop_core import LocalIdentity
|
||||
|
||||
# Create LocalIdentity from the key (convert hex string to bytes)
|
||||
if isinstance(identity_key, bytes):
|
||||
@@ -4674,7 +4991,7 @@ class APIEndpoints:
|
||||
|
||||
if needs_reload and self.daemon_instance:
|
||||
try:
|
||||
from pymc_core import LocalIdentity
|
||||
from openhop_core import LocalIdentity
|
||||
|
||||
final_name = identity["name"] # Could be new_name
|
||||
identity_key = identity["identity_key"]
|
||||
@@ -4962,8 +5279,8 @@ class APIEndpoints:
|
||||
):
|
||||
"""Send advert for a room server identity"""
|
||||
try:
|
||||
from pymc_core.protocol import PacketBuilder
|
||||
from pymc_core.protocol.constants import (
|
||||
from openhop_core.protocol import PacketBuilder
|
||||
from openhop_core.protocol.constants import (
|
||||
ADVERT_FLAG_HAS_NAME,
|
||||
ADVERT_FLAG_IS_ROOM_SERVER,
|
||||
)
|
||||
@@ -6589,7 +6906,7 @@ class APIEndpoints:
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pyMC Repeater API Documentation</title>
|
||||
<title>openHop Repeater API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.0/swagger-ui.css">
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -39,6 +39,26 @@ def require_auth(func):
|
||||
else:
|
||||
logger.warning("Invalid or expired JWT token")
|
||||
|
||||
request_params = getattr(cherrypy.request, "params", None)
|
||||
if request_params is None:
|
||||
request_params = {}
|
||||
|
||||
query_token = request_params.get("token")
|
||||
if query_token:
|
||||
payload = jwt_handler.verify_jwt(query_token)
|
||||
|
||||
if payload:
|
||||
cherrypy.request.user = {
|
||||
"username": payload["sub"],
|
||||
"client_id": payload["client_id"],
|
||||
"auth_type": "jwt_query",
|
||||
}
|
||||
if hasattr(cherrypy.request, "params") and "token" in cherrypy.request.params:
|
||||
del cherrypy.request.params["token"]
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
logger.warning("Invalid or expired JWT query token")
|
||||
|
||||
# Try API token authentication
|
||||
api_key = cherrypy.request.headers.get("X-API-Key", "")
|
||||
if api_key:
|
||||
|
||||
@@ -2,12 +2,107 @@
|
||||
Authentication endpoints for login and token management
|
||||
"""
|
||||
|
||||
import cherrypy
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
import cherrypy
|
||||
|
||||
from .auth.middleware import require_auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MIN_ADMIN_PASSWORD_LEN = 8
|
||||
|
||||
|
||||
class _LoginThrottle:
|
||||
"""In-memory login throttle with exponential backoff."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
per_ip_threshold: int = 5,
|
||||
per_user_threshold: int = 5,
|
||||
global_threshold: int = 20,
|
||||
base_backoff_sec: int = 1,
|
||||
max_backoff_sec: int = 60,
|
||||
window_sec: int = 300,
|
||||
time_fn=None,
|
||||
):
|
||||
self.per_ip_threshold = per_ip_threshold
|
||||
self.per_user_threshold = per_user_threshold
|
||||
self.global_threshold = global_threshold
|
||||
self.base_backoff_sec = base_backoff_sec
|
||||
self.max_backoff_sec = max_backoff_sec
|
||||
self.window_sec = window_sec
|
||||
self._time_fn = time_fn or time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._ip_states = {}
|
||||
self._user_states = {}
|
||||
self._global_state = {"failures": 0, "last_failure": 0.0, "blocked_until": 0.0}
|
||||
|
||||
def _state(self, bucket: dict, key: str):
|
||||
if key not in bucket:
|
||||
bucket[key] = {"failures": 0, "last_failure": 0.0, "blocked_until": 0.0}
|
||||
return bucket[key]
|
||||
|
||||
def _maybe_decay(self, state: dict, now: float) -> None:
|
||||
last = state.get("last_failure", 0.0)
|
||||
if last and (now - last) > self.window_sec:
|
||||
state["failures"] = 0
|
||||
state["blocked_until"] = 0.0
|
||||
|
||||
def _record_failure(self, state: dict, threshold: int, now: float) -> None:
|
||||
self._maybe_decay(state, now)
|
||||
state["failures"] = int(state.get("failures", 0)) + 1
|
||||
state["last_failure"] = now
|
||||
if state["failures"] >= threshold:
|
||||
exponent = state["failures"] - threshold
|
||||
delay = min(self.max_backoff_sec, self.base_backoff_sec * (2**exponent))
|
||||
state["blocked_until"] = max(float(state.get("blocked_until", 0.0)), now + delay)
|
||||
|
||||
def _retry_after(self, state: dict, now: float) -> int:
|
||||
self._maybe_decay(state, now)
|
||||
blocked_until = float(state.get("blocked_until", 0.0))
|
||||
if blocked_until <= now:
|
||||
return 0
|
||||
return max(1, math.ceil(blocked_until - now))
|
||||
|
||||
def get_retry_after(self, client_ip: str, username: str) -> int:
|
||||
now = self._time_fn()
|
||||
user_key = (username or "").strip().lower() or "<unknown>"
|
||||
ip_key = client_ip or "<unknown>"
|
||||
with self._lock:
|
||||
ip_retry = self._retry_after(self._state(self._ip_states, ip_key), now)
|
||||
user_retry = self._retry_after(self._state(self._user_states, user_key), now)
|
||||
global_retry = self._retry_after(self._global_state, now)
|
||||
return max(ip_retry, user_retry, global_retry)
|
||||
|
||||
def register_failure(self, client_ip: str, username: str) -> int:
|
||||
now = self._time_fn()
|
||||
user_key = (username or "").strip().lower() or "<unknown>"
|
||||
ip_key = client_ip or "<unknown>"
|
||||
with self._lock:
|
||||
self._record_failure(self._state(self._ip_states, ip_key), self.per_ip_threshold, now)
|
||||
self._record_failure(
|
||||
self._state(self._user_states, user_key), self.per_user_threshold, now
|
||||
)
|
||||
self._record_failure(self._global_state, self.global_threshold, now)
|
||||
ip_retry = self._retry_after(self._state(self._ip_states, ip_key), now)
|
||||
user_retry = self._retry_after(self._state(self._user_states, user_key), now)
|
||||
global_retry = self._retry_after(self._global_state, now)
|
||||
return max(ip_retry, user_retry, global_retry)
|
||||
|
||||
def register_success(self, client_ip: str, username: str) -> None:
|
||||
user_key = (username or "").strip().lower() or "<unknown>"
|
||||
ip_key = client_ip or "<unknown>"
|
||||
with self._lock:
|
||||
self._ip_states.pop(ip_key, None)
|
||||
self._user_states.pop(user_key, None)
|
||||
# So one successful login doesn't hide broad abuse patterns, keep global
|
||||
# state but soften it.
|
||||
self._global_state["failures"] = max(0, int(self._global_state.get("failures", 0)) - 1)
|
||||
|
||||
|
||||
class AuthAPIEndpoints:
|
||||
"""Nested endpoint for /api/auth/* RESTful routes"""
|
||||
@@ -125,11 +220,34 @@ class TokensAPIEndpoint:
|
||||
|
||||
|
||||
class AuthEndpoints:
|
||||
def __init__(self, config, jwt_handler, token_manager, config_manager=None):
|
||||
def __init__(
|
||||
self,
|
||||
config,
|
||||
jwt_handler,
|
||||
token_manager,
|
||||
config_manager=None,
|
||||
login_throttle=None,
|
||||
):
|
||||
self.config = config
|
||||
self.jwt_handler = jwt_handler
|
||||
self.token_manager = token_manager
|
||||
self.config_manager = config_manager
|
||||
self._login_throttle = login_throttle or _LoginThrottle()
|
||||
|
||||
@staticmethod
|
||||
def _get_request_ip() -> str:
|
||||
"""Extract client IP for login throttling/auditing."""
|
||||
xff = cherrypy.request.headers.get("X-Forwarded-For", "")
|
||||
if xff:
|
||||
first = xff.split(",", 1)[0].strip()
|
||||
if first:
|
||||
return first
|
||||
|
||||
remote = getattr(cherrypy.request, "remote", None)
|
||||
if remote and getattr(remote, "ip", None):
|
||||
return str(remote.ip)
|
||||
|
||||
return "unknown"
|
||||
|
||||
@cherrypy.expose
|
||||
def login(self, **kwargs):
|
||||
@@ -157,6 +275,7 @@ class AuthEndpoints:
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
client_id = data.get("client_id", "").strip()
|
||||
client_ip = self._get_request_ip()
|
||||
|
||||
if not username or not password or not client_id:
|
||||
return json.dumps(
|
||||
@@ -166,6 +285,24 @@ class AuthEndpoints:
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
retry_after = self._login_throttle.get_retry_after(client_ip, username)
|
||||
if retry_after > 0:
|
||||
cherrypy.response.status = 429
|
||||
cherrypy.response.headers["Retry-After"] = str(retry_after)
|
||||
logger.warning(
|
||||
"Login throttled for user '%s' from %s (retry_after=%ss)",
|
||||
username,
|
||||
client_ip,
|
||||
retry_after,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Too many login attempts. Please wait and try again.",
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
# Validate credentials against config
|
||||
# Check if username is 'admin' and password matches config
|
||||
repeater_config = self.config.get("repeater", {})
|
||||
@@ -182,12 +319,22 @@ class AuthEndpoints:
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
if len(config_password) < _MIN_ADMIN_PASSWORD_LEN:
|
||||
logger.warning(
|
||||
"Weak admin password configured (len=%s). Login remains allowed for compatibility.",
|
||||
len(config_password),
|
||||
)
|
||||
|
||||
if username == "admin" and password == config_password:
|
||||
self._login_throttle.register_success(client_ip, username)
|
||||
# Create JWT token
|
||||
token = self.jwt_handler.create_jwt(username, client_id)
|
||||
|
||||
logger.info(
|
||||
f"Successful login for user '{username}' from client '{client_id[:8]}...'"
|
||||
"Successful login for user '%s' from client '%s...' ip=%s",
|
||||
username,
|
||||
client_id[:8],
|
||||
client_ip,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
@@ -199,7 +346,26 @@ class AuthEndpoints:
|
||||
}
|
||||
).encode("utf-8")
|
||||
else:
|
||||
logger.warning(f"Failed login attempt for user '{username}'")
|
||||
retry_after = self._login_throttle.register_failure(client_ip, username)
|
||||
if retry_after > 0:
|
||||
cherrypy.response.status = 429
|
||||
cherrypy.response.headers["Retry-After"] = str(retry_after)
|
||||
logger.warning(
|
||||
"Failed login attempt throttled for user '%s' from %s (retry_after=%ss)",
|
||||
username,
|
||||
client_ip,
|
||||
retry_after,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Too many login attempts. Please wait and try again.",
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
cherrypy.response.status = 401
|
||||
logger.warning("Failed login attempt for user '%s' from %s", username, client_ip)
|
||||
|
||||
# Don't reveal which part was wrong
|
||||
return json.dumps(
|
||||
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
from typing import Optional
|
||||
|
||||
import cherrypy
|
||||
from pymc_core.companion.constants import DEFAULT_OFFLINE_QUEUE_SIZE
|
||||
from openhop_core.companion.constants import DEFAULT_OFFLINE_QUEUE_SIZE
|
||||
|
||||
from repeater.companion.utils import (
|
||||
trim_companion_contacts_to_fit,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
.glass-card[data-v-950ea1f9]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
|
||||
.glass-card[data-v-f8312427]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.ml-0[data-v-1b1421f8]{margin-left:0}.ml-4[data-v-1b1421f8]{margin-left:1rem}.ml-8[data-v-1b1421f8]{margin-left:2rem}.ml-12[data-v-1b1421f8]{margin-left:3rem}.ml-16[data-v-1b1421f8]{margin-left:4rem}.ml-20[data-v-1b1421f8]{margin-left:5rem}.ml-24[data-v-1b1421f8]{margin-left:6rem}.ml-28[data-v-1b1421f8]{margin-left:7rem}.ml-32[data-v-1b1421f8]{margin-left:8rem}.dropdown-enter-active[data-v-45cb296d],.dropdown-leave-active[data-v-45cb296d]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-45cb296d],.dropdown-leave-to[data-v-45cb296d]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-00e540ed],.expand-leave-active[data-v-00e540ed]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-00e540ed],.expand-leave-to[data-v-00e540ed]{opacity:0;max-height:0}.expand-enter-to[data-v-00e540ed],.expand-leave-from[data-v-00e540ed]{opacity:1;max-height:2000px}
|
||||
@@ -0,0 +1 @@
|
||||
.ml-0[data-v-164d2832]{margin-left:0}.ml-4[data-v-164d2832]{margin-left:1rem}.ml-8[data-v-164d2832]{margin-left:2rem}.ml-12[data-v-164d2832]{margin-left:3rem}.ml-16[data-v-164d2832]{margin-left:4rem}.ml-20[data-v-164d2832]{margin-left:5rem}.ml-24[data-v-164d2832]{margin-left:6rem}.ml-28[data-v-164d2832]{margin-left:7rem}.ml-32[data-v-164d2832]{margin-left:8rem}.dropdown-enter-active[data-v-e0c27f45],.dropdown-leave-active[data-v-e0c27f45]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-e0c27f45],.dropdown-leave-to[data-v-e0c27f45]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,warning:`bg-accent-amber/opacity-light dark:bg-accent-amber/opacity-medium border-accent-amber/opacity-medium text-accent-amber`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},C={danger:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,warning:`bg-accent-amber/opacity-light hover:bg-accent-amber/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/opacity-subtle hover:bg-stroke-subtle dark:hover:bg-white/opacity-light text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/opacity-light`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
|
||||
@@ -1 +0,0 @@
|
||||
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},C={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.globe-stage[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-481d86b0]:active{cursor:grabbing}.globe-stage canvas[data-v-481d86b0]{width:100%;height:330px;display:block}.globe-tooltip[data-v-481d86b0]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-481d86b0]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-481d86b0]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-481d86b0]{color:var(--color-text-muted)}.tooltip-value[data-v-481d86b0]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-481d86b0]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-481d86b0]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-481d86b0]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-481d86b0]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-481d86b0]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-481d86b0]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-481d86b0]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-481d86b0]{opacity:.35}.page-tabs[data-v-481d86b0]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-481d86b0]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-481d86b0]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-481d86b0]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-481d86b0]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-481d86b0]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-481d86b0]{transform:rotate(180deg)}.accordion-body[data-v-481d86b0]{padding:0 20px 16px}
|
||||
@@ -1 +0,0 @@
|
||||
.globe-stage[data-v-517bbbeb]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, #ffffff24, transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-517bbbeb]:active{cursor:grabbing}.globe-stage canvas[data-v-517bbbeb]{width:100%;height:330px;display:block}.globe-tooltip[data-v-517bbbeb]{z-index:2;color:#fff;pointer-events:none;background:#050a0ceb;border:1px solid #ffffff2e;border-radius:12px;min-width:154px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px));box-shadow:0 14px 40px #0000005c}.tooltip-title[data-v-517bbbeb]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-517bbbeb]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-517bbbeb]{color:#ffffffa8}.tooltip-value[data-v-517bbbeb]{color:#fff;text-align:right;font-weight:700}.globe-fallback[data-v-517bbbeb]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-517bbbeb]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-517bbbeb]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-517bbbeb]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-517bbbeb]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-517bbbeb]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-517bbbeb]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-517bbbeb]{opacity:.35}.page-tabs[data-v-517bbbeb]{border-bottom:1px solid var(--color-border-subtle,#0000001a);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-517bbbeb]{color:var(--color-text-muted,#888);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-517bbbeb]:hover{color:var(--color-text-primary,#fff)}.page-tab-active[data-v-517bbbeb]{color:var(--color-primary,#aae8e8);border-bottom-color:var(--color-primary,#aae8e8)}.inner-tabs[data-v-517bbbeb]{border:1px solid var(--color-border-subtle,#ffffff1a);background:#ffffff0a;border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-517bbbeb]{color:var(--color-text-muted,#888);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-517bbbeb]:hover{color:var(--color-text-primary,#fff)}.inner-tab-active[data-v-517bbbeb]{background:var(--color-primary,#aae8e8);color:#000}.accordion-header[data-v-517bbbeb]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-517bbbeb]:hover{background:#ffffff0a}.accordion-chevron[data-v-517bbbeb]{color:var(--color-text-muted,#888);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-517bbbeb]{transform:rotate(180deg)}.accordion-body[data-v-517bbbeb]{padding:0 20px 16px}
|
||||
@@ -0,0 +1 @@
|
||||
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/opacity-light rounded-[15px] p-8"><h1 class="text-content-primary text-2xl font-semibold mb-6"> Help & Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/openhop-dev/openhope-repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/opacity-medium hover:bg-primary/opacity-medium border border-primary/opacity-heavy text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help & Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.bg-gradient-light[data-v-5b583b9d]{background:linear-gradient(#0d73774d,#aae8e833)}.bg-gradient-dark[data-v-5b583b9d]{background:linear-gradient(#aae8e82e,#0d73771a)}.login-card[data-v-5b583b9d]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffd9}.dark .login-card[data-v-5b583b9d]{background:#1a1e1fcc}.input-glass[data-v-5b583b9d]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-5b583b9d]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-5b583b9d]:focus{background:#fff}.dark .input-glass[data-v-5b583b9d]:focus{background:#ffffff1a}.input-glass[data-v-5b583b9d]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-5b583b9d]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-5b583b9d]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-5b583b9d]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-5b583b9d]: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-5b583b9d]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-5b583b9d]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-5b583b9d]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}@keyframes float-5b583b9d{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-5b583b9d{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-5b583b9d{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-5b583b9d{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-5b583b9d]{animation:8s ease-in-out infinite pulse-slow-5b583b9d}.animate-pulse-slower[data-v-5b583b9d]{animation:10s ease-in-out infinite pulse-slower-5b583b9d}.animate-pulse-slowest[data-v-5b583b9d]{animation:12s ease-in-out infinite pulse-slowest-5b583b9d}@keyframes shake-5b583b9d{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-5b583b9d]{animation:.5s ease-in-out shake-5b583b9d}.form-group[data-v-5b583b9d]{position:relative}.form-group:hover label[data-v-5b583b9d]{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 @@
|
||||
.bg-gradient-light[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-eec727a3]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-eec727a3]:focus{background:var(--color-surface)}.dark .input-glass[data-v-eec727a3]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-eec727a3]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-eec727a3]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-eec727a3]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-eec727a3]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-eec727a3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-eec727a3]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-eec727a3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-eec727a3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-eec727a3]{animation:8s ease-in-out infinite pulse-slow-eec727a3}.animate-pulse-slower[data-v-eec727a3]{animation:10s ease-in-out infinite pulse-slower-eec727a3}.animate-pulse-slowest[data-v-eec727a3]{animation:12s ease-in-out infinite pulse-slowest-eec727a3}@keyframes shake-eec727a3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-eec727a3]{animation:.5s ease-in-out shake-eec727a3}.form-group[data-v-eec727a3]{position:relative}.form-group:hover label[data-v-eec727a3]{color:var(--color-accent-cyan);transition:color .3s}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user