87 Commits

Author SHA1 Message Date
Rightup 43a112ce7c feat: add HTTP server configuration and control commands 2026-07-07 20:38:22 +01:00
Lloyd e52637535d Merge pull request #337 from Littleaton/radio-fix-comma
Radio fix comma
2026-07-06 16:12:40 +01:00
Littleaton 0e26ed3cb3 fied extra comma 2026-07-06 09:06:55 -06:00
Rightup 1a2bf9e4fd feat:update ui with repeater discovery 2026-07-04 23:48:32 +01:00
Rightup 8b3894d2d5 fix: remove duplicate import statements in test files 2026-07-04 23:41:51 +01:00
Rightup daec7f0ebc feat: implement neighbor discovery session management and API endpoints 2026-07-04 23:41:33 +01:00
Lloyd a5c85dd9d9 Merge pull request #326 from Littleaton/dev
updated working zebra duo config radio 0 / 1 / Rak6421 pi hat
2026-07-04 22:27:33 +01:00
Lloyd ff43d85b85 Merge pull request #333 from mdspann/issue305
Add timezone to MQTT packet data
2026-07-04 22:19:49 +01:00
Rightup 229d71459f fix: improve upsert_client_sync to prevent over write. 2026-07-04 18:41:55 +01:00
mike 047ad19fd3 Add timezone to MQTT packet data 2026-07-03 15:07:26 -07:00
Rightup 05449987c1 feat: update packet detail screen to lookup repeater names 2026-07-03 21:21:20 +01:00
Rightup ae6ac1bd05 update packet table desktop/mobile with dupe groups 2026-07-02 21:47:51 +01:00
Rightup ae68112377 - Refactored ACL authentication flow with shared helpers for replay detection and session updates.
- Changed blank-password login behavior to create/manage guest sessions (when read-only is enabled), including replay protection and session timestamp tracking.
- Preserved and centralized `sync_since` handling during successful auth/session updates.
- Updated login handling to return explicit `(success, permissions)` and reset room-server sync guard state in SQLite on successful room-server login.
- Updated identity/banner output in mesh CLI from `pyMC_*` to `openHop_*` and adjusted default version fallback.
- Extended path handling to support encoded path-length semantics via `PathUtils`, with legacy fallback support for older/malformed packets.
- Added bundled ACK extraction from PATH payload extras and callback-based ACK propagation.
- Hardened room-server push logic: max-failure early skip, corrected expected ACK CRC calculation, path encoding compatibility, persisted sync/last-activity fields, and passed expected CRC + timeout into packet injection.
- Enhanced packet router ACK flow with dispatcher ACK registration helper, support for multipart ACK wrapper handling, configurable ACK wait timeout/CRC in injection, and ensured PATH helper processing always runs.
- Updated daemon wiring to pass ACK callback into `PathHelper`,
- make dispatcher dedupe configurable (prevent dbl deduping), and only register duplicate logging hook when dedupe is enabled.
- Expanded tests to cover guest blank-password replay tracking, updated room-server injector ACK expectations, and new duplicate-logging-hook gating behavior.
2026-07-02 16:32:32 +01:00
Rightup 34747d2610 feat: add packet retrieval by ID endpoint and corresponding database methods 2026-07-02 16:18:54 +01:00
Littleaton b84479de0c fixed rak6421 config. 2026-06-30 14:11:55 -06:00
Littleaton baa89e836c updated working zebra duo config radio 0 / 1 2026-06-30 13:37:38 -06:00
Lloyd 823308aa3f fix: update drop reason handling in PacketRouter and RepeaterHandler 2026-06-29 15:48:19 +01:00
Lloyd a964062bf6 Merge branch 'pr-323' into dev 2026-06-29 15:22:16 +01:00
Lloyd 9be74efeb0 Merge remote-tracking branch 'origin/feat-std-key-formats' into dev 2026-06-29 15:21:05 +01:00
Lloyd 38efb05eeb fix: expose effective drop reason to PacketRouter 2026-06-29 15:18:39 +01:00
Rightup a53df3d76f feat: implement MeshCore-compatible key generation for all identity keys with guards for 0x00 and 0xff prefixs 2026-06-28 22:57:14 +01:00
theshaun 172c8cc016 Initialise MQTT handler only when at least one broker is configured to save some lil cpu and logging 2026-06-28 18:01:49 +10:00
Lloyd 60357f5808 Merge pull request #320 from openhop-dev/dev 2026-06-26 23:07:51 +01:00
Yellowcooln 6de2b23266 Merge pull request #319 from yellowcooln/fix/ha-addon-dispatch-repo
fix: update HA add-on dispatch target
2026-06-26 09:39:08 -04:00
yellowcooln 864030283c fix: update HA add-on dispatch target 2026-06-26 09:35:19 -04:00
Yellowcooln 751c9e3807 Merge pull request #318 from yellowcooln/fix/openhop-docker-vars
Update Docker image branding
2026-06-26 09:23:21 -04:00
yellowcooln fbbc7e6844 fix: handle advert timeout cancellation 2026-06-26 09:18:41 -04:00
yellowcooln 4d095dad42 fix: update Docker image branding 2026-06-26 09:10:58 -04:00
Lloyd fcaad1dacf feat: add ensure_service_user_home function to manage service user home directory 2026-06-26 13:49:54 +01:00
Yellowcooln e2ae7e18a6 Merge pull request #317 from openhop-dev/revert-316-fix/updater-venv-pip-shim
Revert "fix: repair updater venv pip shim"
2026-06-25 22:13:57 -04:00
Yellowcooln 11f8ea2f43 Revert "fix: repair updater venv pip shim" 2026-06-25 22:13:42 -04:00
Yellowcooln d24a346cf7 Merge pull request #316 from yellowcooln/fix/updater-venv-pip-shim
fix: repair updater venv pip shim
2026-06-25 22:13:14 -04:00
yellowcooln af6bea5bbe fix: repair updater venv pip shim 2026-06-25 22:04:25 -04:00
Yellowcooln 9846666076 Merge pull request #315 from yellowcooln/fix/openhop-updater-repo
fix: update updater GitHub repository
2026-06-25 21:46:58 -04:00
yellowcooln 42340f683f fix: update updater GitHub repository 2026-06-25 21:32:52 -04:00
Yellowcooln 27e68167d5 Merge pull request #314 from openhop-dev/yellowcooln-patch-1
Update default Docker image repository
2026-06-25 20:07:28 -04:00
Yellowcooln f0408d263d Update default Docker image repository 2026-06-25 19:54:18 -04:00
Lloyd 135d6b1d8d Merge pull request #313 from openhop-dev/fix-venv-in-manage
Fix venv in manage
2026-06-25 23:36:48 +01:00
Lloyd 9748058010 fix: update virtual environment pip usage in manage.sh 2026-06-25 23:32:19 +01:00
Lloyd cc0dc2ffd6 fix: virtual environment creation logic in manage.sh 2026-06-25 23:13:28 +01:00
Lloyd d63ed4aade fix: update repository references in Docker publish workflow 2026-06-25 22:11:48 +01:00
Lloyd 0ec8ef4741 fix: update repository name format to match GitHub conventions 2026-06-25 22:03:33 +01:00
Lloyd b47b971b98 Merge pull request #311 from openhop-dev/refactor-rename-to-openhop
Refactor rename to openhop
2026-06-25 21:52:54 +01:00
Lloyd 27ccf5453d refactor: update GitHub owner to openhop-dev and enhance changelog error handling 2026-06-25 21:39:08 +01:00
Lloyd 42cd75e6b0 refactor: enhance logging and error handling in API endpoints and HTTP server 2026-06-25 21:26:29 +01:00
Lloyd 1a6e09f63d fix: update repository URL to reflect new organization name 2026-06-25 16:31:11 +01:00
Lloyd 0d0615f3f2 Merge pull request #310 from yellowcooln/feat/openhop-glass-integration
feat: integrate openHop Glass policy synchronization and sensor summaries
2026-06-25 09:29:26 +01:00
yellowcooln 432b00906c fix: satisfy pre-commit after openHop base sync 2026-06-24 20:36:23 -04:00
yellowcooln af88459f29 merge: sync with openHop refactor base 2026-06-24 20:35:06 -04:00
Lloyd 08b4667b2c refactor: disable legacy pymc services and normalize paths in management scripts 2026-06-25 00:00:28 +01:00
Lloyd 16e88579ae refactor: migrate legacy pymc paths to openhop and clean up system packages 2026-06-24 23:49:08 +01:00
Lloyd 6f6e984d9d refactor: simplify hardware dependency in optional dependencies 2026-06-24 23:38:57 +01:00
Lloyd ffb083652f refactor: update legacy paths and cleanup in management scripts 2026-06-24 23:37:14 +01:00
Lloyd 2b67dea96b refactor:rename-project-to-openhop 2026-06-24 23:27:49 +01:00
yellowcooln cf896b2b16 Revert "Bundle policy objects UI"
This reverts commit 71f4e4779d.
2026-06-24 16:47:22 -04:00
yellowcooln 71f4e4779d Bundle policy objects UI 2026-06-24 16:39:59 -04:00
Yellowcooln 2b07e79ccd Merge pull request #309 from Bjorkan/dev
feat(presets): add Meshat.se broker preset
2026-06-24 16:24:46 -04:00
yellowcooln 44a2d2f72f feat: add system info to hardware stats sensor 2026-06-24 16:23:50 -04:00
yellowcooln b4b44831b0 feat: integrate openHop Glass inform policy sync 2026-06-24 15:17:41 -04:00
Björkan f1e860d17e feat(presets): add Meshat.se broker preset 2026-06-24 07:51:28 +02:00
Lloyd c0d919c0e2 feat:New RF Chart 2026-06-23 16:35:02 +01:00
Lloyd 89658302c5 feat:update ui with newlogos 2026-06-23 09:27:34 +01:00
Lloyd 3b7a20a192 fix: add nosec comment to exception handling in CryptoUtils decryption 2026-06-23 09:13:48 +01:00
Lloyd ee6f6a0d0d Merge branch 'pr-306' into dev 2026-06-23 08:56:40 +01:00
Lloyd c761c425d1 Merge branch 'pr-294' into dev 2026-06-23 08:56:33 +01:00
Lloyd 6c5b6ba00a fix: update title in index.html for consistency 2026-06-23 08:52:41 +01:00
Lloyd 018f425dc1 feat: data redaction in log messages and update related tests 2026-06-23 08:52:41 +01:00
Lloyd 4a8876a95e feat: implement login throttling with exponential backoff and enforce minimum admin password length 2026-06-23 08:52:40 +01:00
Lloyd 9cff088853 feat: enhance client resolution and ACL handling in protocol request and text helpers 2026-06-23 08:52:40 +01:00
Lloyd 0c6d27a9b1 Merge pull request #304 from agessaman/feat/database-load-mitigations 2026-06-22 19:42:30 +01:00
Littleaton 06e6a7375f Update meshcore-ca.yaml
fixed -
2026-06-22 09:53:42 -06:00
Littleaton dc7bcb8a2a added meshcore ca mqtt broker 2026-06-22 09:50:12 -06:00
agessaman cd763e2cac feat: implement threaded stats broadcasting in StorageCollector
- Introduced a dedicated thread for broadcasting aggregate statistics to WebSocket clients, improving performance by offloading heavy queries from the packet write path.
- Updated the _publish_packet_sync method to ensure only fast, per-packet operations are executed, while the stats are handled separately.
- Added methods for broadcasting stats at intervals and managing the lifecycle of the stats thread.
- Enhanced unit tests to validate the new stats broadcasting behavior and ensure correct functionality under various conditions.
2026-06-18 21:38:20 -07:00
agessaman 2435757197 feat: enhance SQLiteHandler and StorageCollector for improved caching and async performance
- Added caching for packet type stats and cumulative counts in SQLiteHandler to reduce database load.
- Implemented a dedicated writer thread in StorageCollector to handle blocking storage operations, preventing asyncio event loop stalls.
- Updated record_packet method to utilize the new writer thread for efficient packet processing.
2026-06-18 21:25:19 -07:00
Yellowcooln 0f980e4e03 Merge pull request #302 from yellowcooln/feat/lxc-installer-curl-container-id
feat: improve Proxmox LXC installer prompts
2026-06-18 09:47:18 -04:00
Yellowcooln 0ccc3536ee feat: improve Proxmox LXC installer prompts 2026-06-18 09:36:47 -04:00
Yellowcooln 875ea47192 docs: trim modem GPS example comment 2026-06-16 12:24:59 -04:00
Yellowcooln 211fa51cd6 merge: sync PR branch with upstream dev 2026-06-16 11:42:36 -04:00
Yellowcooln b557116fdf fix: move modem CRC baseline into radio wrapper 2026-06-16 11:22:34 -04:00
Yellowcooln 1881ad9b93 feat: derive modem battery percent from voltage 2026-06-15 20:39:22 -04:00
Yellowcooln 5aebb4dd24 style: format sensor manager polling change 2026-06-15 19:56:10 -04:00
Yellowcooln 1c186d7feb fix: throttle only pyMC modem sensor polling 2026-06-15 18:34:05 -04:00
Yellowcooln f2b7d254a7 fix: slow sensor polling to 60 seconds 2026-06-15 18:32:12 -04:00
Yellowcooln 5d3733a960 fix: satisfy pre-commit for modem HTTP telemetry 2026-06-14 20:08:29 -04:00
Yellowcooln 642c019076 fix: preserve modem GPS satellites in view 2026-06-14 19:32:41 -04:00
Yellowcooln dde487bce3 fix: add generic PyMC modem stats sensor 2026-06-14 18:00:15 -04:00
Yellowcooln 77fc244895 fix: baseline PyMC modem CRC stats 2026-06-14 18:00:15 -04:00
201 changed files with 4947 additions and 1091 deletions
+6 -6
View File
@@ -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
+8 -8
View File
@@ -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
View File
@@ -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
+39 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -3,4 +3,4 @@
*.substvars
.debhelper/
files
pymc-repeater/
openhop-repeater/
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
pymc-repeater
openhop-repeater
-3
View File
@@ -1,3 +0,0 @@
etc/pymc_repeater
var/log/pymc_repeater
usr/share/pymc_repeater
-3
View File
@@ -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/
-57
View File
@@ -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
-18
View File
@@ -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
-19
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
services:
pymc-repeater:
image: pymc-repeater:local
openhop-repeater:
image: openhop-repeater:local
build:
context: .
dockerfile: dockerfile
+7 -7
View File
@@ -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:
+5 -5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
+331 -132
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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 -1
View File
@@ -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.
"""
+1 -1
View File
@@ -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")
+3 -3
View File
@@ -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,
+6 -6
View File
@@ -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,
+1 -1
View File
@@ -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()
+4 -4
View File
@@ -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
View File
@@ -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}. "
+52 -2
View File
@@ -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:
+334 -4
View File
@@ -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")
+207 -1
View File
@@ -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()
+28 -1
View File
@@ -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.
+2 -2
View File
@@ -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 {})},
}
+75 -20
View File
@@ -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,
)
+114 -44
View File
@@ -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():
+2 -2
View File
@@ -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"}
+30 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"""Handler helper modules for pyMC Repeater."""
"""Handler helper modules for openHop Repeater."""
from .advert import AdvertHelper
from .discovery import DiscoveryHelper
+43 -24
View File
@@ -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
+3 -3
View File
@@ -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
+262 -3
View File
@@ -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,
+26 -6
View File
@@ -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 = []
+31 -3
View File
@@ -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 ====================
+51 -9
View File
@@ -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
+13 -6
View File
@@ -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"
+43 -15
View File
@@ -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)"
)
+145 -47
View File
@@ -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(
+5 -5
View File
@@ -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
View File
@@ -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):
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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")
+28
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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")
+207
View File
@@ -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)
+61 -7
View File
@@ -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
View File
@@ -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 {
+20
View File
@@ -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:
+170 -4
View File
@@ -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(
+1 -1
View File
@@ -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 @@
.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
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
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 &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary 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 &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 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