116 Commits

Author SHA1 Message Date
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
Lloyd 19ae4516da Merge pull request #297 from Littleaton/dev 2026-06-16 20:31:42 +01:00
Littleaton bca3f3718f Update radio-settings.json 2026-06-16 12:40:07 -06:00
Yellowcooln 6e9b798ef3 Merge pull request #296 from yellowcooln/fix/docker-dialout-group
fix: add dialout group to Docker image
2026-06-16 13:18:42 -04:00
Littleaton a152e0e846 Update radio-settings.json 2026-06-16 11:16:09 -06:00
Littleaton 8fd28b7d15 added zebra hat duo
added settings for zebra hat duo / Radio 0, Radio 1
2026-06-16 11:12:26 -06: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
Lloyd 9f18a1db79 Merge pull request #295 from theshaun/dev
Update Femtofox radio settings + fix setup
2026-06-16 16:13:38 +01:00
Lloyd de129c1c15 Merge pull request #293 from agessaman/fix/companion-contact-import
Fix: Companion Contact Import Overflow
2026-06-16 15:52:22 +01: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
theshaun 8b948e1305 dont be silly shaun 2026-06-15 23:28:35 +10:00
theshaun b9c82b5778 Resolve gpio_chip + use_gpiod_backend not being set during setup 2026-06-15 23:21:12 +10:00
theshaun 40c7933556 Merge branch 'dev' of https://github.com/theshaun/pyMC_Repeater into dev 2026-06-15 23:00:31 +10:00
theshaun b659aa00d5 Update Femtofox radio settings, remove gpiod requirement and set 1w as 22db (boosts to 30db anyway on hardware) 2026-06-15 22:42:49 +10: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
agessaman dcaa4ac949 Refactor contact trimming logic for improved readability by consolidating function calls into single lines. Update related test cases for consistency. 2026-06-14 15:50:03 -07: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
Yellowcooln 9459dd5bba Merge pull request #292 from recrof/dev
initial support for station g3
2026-06-14 10:30:06 -04:00
Rastislav Vysoky 79684736bc initial support for station g3 2026-06-14 15:31:57 +02:00
agessaman 23078de78b Enhance contact import logic to respect max_contacts limit and trim excess entries. Update tests to validate trimming behavior and ensure correct handling of favourites during import. 2026-06-13 18:26:35 -07:00
Yellowcooln 3d974136a6 fix: add dialout group to Docker image 2026-06-09 21:13:45 -04:00
Rightup 291c1a6ed9 fix:update statics for companion ui fix 2026-06-09 22:26:36 +01:00
Yellowcooln aeea4bb3f8 Format OpenAPI identity test 2026-06-09 16:10:37 -04:00
Yellowcooln 063c8eeb8c Fix companion identity OpenAPI contract 2026-06-09 15:50:09 -04:00
Lloyd 99a04295da Merge pull request #287 from agessaman/kiss/tuning-options 2026-06-09 19:33:47 +01:00
Lloyd 1f9be5a024 Merge pull request #284 from MSmithDev/main 2026-06-09 19:29:16 +01:00
Matt 10b64bce62 added current docker compose and env 2026-06-09 14:17:01 -04:00
Matt 7b85cfcec9 Delete docker-compose.yml 2026-06-09 14:11:55 -04:00
Matt 88820038ee Delete .env.example 2026-06-09 14:11:30 -04:00
Matt 524359de9f Made requested changed to docker setup 2026-06-09 13:14:47 -04:00
agessaman 0b4571cb68 Stop tracking policy.yaml and add it to .gitignore 2026-06-09 09:44:58 -07:00
Lloyd 2b7b2b5b4e feat:add channel sender option to policy 2026-06-09 13:51:18 +01:00
Lloyd eb717cc745 fix:update ui clean up and add change password. 2026-06-09 11:54:46 +01:00
Lloyd c5dbfdcd4b Merge branch 'pr-284' into dev
# Conflicts:
#	README.md
2026-06-09 08:55:34 +01:00
Lloyd f4533e5ef6 Merge branch 'pr-285' into dev 2026-06-09 08:54:41 +01:00
agessaman 183650228e feat(config): add optional KISS CSMA tuning parameters
- Updated `config.yaml.example` to include optional KISS key-up and CSMA tuning parameters.
- Enhanced `get_radio_for_board` function in `config.py` to forward KISS tuning settings to the modem firmware when specified.
- Added tests to verify correct forwarding of KISS parameters and omission of unset values in `test_radio_config.py`.
2026-06-08 21:25:26 -07:00
Yellowcooln d5001a235d docs: remove docker readme edits 2026-06-08 20:31:32 -04:00
Yellowcooln 1569b11690 docker: default compose image to main 2026-06-08 20:27:48 -04:00
Yellowcooln 77480c6c1c docker: use named volumes by default 2026-06-08 20:22:45 -04:00
Yellowcooln ca50656560 docs: remove fork image from env example 2026-06-08 20:22:45 -04:00
Yellowcooln d333deb1e5 docker: clarify compose env setup 2026-06-08 20:22:45 -04:00
Yellowcooln 7015e0eb15 docs: document docker gpio gids 2026-06-08 20:22:45 -04:00
Yellowcooln b5df705b87 docs: clarify docker setup config steps 2026-06-08 20:22:45 -04:00
Yellowcooln 9e8c152f0b docker: tolerate read-only config during merge 2026-06-08 20:22:45 -04:00
Yellowcooln a308ddc00d docker: add gpio and spi groups 2026-06-08 20:22:45 -04:00
Matt 5dfa98c57c Rewrite README with expanded docs and images
Added:
- Supported hardware table with links to products
-
- New screenshots
- Web setup walkthrough
- Policy engine and example
- Updated docker with image
- Cleaned formatting
- Updated pyMC Core and MeshCore Discord links
2026-06-08 14:13:58 -04:00
Lloyd 00682e8086 Merge pull request #282 from agessaman/companion/advanced-settings 2026-06-06 18:09:00 +01:00
agessaman f3146ebc14 fix(companion): clean up ruff errors 2026-06-06 09:44:55 -07:00
agessaman ea6e660f34 refactor(api_endpoints): improve sqlite_handler retrieval logic
- Updated the logic for retrieving the sqlite_handler in APIEndpoints to use a safer approach with getattr, ensuring compatibility with the daemon_instance.
- Adjusted test case to include an additional parameter in the sqlite.companion_push_message assertion for consistency.
2026-06-06 09:39:30 -07:00
agessaman dac60443f0 feat(companion): implement contact trimming and retention policies
- Introduced `enforce_companion_contact_capacity` to manage contact limits during companion loading, with an option to trim non-favourite contacts when exceeding capacity.
- Updated `SQLiteHandler` to support message retention limits, allowing for automatic trimming of older messages based on `offline_queue_size`.
- Enhanced API endpoints to handle contact trimming on overflow, providing feedback on trimmed contacts during updates.
- Added utility functions for selecting and trimming contacts while preserving favourites.
- Improved logging for contact management actions and errors related to capacity.
2026-06-05 21:39:11 -07:00
Lloyd af603d78d0 feat:new menu change 2026-06-05 16:38:29 +01:00
Lloyd 879aac1556 Merge branch 'pr-280' into dev 2026-06-05 16:26:22 +01:00
Lloyd b3119f97f4 Merge branch 'pr-281' into dev 2026-06-05 16:25:15 +01:00
Lloyd da95c67cef fix: improve error logging for invalid policy entries and adjust test configuration 2026-06-05 16:23:18 +01:00
Yellowcooln 767c070384 Update broker host and audience in tests 2026-06-05 11:22:44 -04:00
Yellowcooln 8926b3d593 Fix URL in test_get_preset_meshmapper_is_single_broker_mc2mqtt
Update the URL in the MeshMapper preset test docstring.
2026-06-05 11:06:42 -04:00
Yellowcooln b5b2c60eb6 Update MQTT host and audience to meshmapper.net 2026-06-05 11:06:02 -04:00
Lloyd e9a9f21cab feat: update preamble_length to 32 for radio configurations 2026-06-05 16:00:23 +01:00
Rightup 225feda195 feat:ui for policy and logging update 2026-06-04 16:27:35 +01:00
Rightup 14b4804c26 feat: Enhance logging system and introduce policy management endpoints
- Updated LogBuffer to support log entry IDs, enhanced log entry structure with additional metadata, and implemented subscriber management for real-time log streaming.
- Added OpenAPI specifications for new endpoints related to policy management, including retrieval, updating, validation, and group management for network policies.
- Implemented comprehensive tests for new policy endpoints, ensuring correct behavior for creating, updating, validating, and deleting policy groups and entries.
- Introduced policy evaluation tests to validate the functionality of the PolicyEngine, including various scenarios for action decisions based on defined rules.
- Enhanced packet routing tests to ensure proper handling of policy decisions in packet processing.
2026-06-04 15:53:17 +01:00
Matt 4abc497e83 MeshSmith: Change tx_power from 22 to 18
Testing shows a setting of 18 is sufficient for full output power. May help improve TX signal quality.
2026-06-03 14:34:14 -04:00
agessaman 7fe1b19241 fix: update packet injector to include origin hash for companion bridge 2026-06-02 20:52:22 -07:00
agessaman 499f871262 Merge upstream/dev into companion/advanced-settings
Integrate latest dev while preserving per-companion bridge settings
and contact capacity validation. Resolve import conflicts in main.py
and api_endpoints.py.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:34:52 -07:00
Adam Gessaman 7d57b34a04 feat(companion): enhance contact capacity management and bridge settings
- Introduced `CompanionContactCapacityError` to handle cases where persisted contacts exceed configured limits.
- Added utility functions for parsing companion bridge settings and validating contact capacity.
- Updated `RepeaterDaemon` to check contact capacity during companion loading and initialization.
- Enhanced API endpoints to validate companion settings and manage contact limits effectively.
- Implemented logging for bridge limits and errors related to contact capacity.
2026-06-02 07:42:40 -07:00
Lloyd cd7058be99 fix: update packet router debugs for less noise and policy prep for advanced filters works 2026-06-02 12:18:17 +01:00
Lloyd 416310befd refactor: improve code readability 2026-06-02 10:31:47 +01:00
Lloyd 9e26068a10 feat: enhance installation process to align web/OTA updates with manage.sh defaults 2026-06-02 10:30:51 +01:00
Lloyd d7e74e0a89 Merge pull request #278 from agessaman/feat/pre-1160-compatibility-sendfix
Feat/pre 1160 compatibility sendfix
2026-06-02 10:26:22 +01:00
agessaman e24cdca055 feat(companion): echo injected TX to companion clients as raw RX (0x88)
Push locally-injected TX packets to connected companion frame server
clients as PUSH_CODE_LOG_RX_DATA (0x88) with snr=0/rssi=0, so apps that
decrypt locally from raw RX (e.g. RemoteTerm) see companion-originated
channel traffic. The originating companion is excluded so a node never
hears its own transmission, matching physical firmware behavior.
inject_packet now takes an origin_hash (threaded per-companion via the
packet_injector partial); _on_raw_rx_for_companions gains exclude_hash
to skip that companion's frame server. OTA RX is unaffected.
2026-06-01 17:05:38 -07:00
agessaman ee92f5b1a9 test: expect True from deferred local TX mock after companion send fix
The merged RepeaterHandler treats a falsy tx_success as failure; update
the duty-cycle deferral test to return True instead of None.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 21:43:00 -07:00
agessaman 5a9e1c87cc Merge branch 'fix/companion-message-send' into feat/pre-1160-compatibility-sendfix
Bring in companion transmission handling improvements from RepeaterHandler.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 21:42:26 -07:00
agessaman 778adb6917 feat: implement randomized response jitter in DiscoveryHelper to prevent packet collisions
- Added a default upper bound for randomized pre-send jitter in discovery responses to avoid collisions when multiple repeaters respond simultaneously.
- Introduced a new parameter `response_jitter_ms` in the `DiscoveryHelper` constructor to configure the jitter.
- Updated the `_send_packet_async` method to apply the jitter before sending responses.
- Added tests to verify the correct application of jitter and ensure functionality when jitter is disabled.
2026-05-30 18:07:23 -07:00
agessaman 5fcb6255d5 feat: enhance login handler with anonymous request support and region name formatting
- Updated the `LoginHelper` class to wrap the login handler in an `AnonRequestHandler`, allowing for proper handling of anonymous requests.
- Introduced methods for formatting region names based on flood policies and added support for retrieving transport keys from SQLite storage.
- Enhanced the constructor to accept additional parameters for SQLite handler and configuration, improving flexibility for owner-info and feature-flag replies.
- Added tests to validate the new functionality and ensure correct behavior of region name formatting and owner/features callbacks.
2026-05-30 16:19:52 -07:00
agessaman 2d5353ad7d fix(companion): improve transmission handling in RepeaterHandler
- Updated the __call__ method to return a boolean indicating transmission success.
- Adjusted handling of transmission failures to increment dropped_count appropriately.
- Enhanced logging for transmission failures in PacketRouter to ensure visibility of issues.
- Added tests to verify correct behavior during transmission exceptions and failures.
2026-05-16 17:32:15 -07:00
228 changed files with 8595 additions and 1337 deletions
+34
View File
@@ -0,0 +1,34 @@
# Copy this file to .env before running Docker Compose:
# cp .env.example .env
# Published image to run. Use a different repository or tag if you are testing
# a fork or a specific release.
PYMC_REPEATER_IMAGE=pymcdev/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/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/openhop-repeater/config
# PYMC_DATA_VOLUME=/opt/openhop-repeater/data
# Serial/SPI/GPIO access uses the host's numeric group IDs. Check your host with:
# getent group dialout
# getent group gpio
# getent group spi
# Example output:
# dialout:x:20:
# gpio:x:997:
# spi:x:999:
# Put the third field from each output line below.
DIALOUT_GID=20
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.
PUID=15888
PGID=15888
+7 -7
View File
@@ -10,14 +10,14 @@ on:
image_repository:
description: "Docker image repository to publish to"
required: false
default: "pymcdev/pymc-repeater"
default: "pymcdev/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="pymcdev/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 }}
+8 -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
@@ -51,8 +53,10 @@ htmlcov/
*~
# Config
.env
config.yaml
config.yaml.backup
policy.yaml
identity.json
# Data
@@ -65,3 +69,4 @@ syncpi.sh
# Docker
/data
Installing
+3 -2
View File
@@ -46,8 +46,9 @@ repos:
hooks:
- id: openapi-contract-check
name: OpenAPI contract check
entry: python3 scripts/check_openapi_contract.py
language: system
entry: python scripts/check_openapi_contract.py
language: python
additional_dependencies: [PyYAML]
pass_filenames: false
always_run: true
files: ^(repeater/web/.*\.py|repeater/web/openapi\.yaml)$
+370 -268
View File
@@ -1,210 +1,231 @@
# pyMC_repeater
# openHop Repeater
Repeater Daemon in Python using the `pymc_core` Lib.
Lightweight Python MeshCore repeater daemon built on `openhop_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
integrations.
I started **pyMC_core** as a way to really get under the skin of **MeshCore** — to see how it ticked and why it behaved the way it did.
After a few late nights of tinkering, testing, and head-scratching, I shared what Id learned with the community.
The response was honestly overwhelming — loads of encouragement, great feedback, and a few people asking if I could spin it into a lightweight **repeater daemon** that would run happily on low-power, Pi-class hardware.
## Contents
That challenge shaped much of what followed:
- I went with a lightweight HTTP server (**CherryPy**) instead of a full-fat framework.
- I stuck with simple polling over WebSockets — its more reliable, has fewer dependencies, and is far less resource hungry.
- I kept the architecture focused on being **clear, modular, and hackable** rather than chasing performance numbers.
Theres still plenty of room for this project to grow and improve — but youve got to start somewhere!
My hope is that **pyMC_repeater** serves as a solid, approachable foundation that others can learn from, build on, and maybe even have a bit of fun with along the way.
> **Id love to see these repeaters out in the wild — actually running in real networks and production setups.**
> My own testing so far has been in a very synthetic environment with little to no other users in my area,
> so feedback from real-world deployments would be incredibly valuable!
---
- [Overview](#overview)
- [Screenshots](#screenshots)
- [Supported Hardware](#supported-hardware)
- [Installation](#installation)
- [Configuration](#configuration)
- [Policy Engine](#policy-engine)
- [Upgrading](#upgrading)
- [Proxmox LXC Installation](#proxmox-lxc-installation)
- [Uninstallation](#uninstallation)
- [Docker Compose](#docker-compose)
- [Roadmap](#roadmap)
- [Contributing](#contributing)
- [Support](#support)
- [Disclaimer](#disclaimer)
- [License](#license)
## Overview
The repeater daemon runs continuously as a background process, forwarding LoRa packets using `pymc_core`'s Dispatcher and packet routing.
The repeater daemon runs as a background service and forwards LoRa packets using
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.
- The web interface supports setup, monitoring, logs, configuration, and updates.
- Packet routing, policy checks, storage, sensors, GPS, MQTT, and optional
pyMC_Glass integration are kept in modular components.
- Hardware support covers direct SPI radios, CH341 USB-to-SPI adapters,
pyMC TCP/USB modem firmware, and KISS serial modems.
## Supported Hardware (Out of the Box)
The repeater supports two radio backends:
- **SX1262 (SPI)** — Direct connection to LoRa modules (HATs, etc.) as listed below.
- **KISS modem** — Serial TNC using the KISS protocol. Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`.
> [!CAUTION]
> ## Compatibility
>
> ### Supported Radio Interfaces
>
> | Interface | Supported |
> |------------|------------|
> | Native SPI radio SX1262 | ✅ Yes |
> | USBSPI bridge (CH341F) | ✅ Yes |
> | UART-based HATs | ❌ No |
> | SX1302 concentrator boards | ❌ No |
> | SX1303 concentrator boards | ❌ No |
>
> This project supports **single-radio SPI transceivers only**, either:
> - Connected directly via SPI
> - Connected via a CH341F USBSPI adapter
> - Connected using hardware that supports Meshcore Kiss Modem firmware
The following hardware is currently supported out-of-the-box:
HackerGadgets uConsole
Hardware: uConsole RTL-SDR/LoRa/GPS/RTC/USB Hub
Platform: Clockwork uConsole (Raspberry Pi CM4/CM5)
Frequency: 433/915MHz (configurable)
TX Power: Up to 22dBm
SPI Bus: SPI1
GPIO Pins: CS=-1, Reset=25, Busy=24, IRQ=26
Additional Setup: Requires SPI1 overlay and GPS/RTC configuration (see uConsole setup guide)
Frequency Labs meshadv-mini
Hardware: FrequencyLabs meshadv-mini Hat
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=8, Reset=24, Busy=20, IRQ=16
Frequency Labs meshadv
Hardware: FrequencyLabs meshadv-mini Hat
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, TXEN=13, RXEN=12, use_dio3_tcxo=True
HT-RA62 module
Hardware: Heltec HT-RA62 LoRa module
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True
Zindello Industries UltraPeater
Hardware: EBYTE E22/P 1W Module
Platform: Luckfox Pico Ultra/W (NOT A PI DEVICE)
Frequency: 868MHz (EU) or 915Mhz (US/AU)
Tx Power: Up to 30dBm
SPI Bus: SPI0
GPIO Pins: CS=16, Reset=22, Busy=11, IRQ=10, TXEN=20 , RXEN=21 (E22 Only), EN=21 (E22P Only), TXLED=9, RXLED=1, use_dio2_rf=False, use_dio3_tcxo=True, use_gpiod_backend=True, gpio_chip=1
Waveshare LoRaWAN/GNSS HAT (SPI Version Only)
NO LONGER RECOMMENDED
Note: May experience issues on "Narrow" (62.5KHz) settings due to a lack of TCXO
Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported)
Platform: Raspberry Pi (or compatible single-board computer)
Frequency: 868MHz (EU) or 915MHz (US)
TX Power: Up to 22dBm
SPI Bus: SPI0
GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16
Note: Only the SPI version is supported. The UART version will not work.
...
Real-world deployment feedback is especially welcome. Dense networks, unusual
hardware, and production-style installations are the best way to find the rough
edges and make the repeater better for everyone.
## Screenshots
### Dashboard
![Dashboard](docs/dashboard.png)
*Real-time monitoring dashboard showing packet statistics, neighbor discovery, and system status*
Real-time packet statistics, neighbor discovery, and system status.
### Statistics
![Statistics](docs/stats.png)
*statistics and performance metrics*
Historical statistics and performance metrics.
## Supported Hardware
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`
- **pyMC TCP modem**: set `radio_type: pymc_tcp`
- **pyMC USB-CDC modem**: set `radio_type: pymc_usb`
- **KISS serial modem**: set `radio_type: kiss`
- **No radio hardware**: set `radio_type: null` for setup, testing, or API-only work
> [!CAUTION]
> **Compatibility**
>
> This project targets single-radio SX1262-class transceivers and supported
> modem integrations. It does not support UART-only HATs or SX1302/SX1303
> concentrator boards.
| Interface | Status |
|-----------|--------|
| Native SX1262 SPI radio | Supported |
| CH341 USB-to-SPI bridge | Supported |
| pyMC TCP modem | Supported |
| pyMC USB-CDC modem | Supported |
| KISS serial modem | Supported |
| UART-only HATs | Not supported |
| SX1302/SX1303 concentrator boards | Not supported |
The following devices have out-of-the-box presets or known support:
| Device Name | Platform | TX Power | Connection | Radio Module | Link |
|-------------|----------|----------|:----------:|:------------:|------|
| HackerGadgets uConsole | uConsole / Raspberry Pi CM | Up to 22 dBm | SPI | SX1262-class | [View](https://www.clockworkpi.com/home-uconsole) |
| Zindello Industries UltraPeater | Luckfox | Up to 30 dBm | SPI | E22, E22P | [View](https://zindello.com.au/ultrapeater/) |
| MeshSmith PiMesh-1W | Raspberry Pi | Up to 30 dBm | SPI | E22P | [View](https://meshsmith.net/products/pimesh-1w) |
| MeshSmith EtherMesh-1W | Network | Up to 30 dBm | TCP | E22P | [View](https://meshsmith.net/products/ethermesh-1w) |
| Frequency Labs meshadv-mini | Raspberry Pi | Up to 30 dBm | SPI | E22 | [View](https://www.etsy.com/shop/FrequencyLabs) |
| Frequency Labs meshadv | Raspberry Pi | Up to 30 dBm | SPI | E22 | [View](https://www.etsy.com/shop/FrequencyLabs) |
Always confirm pin mappings, antenna setup, regional frequency rules, and TX
power limits before transmitting.
## Installation
Before You Begin
### Install Git
Make sure SPI is switched on using raspi-config:
```bash
sudo raspi-config
```
1. Go to Interface Options
2. Select SPI
3. Choose Enable
4. Reboot when prompted:
```bash
sudo reboot
```
After reboot, you can confirm SPI is active:
```bash
ls /dev/spi*
```
You should see something like:
```bash
/dev/spidev0.0 /dev/spidev0.1
```
**Install Git (if not already installed):**
```bash
sudo apt update
sudo apt install git -y
```
**Clone the Repository:**
### 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:**
### Quick Install
```bash
sudo ./manage.sh
sudo bash ./manage.sh install
```
This script will:
The installer will:
- Create a dedicated `repeater` service user with hardware access
- Install files to `/opt/pymc_repeater`
- Create configuration directory at `/etc/pymc_repeater`
- Setup log directory at `/var/log/pymc_repeater`
- **Launch interactive radio & hardware configuration wizard**
- Install and enable systemd service
- 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 `openhop-repeater` systemd service
After installation:
**After Installation:**
```bash
# View live logs
sudo journalctl -u pymc-repeater -f
sudo journalctl -u openhop-repeater -f
```
# Access web dashboard
Open the web dashboard at:
```text
http://<repeater-ip>:8000
```
**Development Install:**
### Development Install
```bash
pip install -e .
```
For development tools:
```bash
pip install -e ".[dev]"
```
## Configuration
The configuration file is created and configured during installation at:
```
/etc/pymc_repeater/config.yaml
The main configuration file is created during installation:
```text
/etc/openhop_repeater/config.yaml
```
### Optional pyMC_Glass integration
The repeater now supports an additive `glass` config section for central control-plane integration.
When enabled, it sends periodic `/inform` payloads to pyMC_Glass, receives queued commands, and reports command results on the next inform cycle.
### Setup Wizard
The web-based setup flow guides you through repeater identity, hardware
selection, radio presets, and login setup.
#### Start Setup
![Onboarding Step 1](docs/onboarding1.png)
#### Repeater Name
![Onboarding Step 2](docs/onboarding2.png)
#### Hardware Type
![Onboarding Step 3](docs/onboarding3.png)
#### Choose A Preset
![Onboarding Step 5](docs/onboarding5.png)
#### TX Power
TX power defaults to 14 dBm and can be changed later.
![TX Power Notice](docs/onboarding-tx-disclaimer.png)
#### Set A Password
![Onboarding Step 6](docs/onboarding6.png)
#### Update TX settings
![Radio Configuration](docs/config.png)
#### Run CAD Calibration
![CAD Calibration](docs/CAD-Tool.png)
### Reconfigure Radio And Hardware
To reconfigure radio and hardware settings after installation:
```bash
sudo bash setup-radio-config.sh /etc/openhop_repeater
```
You can also launch the management menu:
```bash
sudo ./manage.sh
sudo systemctl restart openhop-repeater
```
### Optional pyMC_Glass Integration
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.
Minimal example:
```yaml
glass:
enabled: true
@@ -212,77 +233,95 @@ glass:
inform_interval_seconds: 30
```
To reconfigure radio and hardware settings after installation, run:
```bash
sudo bash setup-radio-config.sh /etc/pymc_repeater
# or
sudo ./manage.sh
# then restart the service
sudo systemctl restart pymc-repeater
## Policy Engine
Use the policy engine to create packet management rules from the
web interface.
![Policy](docs/config-policy.png)
### Example: Drop Channel packets over two hops
![Policy Example](docs/policy-example1.png)
```
## Upgrading
To upgrade an existing installation to the latest version:
### Web Interface
The web interface can upgrade an installation or switch branches.
> [!NOTE]
> Docker installs cannot be upgraded or branch-switched from the web interface.
> Update the container image instead.
![Upgrade](docs/webui-upgrade.png)
### CLI
```bash
# Navigate to your pyMC_Repeater directory
cd pyMC_Repeater
# Run the upgrade script
sudo ./manage.sh
cd openhop-repeater
sudo bash ./manage.sh upgrade
```
The upgrade script will:
- Pull the latest code from the main branch
- Update all application files
- Update application files
- Upgrade Python dependencies if needed
- Restart the service automatically
- Preserve your existing configuration
- Preserve the existing configuration
---
## Proxmox LXC Installation
## Installing on Proxmox (LXC Container)
pyMC Repeater can run inside a Proxmox LXC container using a **CH341 USB-to-SPI adapter** for radio communication. This is ideal for headless, always-on deployments without dedicating a full Raspberry Pi.
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.
### Requirements
- **Proxmox VE 7.x or 8.x** host
- **CH341 USB-to-SPI adapter** (VID `1a86`, PID `5512`) connected to the Proxmox host
- **SX1262-based LoRa module** (e.g. Ebyte E22-900M30S) wired to the CH341 adapter
- Internet connectivity for the container
Software:
- Proxmox VE 7.x or 8.x host
- Internet access for the container
Hardware, choose one:
- CH341 USB-to-SPI adapter with VID `1a86` and PID `5512`, connected to the
Proxmox host and wired to an SX1262-based LoRa module such as an Ebyte
E22-900M30S
- TCP modem, such as MeshSmith EtherMesh
### One-Line Install
Run this on the **Proxmox host** (not inside a container):
Run this command on the Proxmox host, not inside a container:
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/rightup/pyMC_Repeater/dev/scripts/proxmox-install.sh)"
bash -c "$(curl -fsSL https://raw.githubusercontent.com/pyMC-dev/openhop-repeater/main/scripts/proxmox-install.sh)"
```
> **Tip:** Replace `dev` in the URL with whichever branch you want to install.
Replace `main` in the URL with another branch name if needed.
The installer will interactively prompt you for container settings (hostname, RAM, disk, bridge, etc.) 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
3. Install a host-side udev rule for the CH341 device
4. Clone the repository and pre-seed the config with CH341 GPIO pin mappings
5. Run `manage.sh install` inside the container
6. Display the dashboard URL when finished
1. Download a Debian 12 LXC template.
2. Create a privileged container with USB passthrough.
3. Install a host-side udev rule for the CH341 device.
4. Clone the repository and pre-seed CH341 GPIO pin mappings.
5. Run `manage.sh install` inside the container.
6. Display the dashboard URL.
### Default Container Settings
| Setting | Default |
|-----------|-----------------|
| Hostname | `pymc-repeater` |
| RAM | 1024 MB |
| Disk | 4 GB |
| CPU cores | 2 |
| Bridge | `vmbr0` |
| Storage | `local-lvm` |
| Password | `pymc` |
| Setting | Default |
|---------|---------|
| Container ID | Next available |
| Hostname | `openhop-repeater` |
| RAM | 1024 MB |
| Disk | 4 GB |
| CPU cores | 2 |
| Bridge | `vmbr0` |
| Storage | `local-lvm` |
| Password | `pymc` |
### After Installation
@@ -291,110 +330,169 @@ The installer will interactively prompt you for container settings (hostname, RA
pct enter <CTID>
# View service logs
journalctl -u pymc-repeater -f
# Access web dashboard
http://<container-ip>:8000
journalctl -u openhop-repeater -f
# Manage the repeater
cd /opt/pymc_repeater && bash manage.sh
cd /opt/openhop_repeater
bash manage.sh
```
Open the dashboard at:
```text
http://<container-ip>:8000
```
### CH341 GPIO Pin Mapping
The installer pre-configures the CH341 GPIO pins for an E22 module. These differ from the Raspberry Pi BCM pin numbers:
The Proxmox installer pre-configures CH341 GPIO pins for an E22 module. These
are not Raspberry Pi BCM pin numbers:
| Function | CH341 GPIO | Pi BCM (default) |
|----------|-----------|-------------------|
| CS | 0 | 21 |
| RXEN | 1 | -1 |
| Reset | 2 | 18 |
| Busy | 4 | 20 |
| IRQ | 6 | 16 |
| Function | CH341 GPIO | Pi BCM Default |
|----------|-----------:|---------------:|
| CS | 0 | 21 |
| RXEN | 1 | -1 |
| Reset | 2 | 18 |
| Busy | 4 | 20 |
| IRQ | 6 | 16 |
The installer also enables `use_dio3_tcxo` and `use_dio2_rf` for E22 modules.
### Troubleshooting (Proxmox)
### Troubleshooting
- **USB device not found**: Make sure the CH341 is plugged into the Proxmox host and shows up with `lsusb -d 1a86:5512`
- **Permission denied on USB**: The installer creates a host udev rule (`/etc/udev/rules.d/99-ch341.rules`). Run `udevadm trigger` on the host if needed
- **Container can't see USB**: Verify USB passthrough lines exist in `/etc/pve/lxc/<CTID>.conf`:
```
- **USB device not found**: confirm the CH341 is plugged into the Proxmox host
and appears in `lsusb -d 1a86:5512`.
- **Permission denied on USB**: the installer creates
`/etc/udev/rules.d/99-ch341.rules`. Run `udevadm trigger` on the host if
needed.
- **Container cannot see USB**: verify USB passthrough lines exist in
`/etc/pve/lxc/<CTID>.conf`:
```text
lxc.cgroup2.devices.allow: c 189:* rwm
lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir 0 0
```
- **NoBackendError (libusb)**: The installer installs `libusb-1.0-0` automatically. If you see this error, run `apt-get install libusb-1.0-0` inside the container
- **NoBackendError for libusb**: the installer installs `libusb-1.0-0`
automatically. If needed, run `apt-get install libusb-1.0-0` inside the
container.
## Uninstallation
```bash
sudo ./manage.sh
sudo bash ./manage.sh uninstall
```
This script will:
The uninstaller will:
- Stop and disable the systemd service
- Remove the installation directory
- Optionally remove configuration, logs, and user data
- Optionally remove the service user account
The script will prompt you for each optional removal step.
The script prompts before each optional removal step.
## Docker Compose
You can now run pyMC Repeater from within a [Docker Container](https://www.docker.com/). Checkout the example [Docker Compose](./docker-compose.yml) file before you get started. It will need some configuration changes based on what hardware you're using (USB vs SPI). Look at the commented out lines to see which hardware requires what lines and only enable what you need.
You can run openHop Repeater in Docker using the published image.
Here is what you'll need to do in order to get the container running:
1. Copy the `config.yaml.example` to `config.yaml`
Copy `.env.example` to `.env` before starting:
```bash
cp ./config.yaml.example ./config.yaml
cp .env.example .env
```
2. Run the configuration script and follow the prompts.
Set `DIALOUT_GID`, `GPIO_GID`, and `SPI_GID` from `getent group dialout`,
`getent group gpio`, and `getent group spi` if your host values are different.
Default storage should use Docker named volumes. This avoids Portainer creating
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/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`,
`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.
5. Pull and start the container.
```bash
sudo bash ./setup-radio-config.sh
docker compose up -d
```
3. Modify the `config.yaml` file with a unique web UI password. This allows you to bypass the `/setup` page when logging for the first time. You can find the value under `repeater.security.admin_password`. Change to _anything_ besides the default of `admin123`.
### Example `docker-compose.yml`
4. Configure the [docker compose](./docker-compose.yml) to your specific hardware and file paths. Be sure to comment-out or delete lines that aren't required for your hardware. Please note that your hardware devices might be at a different path than those listed in the docker compose file.
```yaml
services:
openhop-repeater:
image: ${PYMC_REPEATER_IMAGE:-pymcdev/openhop-repeater:main}
container_name: openhop-repeater
restart: unless-stopped
ports:
- 8000:8000
5. Build and start the container.
devices:
# SPI devices. Your paths may differ. Remove if not using SPI hardware.
- /dev/spidev0.0
- /dev/gpiochip0
```bash
docker compose up -d --force-recreate --build
# USB devices. Uncomment/change only if needed.
# - /dev/bus/usb/002:/dev/bus/usb/002
cap_add:
- SYS_RAWIO
group_add:
- "${DIALOUT_GID:-20}"
- "${GPIO_GID:-986}"
- "${SPI_GID:-989}"
- plugdev
volumes:
- ${PYMC_CONFIG_VOLUME:-openhop-repeater-config}:/etc/openhop_repeater
- ${PYMC_DATA_VOLUME:-openhop-repeater-data}:/var/lib/openhop_repeater
volumes:
openhop-repeater-config:
openhop-repeater-data:
```
## Roadmap / Planned Features
- [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery
- [ ] **Remote Administration over LoRa** - Manage repeater configuration remotely via LoRa mesh
- [ ] **Trace Request Handling** - Respond to trace/diagnostic requests from mesh network
## Roadmap
- [ ] **Public map integration**: submit repeater location and details to a
public map for discovery.
- [ ] **Remote administration over LoRa**: manage repeater configuration from
the mesh.
- [ ] **Trace request handling**: respond to trace and diagnostic requests from
the mesh network.
## Contributing
I welcome contributions! To contribute to pyMC_repeater:
Contributions are welcome.
1. Fork the repository and clone your fork.
2. Create a feature branch from `dev`:
1. **Fork the repository** and clone your fork
2. **Create a feature branch** from the `dev` branch:
```bash
git checkout -b feature/your-feature-name dev
```
3. **Make your changes** and test with **real** hardware
4. **Commit with clear messages**:
3. Make your changes and test with real hardware when possible.
4. Commit with a clear message:
```bash
git commit -m "feat: description of changes"
git commit -m "feat: describe your change"
```
5. **Push to your fork** and submit a **Pull Request to the `dev` branch**
- Include a clear description of the changes
- Reference any related issues
5. Push to your fork and open a pull request against `dev`.
Include a clear description, hardware tested, and any related issues.
### Development Setup
@@ -402,15 +500,16 @@ I welcome contributions! To contribute to pyMC_repeater:
# Install in development mode with dev tools (ruff, pytest, mypy, etc)
pip install -e ".[dev]"
# Setup pre-commit hooks for code quality
# Install pre-commit hooks
pip install pre-commit
pre-commit install
# Manually run pre-commit checks on all files
# Run checks manually
pre-commit run --all-files
```
**Note:** Hardware support (LoRa radio drivers) is included in the base installation automatically via `pymc_core[hardware]`.
Hardware support for LoRa radio drivers is included in the base installation
through `openhop_core[hardware]`.
Pre-commit hooks will automatically:
- Lint and auto-fix Python issues with Ruff
@@ -419,26 +518,29 @@ Pre-commit hooks will automatically:
## Support
- [Core Lib Documentation](https://rightup.github.io/pyMC_core/)
- [Meshcore Discord](https://discordapp.com/channels/1343693475589263471/1431414286974189639)
- [pyMC Core](https://github.com/openhop-dev/openhop-core)
- [MeshCore Discord](https://meshcore.gg)
## Disclaimer
**⚠️ Important Notice**
This software has been tested on actual hardware, but it is provided "as is"
without warranty of any kind, express or implied. No guarantee is made about
performance, compatibility, or suitability for any particular purpose.
This software has been tested on actual hardware, but is provided "as is" without warranty of any kind, express or implied. While care has been taken to ensure stability and reliability, I make no guarantees about the software's performance, compatibility, or suitability for any particular purpose.
By using this software, you acknowledge and agree that:
**By using this software, you acknowledge and agree that:**
- You use this software entirely at your own risk
- I hold no responsibility for any damage to hardware, data loss, or system failures
- You are responsible for ensuring compliance with local radio regulations and licensing requirements
- No support or warranty is guaranteed, though community assistance is available
- You use it entirely at your own risk.
- The author is not responsible for hardware damage, data loss, or system
failures.
- You are responsible for complying with local radio regulations and licensing
requirements.
- No support or warranty is guaranteed, though community assistance may be
available.
This software is intended for educational and experimental purposes. Always test in a controlled environment before deploying to production.
This software is intended for educational and experimental use. Always test in a
controlled environment before production deployment.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
This project is licensed under the MIT License. See [LICENSE](LICENSE) for
details.
+24 -24
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/rightup/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
@@ -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
+51 -9
View File
@@ -122,6 +122,11 @@ repeater:
# Controls how long users stay logged in before needing to re-authenticate
jwt_expiry_minutes: 60
# Policy engine configuration file.
# Relative paths are resolved from this config file directory.
policy:
policy_file: "policy.yaml"
# Local GPS receiver. When enabled, the daemon reads NMEA sentences from the
# configured source and exposes parsed data at /api/gps.
gps:
@@ -134,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)
@@ -145,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
@@ -271,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
@@ -369,18 +401,28 @@ radio:
coding_rate: 8
# Preamble length in symbols
preamble_length: 17
preamble_length: 32
# 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
# # Optional KISS key-up / CSMA tuning, forwarded to the modem firmware.
# # Omit to keep the wrapper/firmware defaults. For a host-managed repeater the
# # engine already staggers retransmits, so the firmware's p-persistent CSMA
# # backoff is redundant — kiss_persistence: 255 transmits as soon as the channel
# # is clear (carrier-sense still prevents talking over a packet already on air).
# kiss_persistence: 255 # 0-255; p(tx when clear) = (value+1)/256. Firmware default 63.
# kiss_slottime_ms: 20 # CSMA backoff slot; unused at persistence 255. Firmware default 100.
# tx_delay_ms: 50 # key-up delay; LoRa needs ~none. Firmware default 500.
# # kiss_txtail_ms: 0 # tail after TX (rarely needed)
# # 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
@@ -390,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
@@ -450,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:
@@ -484,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.
@@ -560,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
+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/rightup/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
+12
View File
@@ -0,0 +1,12 @@
services:
openhop-repeater:
image: openhop-repeater:local
build:
context: .
dockerfile: dockerfile
args:
PUID: ${PUID:-15888}
PGID: ${PGID:-15888}
DIALOUT_GID: ${DIALOUT_GID:-20}
GPIO_GID: ${GPIO_GID:-986}
SPI_GID: ${SPI_GID:-989}
+14 -11
View File
@@ -1,11 +1,7 @@
services:
pymc-repeater:
build:
context: .
args:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
container_name: pymc-repeater
openhop-repeater:
image: ${PYMC_REPEATER_IMAGE:-pymcdev/openhop-repeater:main}
container_name: openhop-repeater
restart: unless-stopped
ports:
- 8000:8000
@@ -18,9 +14,16 @@ services:
# SPI DEVICES PERMISSIONS
cap_add:
- SYS_RAWIO
# USB DEVICSE PERMISSIONS
# USB DEVICE PERMISSIONS
group_add:
- plugdev
- "${DIALOUT_GID:-20}"
- "${GPIO_GID:-986}"
- "${SPI_GID:-989}"
- plugdev
volumes:
- ./config:/etc/pymc_repeater
- ./data:/var/lib/pymc_repeater
- ${PYMC_CONFIG_VOLUME:-openhop-repeater-config}:/etc/openhop_repeater
- ${PYMC_DATA_VOLUME:-openhop-repeater-data}:/var/lib/openhop_repeater
volumes:
openhop-repeater-config:
openhop-repeater-data:
+48 -5
View File
@@ -1,8 +1,8 @@
#!/bin/sh
set -eu
INSTALL_DIR="${INSTALL_DIR:-/opt/pymc_repeater}"
CONFIG_DIR="${CONFIG_DIR:-/etc/pymc_repeater}"
INSTALL_DIR="${INSTALL_DIR:-/opt/openhop_repeater}"
CONFIG_DIR="${CONFIG_DIR:-/etc/openhop_repeater}"
CONFIG_PATH="${PYMC_REPEATER_CONFIG:-${CONFIG_DIR}/config.yaml}"
EXAMPLE_PATH="${CONFIG_DIR}/config.yaml.example"
BUNDLED_EXAMPLE_PATH="${INSTALL_DIR}/config.yaml.example"
@@ -13,16 +13,48 @@ YQ_CMD="${YQ_CMD:-/usr/local/bin/yq}"
mkdir -p "${CONFIG_DIR}"
print_permission_help() {
echo "If you are bind-mounting ./config or ./data, ensure the host paths are writable by ${RUNTIME_USER} (${RUNTIME_UID}:${RUNTIME_GID})." >&2
echo "For the default image user, run: sudo chown -R ${RUNTIME_UID}:${RUNTIME_GID} ./config ./data" >&2
}
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/openhop_repeater" >&2
echo "Then place the config at ./config/config.yaml." >&2
print_permission_help
exit 1
}
copy_or_die() {
src="$1"
dest="$2"
if ! cp "${src}" "${dest}"; then
echo "Failed to initialize ${dest} from ${src}." >&2
echo "If you are bind-mounting ./config.yaml, ensure the host path is writable by ${RUNTIME_USER} (${RUNTIME_UID}:${RUNTIME_GID})." >&2
print_permission_help
exit 1
fi
}
use_runtime_merged_config() {
src="$1"
runtime_dir="$(mktemp -d /tmp/openhop-repeater-config.XXXXXX)"
runtime_config="${runtime_dir}/config.yaml"
if ! cp "${src}" "${runtime_config}"; then
echo "Failed to prepare temporary merged config at ${runtime_config}; keeping the existing config." >&2
return 1
fi
CONFIG_PATH="${runtime_config}"
echo "Using merged config from ${CONFIG_PATH} for this container start only." >&2
echo "Fix the bind-mounted config ownership so future upgrades can persist merged config changes." >&2
print_permission_help
return 0
}
merge_config_from_example() {
config_path="$1"
@@ -62,15 +94,26 @@ merge_config_from_example() {
fi
if ! cmp -s "${config_path}" "${merged_config}"; then
copy_or_die "${merged_config}" "${config_path}"
if ! cp "${merged_config}" "${config_path}"; then
echo "Failed to update ${config_path} from merged config; the bind-mounted config is not writable." >&2
use_runtime_merged_config "${merged_config}" || true
fi
fi
cleanup_merge
trap - EXIT HUP INT TERM
}
if [ -d "${CONFIG_PATH}" ] && [ "$(basename "${CONFIG_PATH}")" = "config.yaml" ]; then
fail_bad_config_mount
fi
if [ ! -f "${EXAMPLE_PATH}" ] && [ -f "${BUNDLED_EXAMPLE_PATH}" ]; then
copy_or_die "${BUNDLED_EXAMPLE_PATH}" "${EXAMPLE_PATH}"
if ! cp "${BUNDLED_EXAMPLE_PATH}" "${EXAMPLE_PATH}"; then
echo "Could not copy bundled example config to ${EXAMPLE_PATH}; using bundled example for config merge only." >&2
print_permission_help
EXAMPLE_PATH="${BUNDLED_EXAMPLE_PATH}"
fi
fi
if [ -d "${CONFIG_PATH}" ]; then
+15 -5
View File
@@ -5,18 +5,24 @@ ARG USER=repeater
ARG GROUP=repeater
ARG PUID=15888
ARG PGID=15888
ARG DIALOUT_GID=20
ARG GPIO_GID=986
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} \
PUID=${PUID} \
PGID=${PGID}
PGID=${PGID} \
DIALOUT_GID=${DIALOUT_GID} \
GPIO_GID=${GPIO_GID} \
SPI_GID=${SPI_GID}
# Install runtime dependencies only
RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \
@@ -45,7 +51,11 @@ RUN arch="${TARGETARCH:-}" \
# Create the group and user in order to run without root privileges
RUN groupadd --gid "$PGID" "$GROUP" \
&& useradd --uid "$PUID" --gid "$PGID" --home-dir "$HOME_DIR" --create-home --shell /usr/bin/bash "$USER"
&& (getent group dialout >/dev/null || groupadd --gid "$DIALOUT_GID" dialout) \
&& groupadd --gid "$GPIO_GID" gpio \
&& groupadd --gid "$SPI_GID" spi \
&& useradd --uid "$PUID" --gid "$PGID" --home-dir "$HOME_DIR" --create-home --shell /usr/bin/bash "$USER" \
&& usermod -a -G dialout,gpio,spi "$USER"
# Create runtime directories
RUN mkdir -p ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} \
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

+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.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 327 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+31
View File
@@ -0,0 +1,31 @@
# Copy this file to .env before running Docker Compose:
# cp .env.example .env
# Published image to run. Use a different repository or tag if you are testing
# a fork or a specific release.
PYMC_REPEATER_IMAGE=pymcdev/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/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/openhop-repeater/config
# PYMC_DATA_VOLUME=/opt/openhop-repeater/data
# SPI/GPIO access uses the host's numeric group IDs. Check your host with:
# getent group gpio
# getent group spi
# Example output:
# gpio:x:997:
# spi:x:999:
# Put the third field from each output line below.
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.
PUID=15888
PGID=15888
+291 -114
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,7 +27,68 @@ 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
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"
}
# Create (or re-create) the dedicated venv for openhop_repeater
ensure_venv() {
if [ ! -x "$VENV_PYTHON" ]; then
echo ">>> Creating virtual environment at $VENV_DIR ..."
@@ -40,35 +107,43 @@ migrate_to_venv() {
ensure_venv
# 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 +192,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 +230,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 +278,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 +294,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 +303,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 +335,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 +360,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,23 +416,29 @@ 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
disable_legacy_services
(
echo "10"; echo "# Adding user to hardware groups..."
for grp in plugdev dialout gpio i2c spi; do
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 +468,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 +479,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 +511,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 +552,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
@@ -469,22 +570,36 @@ if [ ! -x "$VENV_PYTHON" ]; then
"$VENV_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 ----
@@ -502,11 +617,20 @@ if [ -n "$ARCH_TAG" ]; then
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
fi
# ---- Install pymc_repeater from git ----
exec "$VENV_PIP" install \
# ---- Install openhop_repeater from git ----
if "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
"openhop_repeater[hardware] @ git+https://github.com/rightup/openhop-repeater.git@${CHANNEL}"; then
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/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
else
exit 1
fi
UPGRADEEOF
chmod 0755 /usr/local/bin/pymc-do-upgrade
@@ -514,13 +638,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 ""
@@ -547,7 +671,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
@@ -651,7 +775,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."
@@ -722,7 +846,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
@@ -736,6 +860,14 @@ 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"
echo "[2/9] Backing up configuration..."
if [ -d "$CONFIG_DIR" ]; then
@@ -768,11 +900,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..."
@@ -788,8 +920,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"
@@ -801,15 +933,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}')
@@ -817,36 +949,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
@@ -855,7 +987,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
@@ -873,22 +1005,36 @@ if [ ! -x "$VENV_PYTHON" ]; then
"$VENV_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 ----
@@ -906,11 +1052,20 @@ python3 -m pip uninstall -y pymc_core 2>/dev/null || true
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
fi
# ---- Install pymc_repeater from git ----
exec "$VENV_PIP" install \
# ---- Install openhop_repeater from git ----
if "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
"openhop_repeater[hardware] @ git+https://github.com/rightup/openhop-repeater.git@${CHANNEL}"; then
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/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
else
exit 1
fi
UPGRADEEOF
chmod 0755 /usr/local/bin/pymc-do-upgrade
echo " ✓ Permissions updated"
@@ -921,7 +1076,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 ""
@@ -951,7 +1106,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
@@ -1073,10 +1228,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 ""
@@ -1087,24 +1242,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
@@ -1112,9 +1271,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
}
@@ -1149,9 +1308,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
@@ -1165,18 +1324,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
@@ -1236,6 +1395,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"
@@ -1248,6 +1420,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
@@ -1288,32 +1461,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)"
@@ -1337,7 +1514,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
@@ -1375,7 +1552,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]",
"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]",
]
# 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]
+82 -30
View File
@@ -13,7 +13,7 @@
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"preamble_length": 17,
"preamble_length": 32,
"is_waveshare": true
},
"uconsole_aiov1": {
@@ -29,9 +29,9 @@
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"preamble_length": 17
"preamble_length": 32
},
"uconsole_aio_v2": {
"uconsole_aio_v2": {
"name": "uConsole LoRa Module aio v2",
"bus_id": 1,
"cs_id": 0,
@@ -44,7 +44,7 @@
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"preamble_length": 17,
"preamble_length": 32,
"use_dio3_tcxo": true,
"use_dio2_rf": true
},
@@ -60,9 +60,9 @@
"rxen_pin": 12,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"tx_power": 18,
"use_dio3_tcxo": true,
"preamble_length": 17
"preamble_length": 32
},
"pimesh-1w-v2": {
"name": "PiMesh-1W (V2)",
@@ -77,10 +77,10 @@
"txled_pin": -1,
"rxled_pin": -1,
"en_pin": 26,
"tx_power": 22,
"tx_power": 18,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
"preamble_length": 32
},
"meshadv-mini": {
"name": "MeshAdv Mini",
@@ -95,7 +95,7 @@
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"preamble_length": 17
"preamble_length": 32
},
"meshadv": {
"name": "MeshAdv",
@@ -111,7 +111,7 @@
"rxled_pin": -1,
"tx_power": 22,
"use_dio3_tcxo": true,
"preamble_length": 17
"preamble_length": 32
},
"zebra": {
"name": "ZebraHat-1W",
@@ -128,7 +128,41 @@
"tx_power": 18,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
"preamble_length": 32
},
"zebra-duo-hat-r0": {
"name": "ZebraHatDuo-R0-1W",
"bus_id": 0,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 18,
"busy_pin": 23,
"irq_pin": 24,
"txen_pin": -1,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 32
},
"zebra-duo-hat-r1": {
"name": "ZebraHatDuo-R1-1W",
"bus_id": 0,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 17,
"busy_pin": 27,
"irq_pin": 22,
"txen_pin": -1,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 32
},
"femtofox-1W-SX": {
"name": "FemtoFox SX1262 (1W)",
@@ -136,7 +170,6 @@
"cs_id": 0,
"cs_pin": 16,
"gpio_chip": 1,
"use_gpiod_backend": true,
"reset_pin": 25,
"busy_pin": 22,
"irq_pin": 23,
@@ -144,9 +177,9 @@
"rxen_pin": 24,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 30,
"tx_power": 22,
"use_dio3_tcxo": true,
"preamble_length": 17
"preamble_length": 32
},
"femtofox-2W-SX": {
"name": "FemtoFox SX1262 (2W)",
@@ -154,7 +187,6 @@
"cs_id": 0,
"cs_pin": 16,
"gpio_chip": 1,
"use_gpiod_backend": true,
"reset_pin": 25,
"busy_pin": 22,
"irq_pin": 23,
@@ -181,7 +213,7 @@
"tx_power": 8,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
"preamble_length": 32
},
"nebra-duo-hat": {
"name": "NebraDuo-E22P-1W",
@@ -198,7 +230,7 @@
"tx_power": 18,
"use_dio3_tcxo": true,
"use_dio2_rf": true,
"preamble_length": 17
"preamble_length": 32
},
"ch341-usb-sx1262": {
"name": "CH341 USB-SPI + SX1262 (example)",
@@ -221,7 +253,7 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 17,
"preamble_length": 32,
"is_waveshare": false
},
"ultrapeater-e22": {
@@ -239,7 +271,7 @@
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
},
@@ -259,7 +291,7 @@
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
},
@@ -280,7 +312,7 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 17,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
},
@@ -301,11 +333,11 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 17,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
},
"ultrapeaterzero-e22": {
"ultrapeaterzero-e22": {
"name": "Zindello Industries UltraPeaterZero E22",
"bus_id": 0,
"cs_id": 0,
@@ -321,9 +353,9 @@
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17
"preamble_length": 32
},
"ultrapeaterzero-e22p": {
"ultrapeaterzero-e22p": {
"name": "Zindello Industries UltraPeaterZero E22P",
"bus_id": 0,
"cs_id": 0,
@@ -339,7 +371,27 @@
"tx_power": 22,
"use_dio2_rf": false,
"use_dio3_tcxo": true,
"preamble_length": 17
"preamble_length": 32
},
"bq-station-g3": {
"name": "BQ Voyage Station G3",
"bus_id": 0,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 16,
"busy_pin": 24,
"irq_pin": 22,
"txen_pin": -1,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 19,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
},
"pymc_usb": {
"name": "pymc_usb modem (USB-CDC)",
@@ -347,7 +399,7 @@
"connection_type": "usb",
"radio_type": "pymc_usb",
"tx_power": 22,
"preamble_length": 16
"preamble_length": 32
},
"pymc_tcp": {
"name": "pymc_tcp modem (Wi-Fi / Ethernet)",
@@ -355,15 +407,15 @@
"connection_type": "network",
"radio_type": "pymc_tcp",
"tx_power": 22,
"preamble_length": 16
"preamble_length": 32
},
"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,
"preamble_length": 17
"preamble_length": 32
}
}
}
+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,
+17 -7
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,
@@ -59,13 +59,23 @@ class CompanionFrameServer(_BaseFrameServer):
# -----------------------------------------------------------------
async def _persist_companion_message(self, msg_dict: dict) -> None:
"""Persist message to SQLite and pop from bridge queue."""
"""Persist message to SQLite and pop from bridge queue.
The bridge's ``offline_queue_size`` (``message_queue._max_size``) doubles
as the SQLite retention limit: 0 disables offline storage entirely, so the
message is dropped instead of persisted.
"""
if not self.sqlite_handler:
return
retention = getattr(self.bridge.message_queue, "_max_size", None)
if retention == 0:
self.bridge.message_queue.pop_last()
return
await asyncio.to_thread(
self.sqlite_handler.companion_push_message,
self.companion_hash,
msg_dict,
retention,
)
self.bridge.message_queue.pop_last()
+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()
+255
View File
@@ -1,7 +1,46 @@
"""Shared utilities for Companion (e.g. validation for config sync)."""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from openhop_core.companion.constants import DEFAULT_MAX_CONTACTS
logger = logging.getLogger(__name__)
_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 openhop_core defaults).
_COMPANION_IGNORED_BRIDGE_KEYS = frozenset({"max_channels", "adv_type"})
# Contact flag bit 0 marks a favourite (protected from forced-trim eviction).
_CONTACT_FLAG_FAVOURITE = 0x01
class CompanionContactCapacityError(Exception):
"""Persisted companion contacts exceed configured max_contacts."""
def __init__(
self,
companion_hash: str,
stored_count: int,
max_contacts: int,
companion_name: Optional[str] = None,
) -> None:
self.companion_hash = companion_hash
self.stored_count = stored_count
self.max_contacts = max_contacts
self.companion_name = companion_name
label = f"'{companion_name}'" if companion_name else companion_hash
super().__init__(
f"Companion {label}: {stored_count} contacts in storage exceeds "
f"max_contacts={max_contacts}. Increase max_contacts or remove contacts before starting."
)
def normalize_companion_identity_key(identity_key: str) -> str:
"""Strip whitespace and remove optional 0x prefix so fromhex() is consistent across installs."""
@@ -23,3 +62,219 @@ def validate_companion_node_name(value: str) -> str:
if any(c in s for c in _INVALID_NODE_NAME_CHARS):
raise ValueError("node_name contains invalid characters")
return s
def parse_positive_int(value: Any, field_name: str, *, minimum: int = 1) -> int:
"""Parse a positive integer from config or API input."""
try:
n = int(value)
except (TypeError, ValueError) as e:
raise ValueError(f"{field_name} must be a positive integer") from e
if n < minimum:
raise ValueError(f"{field_name} must be >= {minimum}")
return n
def parse_companion_bridge_kwargs(settings: dict) -> Dict[str, int]:
"""Extract optional RepeaterCompanionBridge kwargs from companion settings.
Only ``max_contacts`` and ``offline_queue_size`` are honored. ``max_channels`` and
``adv_type`` are ignored with a warning if present.
"""
if not settings:
return {}
for key in _COMPANION_IGNORED_BRIDGE_KEYS:
if key in settings:
logger.warning(
"Companion setting %r is not supported and will be ignored (fixed default)",
key,
)
kwargs: Dict[str, int] = {}
if "max_contacts" in settings:
max_contacts = parse_positive_int(settings["max_contacts"], "max_contacts")
kwargs["max_contacts"] = max_contacts
if "offline_queue_size" in settings:
# 0 is valid and means "off" (no offline message storage).
kwargs["offline_queue_size"] = parse_positive_int(
settings["offline_queue_size"], "offline_queue_size", minimum=0
)
return kwargs
def effective_max_contacts(bridge_kwargs: Dict[str, int]) -> int:
"""Return max_contacts from parsed kwargs or openhop_core default."""
return bridge_kwargs.get("max_contacts", DEFAULT_MAX_CONTACTS)
def merge_companion_settings_update(current_settings: dict, patch: dict) -> Dict[str, Any]:
"""Merge a companion settings PATCH into current settings.
Raises:
ValueError: Unknown setting or invalid bridge setting value.
"""
merged = dict(current_settings or {})
for key, value in patch.items():
if key not in COMPANION_SETTINGS_ALLOWLIST:
raise ValueError(f"Unknown companion setting: {key}")
if key in COMPANION_BRIDGE_SETTING_KEYS:
parsed = parse_companion_bridge_kwargs({key: value})
merged[key] = parsed[key]
else:
merged[key] = value
return merged
def validate_companion_config_capacity(
identity: dict,
sqlite_handler: Any,
*,
companion_name: Optional[str] = None,
settings: Optional[dict] = None,
) -> None:
"""Raise CompanionContactCapacityError if persisted contacts exceed configured max_contacts."""
if sqlite_handler is None:
return
identity_key = identity.get("identity_key")
if not identity_key:
return
merged_settings = settings if settings is not None else (identity.get("settings") or {})
max_contacts = effective_max_contacts(parse_companion_bridge_kwargs(merged_settings))
companion_hash = companion_hash_str_from_identity_key(identity_key)
check_companion_contact_capacity(
companion_hash,
max_contacts,
sqlite_handler,
companion_name=companion_name,
)
def check_companion_contact_capacity(
companion_hash: str,
max_contacts: int,
sqlite_handler: Any,
*,
companion_name: Optional[str] = None,
) -> None:
"""Raise CompanionContactCapacityError if persisted contacts exceed max_contacts."""
if sqlite_handler is None:
return
stored_count = sqlite_handler.companion_count_contacts(companion_hash)
if stored_count > max_contacts:
raise CompanionContactCapacityError(
companion_hash, stored_count, max_contacts, companion_name=companion_name
)
def select_companion_contacts_to_trim(contacts, max_contacts: int):
"""Select which persisted contacts to keep/remove to fit ``max_contacts``.
Mirrors ``ContactStore.add_or_overwrite`` eviction: the oldest non-favourite
contacts (by ``lastmod``) are removed first; favourites (flags bit 0) are
never evicted.
Returns:
(keep, removed): lists of contact dicts.
Raises:
ValueError: favourites alone exceed ``max_contacts`` (cannot trim).
"""
contacts = list(contacts)
if len(contacts) <= max_contacts:
return contacts, []
favourites = [c for c in contacts if int(c.get("flags", 0)) & _CONTACT_FLAG_FAVOURITE]
if len(favourites) > max_contacts:
raise ValueError(
f"Cannot trim to max_contacts={max_contacts}: "
f"{len(favourites)} favourite contacts cannot be evicted"
)
non_favourites = [c for c in contacts if not int(c.get("flags", 0)) & _CONTACT_FLAG_FAVOURITE]
# Keep the newest non-favourites by lastmod; evict the oldest.
non_favourites.sort(key=lambda c: int(c.get("lastmod", 0)))
keep_count = max_contacts - len(favourites)
removed = non_favourites[: len(non_favourites) - keep_count]
kept_non_favourites = non_favourites[len(non_favourites) - keep_count :]
return favourites + kept_non_favourites, removed
def trim_companion_contacts_to_fit(
sqlite_handler: Any, companion_hash: str, max_contacts: int
) -> int:
"""Trim persisted contacts (favourite-aware) down to ``max_contacts``.
Loads the companion's contacts, evicts the oldest non-favourites per
:func:`select_companion_contacts_to_trim`, persists the kept set, and returns
the number removed (0 if already within the limit).
Raises:
ValueError: favourites alone exceed ``max_contacts`` (cannot trim).
RuntimeError: persisting the trimmed contact list failed.
"""
if sqlite_handler is None:
return 0
contacts = sqlite_handler.companion_load_contacts(companion_hash)
keep, removed = select_companion_contacts_to_trim(contacts, max_contacts)
if not removed:
return 0
if not sqlite_handler.companion_save_contacts(companion_hash, keep):
raise RuntimeError(f"Failed to persist trimmed contacts for {companion_hash}")
return len(removed)
def enforce_companion_contact_capacity(
companion_hash: str,
max_contacts: int,
sqlite_handler: Any,
*,
trim: bool = False,
companion_name: Optional[str] = None,
) -> int:
"""Ensure persisted contacts fit ``max_contacts`` at load time.
With ``trim=False`` (default) this is a guard: it raises
:class:`CompanionContactCapacityError` when over capacity. With ``trim=True``
(the ``trim_contacts_on_overflow`` policy) it trims favourite-aware to fit,
persists, and returns the number of contacts removed.
"""
if not trim:
check_companion_contact_capacity(
companion_hash, max_contacts, sqlite_handler, companion_name=companion_name
)
return 0
return trim_companion_contacts_to_fit(sqlite_handler, companion_hash, max_contacts)
def format_companion_bridge_limits(bridge_kwargs: Dict[str, int]) -> str:
"""Format non-default bridge limits for log lines."""
if not bridge_kwargs:
return ""
parts = [f"{k}={v}" for k, v in sorted(bridge_kwargs.items())]
return ", " + ", ".join(parts)
def companion_hash_str_from_identity_key(identity_key: Any) -> str:
"""Derive companion_hash storage key (0xHH) from an identity_key config value."""
from openhop_core import LocalIdentity
if isinstance(identity_key, str):
key_bytes = bytes.fromhex(normalize_companion_identity_key(identity_key))
elif isinstance(identity_key, bytes):
key_bytes = identity_key
else:
raise ValueError("identity_key has unknown type")
pubkey_byte = LocalIdentity(seed=key_bytes).get_public_key()[0]
return f"0x{pubkey_byte:02x}"
# All companion settings writable via identity API (tcp + bridge power-user keys).
COMPANION_SETTINGS_ALLOWLIST = frozenset(
{
"node_name",
"tcp_port",
"bind_address",
"tcp_timeout",
# Persistent opt-in: trim oldest non-favourite contacts to fit max_contacts
# at load instead of refusing to start when over capacity.
"trim_contacts_on_overflow",
*COMPANION_BRIDGE_SETTING_KEYS,
}
)
+137 -23
View File
@@ -6,9 +6,67 @@ from typing import Any, Dict, Optional, overload
import yaml
from repeater.policy_engine import default_policy_engine_config
logger = logging.getLogger("Config")
def _resolve_policy_config_path(config: Dict[str, Any], config_path: str) -> Path:
policy_section = config.get("policy", {}) if isinstance(config.get("policy"), dict) else {}
configured = policy_section.get("policy_file") or "policy.yaml"
base_dir = Path(config_path).expanduser().resolve().parent
p = Path(str(configured)).expanduser()
if not p.is_absolute():
p = (base_dir / p).resolve()
return p
def _load_policy_engine_config(config: Dict[str, Any], config_path: str) -> Dict[str, Any]:
policy_path = _resolve_policy_config_path(config, config_path)
defaults = default_policy_engine_config()
if not policy_path.exists():
logger.info("Policy file not found at %s, policy engine disabled", policy_path)
config["policy_engine"] = defaults
config["policy_file_path"] = str(policy_path)
return config
try:
with open(policy_path) as f:
loaded = yaml.safe_load(f) or {}
if isinstance(loaded, dict) and isinstance(loaded.get("policy_engine"), dict):
policy_cfg = loaded.get("policy_engine")
elif isinstance(loaded, dict):
policy_cfg = loaded
else:
policy_cfg = {}
merged = dict(defaults)
if isinstance(policy_cfg, dict):
merged.update(policy_cfg)
if not isinstance(merged.get("rules"), list):
merged["rules"] = []
if not isinstance(merged.get("objects"), dict):
merged["objects"] = {}
config["policy_engine"] = merged
config["policy_file_path"] = str(policy_path)
logger.info("Loaded policy config from %s", policy_path)
return config
except Exception as e:
logger.warning(
"Failed to load policy config from %s: %s. Policy engine disabled.",
policy_path,
e,
)
config["policy_engine"] = defaults
config["policy_file_path"] = str(policy_path)
return config
class NullRadio:
"""No-op radio used when radio_type disables hardware initialization."""
@@ -43,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 = (
@@ -102,7 +195,7 @@ 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("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml")
# Check if config file exists
if not Path(config_path).exists():
@@ -142,7 +235,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:
@@ -156,6 +249,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,
@@ -207,6 +306,8 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
config["logging"] = {}
config["logging"]["level"] = os.getenv("PYMC_REPEATER_LOG_LEVEL")
config = _load_policy_engine_config(config, config_path)
return config
@@ -222,7 +323,7 @@ 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("PYMC_REPEATER_CONFIG", "/etc/openhop_repeater/config.yaml")
try:
# Create backup of existing config
@@ -286,16 +387,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,7 +475,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")
@@ -391,8 +492,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)
@@ -433,7 +534,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:
@@ -453,16 +554,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")
@@ -481,7 +582,20 @@ def get_radio_for_board(board_config: dict):
"spreading_factor": int(radio_cfg.get("spreading_factor", 8)),
"coding_rate": int(radio_cfg.get("coding_rate", 8)),
"tx_power": int(radio_cfg.get("tx_power", 14)),
"preamble_length": int(radio_cfg.get("preamble_length", 32)),
}
# Optional KISS key-up / CSMA tuning, forwarded to the modem firmware (via
# SetHardware) only when present so the wrapper keeps its own defaults otherwise.
# For a host-managed repeater the engine already staggers retransmits, so the
# firmware's p-persistent CSMA backoff is usually redundant; set
# kiss_persistence: 255 to transmit as soon as the channel is clear.
for _key in ("tx_delay_ms", "kiss_persistence", "kiss_slottime_ms", "kiss_txtail_ms"):
if kiss_config.get(_key) is not None:
radio_config[_key] = int(kiss_config[_key])
if kiss_config.get("kiss_full_duplex") is not None:
radio_config["kiss_full_duplex"] = bool(kiss_config["kiss_full_duplex"])
radio = KissModemWrapper(
port=port,
baudrate=baudrate,
@@ -499,11 +613,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 "
"PR pyMC-dev/openhop-core#68 (merged 2026-05-13). "
"Reinstall the [hardware] extra to pick it up."
) from None
@@ -539,15 +653,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 "
"PR pyMC-dev/openhop-core#68 (merged 2026-05-13). "
"Reinstall the [hardware] extra to pick it up."
) from None
@@ -581,7 +695,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}. "
+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 {})},
}
+105 -8
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
@@ -860,6 +869,47 @@ class SQLiteHandler:
logger.error(f"Failed to get CRC error history: {e}")
return []
def get_policy_event_counts(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 60,
) -> list:
"""Return policy-blocked packet counts grouped by bucket timestamp.
A policy event is represented by a packet drop reason that starts with
"Policy blocked packet".
"""
try:
bucket_seconds = max(1, int(bucket_seconds))
with self._connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
COUNT(*) AS count
FROM packets
WHERE timestamp >= ?
AND timestamp <= ?
AND drop_reason LIKE 'Policy blocked packet%'
GROUP BY bucket_ts
ORDER BY bucket_ts ASC
""",
(bucket_seconds, bucket_seconds, start_timestamp, end_timestamp),
).fetchall()
return [
{
"timestamp": int(row["bucket_ts"]),
"count": int(row["count"]),
}
for row in rows
]
except Exception as e:
logger.error(f"Failed to get policy event counts: {e}")
return []
def get_packet_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -1145,11 +1195,15 @@ class SQLiteHandler:
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",
@@ -1221,13 +1275,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}")
@@ -1561,6 +1617,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
@@ -1589,12 +1650,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}")
@@ -1701,7 +1764,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)
@@ -2290,6 +2353,20 @@ class SQLiteHandler:
return 0
# Companion persistence methods
def companion_count_contacts(self, companion_hash: str) -> int:
"""Return the number of persisted contacts for a companion."""
try:
with self._connect() as conn:
cursor = conn.execute(
"SELECT COUNT(*) FROM companion_contacts WHERE companion_hash = ?",
(companion_hash,),
)
row = cursor.fetchone()
return int(row[0]) if row else 0
except Exception as e:
logger.error(f"Failed to count companion contacts: {e}")
return 0
def companion_load_contacts(self, companion_hash: str) -> List[Dict]:
"""Load contacts for a companion from storage."""
try:
@@ -2586,7 +2663,9 @@ class SQLiteHandler:
logger.error(f"Failed to load companion messages: {e}")
return []
def companion_push_message(self, companion_hash: str, msg: Dict) -> bool:
def companion_push_message(
self, companion_hash: str, msg: Dict, max_messages: Optional[int] = None
) -> bool:
"""Append a message to the companion's queue.
Deduplicates by (companion_hash, packet_hash) using INSERT OR IGNORE
@@ -2594,6 +2673,9 @@ class SQLiteHandler:
previous SELECT + INSERT round-trip (two statements, two SD-card reads)
with a single atomic statement.
When ``max_messages`` is set, the oldest rows beyond that retention limit
are trimmed after a successful insert (power-user ``offline_queue_size``).
Returns True if inserted, False if the message was a duplicate (skipped).
"""
try:
@@ -2622,8 +2704,23 @@ class SQLiteHandler:
time.time(),
),
)
inserted = cursor.rowcount > 0
if inserted and max_messages is not None:
# Keep the newest `max_messages` rows; drop older overflow.
conn.execute(
"""
DELETE FROM companion_messages
WHERE companion_hash = ? AND id NOT IN (
SELECT id FROM companion_messages
WHERE companion_hash = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
)
""",
(companion_hash, companion_hash, max_messages),
)
conn.commit()
return cursor.rowcount > 0
return inserted
except Exception as e:
logger.error(f"Failed to push companion message: {e}")
return False
+110 -39
View File
@@ -1,5 +1,7 @@
import asyncio
import concurrent.futures
import logging
import threading
import time
from typing import Optional
@@ -20,6 +22,17 @@ 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)
@@ -55,8 +68,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 +83,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 +172,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 +187,77 @@ 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)
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)."""
self.sqlite_handler.store_packet(packet_record)
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
# 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.
@@ -310,6 +360,18 @@ class StorageCollector:
def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list:
return self.sqlite_handler.get_crc_error_history(hours, limit)
def get_policy_event_counts(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 60,
) -> list:
return self.sqlite_handler.get_policy_event_counts(
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
bucket_seconds=bucket_seconds,
)
def get_packet_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_packet_stats(hours)
@@ -409,6 +471,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():
+79 -20
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,10 +19,11 @@ 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
from repeater.policy_engine import PolicyDecision, PolicyEngine
logger = logging.getLogger("RepeaterHandler")
@@ -65,6 +66,7 @@ class RepeaterHandler(BaseHandler):
self.local_hash_bytes = local_hash_bytes or bytes([local_hash])
self.send_advert_func = send_advert_func
self.airtime_mgr = AirtimeManager(config)
self.policy_engine = PolicyEngine.from_runtime_config(config)
self.seen_packets = OrderedDict()
self.cache_ttl = max(
300, config.get("repeater", {}).get("cache_ttl", 3600)
@@ -90,7 +92,7 @@ class RepeaterHandler(BaseHandler):
"spreading_factor": getattr(radio, "spreading_factor", 8),
"bandwidth": getattr(radio, "bandwidth", 125000),
"coding_rate": getattr(radio, "coding_rate", 8),
"preamble_length": getattr(radio, "preamble_length", 17),
"preamble_length": getattr(radio, "preamble_length", 32),
"frequency": getattr(radio, "frequency", 915000000),
"tx_power": getattr(radio, "tx_power", 14),
}
@@ -162,7 +164,7 @@ class RepeaterHandler(BaseHandler):
async def __call__(
self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False
) -> None:
) -> bool:
if metadata is None:
metadata = {}
@@ -191,6 +193,38 @@ class RepeaterHandler(BaseHandler):
allow_forward = mode == "forward"
allow_local_tx = mode != "no_tx"
policy_context = {
"route_type": route_type,
"payload_type": packet.get_payload_type()
if hasattr(packet, "get_payload_type")
else None,
"payload_length": len(packet.payload or b""),
"path_hash_size": packet.get_path_hash_size()
if hasattr(packet, "get_path_hash_size")
else None,
"hop_count": packet.get_path_hash_count()
if hasattr(packet, "get_path_hash_count")
else None,
"rssi": metadata.get("rssi", 0),
"snr": metadata.get("snr", 0.0),
"local_transmission": local_transmission,
"mode": mode,
}
prechecked_decision = metadata.get("_policy_precheck_decision")
if isinstance(prechecked_decision, PolicyDecision):
policy_decision = prechecked_decision
else:
policy_decision = self.policy_engine.evaluate(packet, policy_context)
policy_reason = None
if policy_decision.matched:
logger.info(policy_decision.reason)
if policy_decision.action == "drop":
allow_forward = False
allow_local_tx = False
policy_reason = self._policy_drop_reason(policy_decision)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, "
@@ -259,19 +293,23 @@ class RepeaterHandler(BaseHandler):
f"Duty-cycle limit: deferring local TX by {wait_time:.1f}s "
f"(airtime={airtime_ms:.1f}ms)"
)
self.forwarded_count += 1
transmitted = True
tx_task = await self.schedule_retransmit(
fwd_pkt, deferred_delay, airtime_ms, local_transmission=True
)
try:
await tx_task
tx_success = await tx_task
except Exception as e:
self.forwarded_count -= 1
transmitted = False
drop_reason = "TX failed (deferred)"
logger.warning(f"Deferred local TX failed: {e}")
raise
if not tx_success:
transmitted = False
drop_reason = "TX failed (deferred)"
self.dropped_count += 1
else:
self.forwarded_count += 1
transmitted = True
tx_metadata = getattr(fwd_pkt, "_tx_metadata", None)
if tx_metadata:
lbt_attempts = tx_metadata.get("lbt_attempts", 0)
@@ -292,19 +330,23 @@ class RepeaterHandler(BaseHandler):
self.dropped_count += 1
drop_reason = "Duty cycle limit"
else:
self.forwarded_count += 1
transmitted = True
tx_task = await self.schedule_retransmit(
fwd_pkt, delay, airtime_ms, local_transmission=local_transmission
)
try:
await tx_task
tx_success = await tx_task
except Exception as e:
self.forwarded_count -= 1
transmitted = False
drop_reason = "TX failed"
logger.warning(f"Local TX failed: {e}")
raise
if not tx_success:
transmitted = False
drop_reason = "TX failed"
self.dropped_count += 1
else:
self.forwarded_count += 1
transmitted = True
tx_metadata = getattr(fwd_pkt, "_tx_metadata", None)
if tx_metadata:
lbt_attempts = tx_metadata.get("lbt_attempts", 0)
@@ -321,9 +363,9 @@ class RepeaterHandler(BaseHandler):
self.dropped_count += 1
# Determine drop reason
if local_transmission and not allow_local_tx:
drop_reason = "No TX mode"
drop_reason = policy_reason or "No TX mode"
elif not allow_forward:
drop_reason = "Repeat disabled"
drop_reason = policy_reason or "Repeat disabled"
else:
# Check if packet has a specific drop reason set by handlers
drop_reason = processed_packet.drop_reason or self._get_drop_reason(
@@ -415,6 +457,14 @@ class RepeaterHandler(BaseHandler):
# Not a duplicate or first occurrence
self._append_recent_packet(packet_record)
return transmitted
@staticmethod
def _policy_drop_reason(decision: PolicyDecision) -> str:
if decision.rule_id is None:
return "Policy blocked packet"
return f"Policy blocked packet (rule {decision.rule_id})"
def log_trace_record(self, packet_record: dict) -> None:
"""Manually log a packet trace record (used by external callers)"""
self._append_recent_packet(packet_record)
@@ -770,7 +820,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()
@@ -1128,10 +1178,18 @@ class RepeaterHandler(BaseHandler):
"Packet dropped at TX time: duty-cycle exceeded (airtime=%.1fms)",
airtime_ms,
)
return
return False
try:
await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
if not sent:
logger.warning(
"Retransmit failed (attempt %d): dispatcher returned false",
attempt + 1,
)
if local_transmission and attempt == 0:
continue
return False
self._record_packet_sent(fwd_pkt)
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
@@ -1140,13 +1198,14 @@ class RepeaterHandler(BaseHandler):
f"Retransmitted packet ({packet_size} bytes, "
f"{airtime_ms:.1f}ms airtime)"
)
return
return True
except Exception as e:
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
if local_transmission and attempt == 0:
pass # release lock, outer loop sleeps, then retries
else:
raise
return False
return asyncio.create_task(delayed_send())
+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
+2 -5
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")
@@ -120,9 +120,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'}'")
+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
+30 -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.
@@ -7,11 +7,21 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio
import logging
import secrets
from pymc_core.node.handlers.control import ControlHandler
from openhop_core.node.handlers.control import ControlHandler
logger = logging.getLogger("DiscoveryHelper")
# Default upper bound (ms) for the randomized pre-send jitter applied to node
# discovery responses. A node-discover request is a broadcast that every
# in-range repeater answers at once, so without jitter they all transmit at the
# same engine-scheduled instant and collide. Mirrors the firmware, which spreads
# these replies deliberately (MyMesh.cpp:797, sendZeroHop with
# getRetransmitDelay*4). Safe to be generous: the requester's discovery window is
# 60s (firmware pending_discover_until = futureMillis(60000)).
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater."""
@@ -23,6 +33,7 @@ class DiscoveryHelper:
node_type: int = 2,
log_fn=None,
debug_log_fn=None,
response_jitter_ms: int = DEFAULT_DISCOVERY_RESPONSE_JITTER_MS,
):
"""
Initialize the discovery helper.
@@ -34,10 +45,14 @@ class DiscoveryHelper:
log_fn: Optional logging function for ControlHandler
debug_log_fn: Optional logging for verbose ControlHandler messages (e.g. callback
presence). Pass logger.debug to avoid INFO noise when forwarding to companions.
response_jitter_ms: Upper bound (ms) for the randomized delay added before
transmitting a discovery response, to avoid multiple repeaters colliding
when answering the same broadcast. Set to 0 to disable (e.g. in tests).
"""
self.local_identity = local_identity
self.packet_injector = packet_injector # Function to inject packets into router
self.node_type = node_type
self.response_jitter_ms = max(0, int(response_jitter_ms))
# Create ControlHandler internally as a parsing utility
self.control_handler = ControlHandler(
@@ -118,7 +133,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,
@@ -147,6 +162,18 @@ class DiscoveryHelper:
tag: The tag for logging purposes
"""
try:
# Randomized pre-send jitter so multiple repeaters answering the same
# zero-hop discovery broadcast don't transmit at the same engine-scheduled
# instant and collide (the engine's DIRECT delay is fixed, not random).
# Mirrors firmware MyMesh.cpp:797. Uses secrets like the engine's TX jitter.
if self.response_jitter_ms > 0:
jitter_s = secrets.randbelow(self.response_jitter_ms + 1) / 1000.0
if jitter_s > 0:
logger.debug(
f"Discovery response jitter {jitter_s * 1000:.0f}ms for tag 0x{tag:08X}"
)
await asyncio.sleep(jitter_s)
success = await self.packet_injector(packet, wait_for_ack=False)
if success:
logger.info(f"Response sent for tag 0x{tag:08X}")
+105 -8
View File
@@ -1,28 +1,42 @@
"""
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.
"""
import asyncio
import logging
import time
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")
class LoginHelper:
def __init__(self, identity_manager, packet_injector=None, log_fn=None):
def __init__(
self,
identity_manager,
packet_injector=None,
log_fn=None,
sqlite_handler=None,
config=None,
):
self.identity_manager = identity_manager
self.packet_injector = packet_injector
self.log_fn = log_fn or logger.info
self.sqlite_handler = sqlite_handler
self.config = config or {}
self.handlers = {}
self.acls = {} # Per-identity ACLs keyed by hash_byte
self._pending_tasks = set()
# Shared across all identities so the node's total anon-reply rate is
# bounded (mirrors firmware anon_limiter: ~4 requests / 2 min).
self.anon_limiter = AnonRateLimiter()
def _track_task(self, task: asyncio.Task) -> None:
self._pending_tasks.add(task)
@@ -126,12 +140,88 @@ class LoginHelper:
is_room_server=(identity_type == "room_server"),
)
handler.set_send_packet_callback(self._send_packet_with_delay)
# Wrap the login handler in an anon-request dispatcher so anonymous
# regions/owner/basic discovery queries are answered instead of being
# mis-parsed as failed logins (MeshCore 1.16.0 discovery feature).
anon_handler = AnonRequestHandler(
local_identity=identity,
log_fn=self.log_fn,
login_handler=handler,
anon_limiter=self.anon_limiter,
region_names_fn=self._format_region_names,
owner_info_fn=self._make_owner_info_fn(name, config),
features_fn=self._make_features_fn(config),
clock_fn=lambda: int(time.time()),
)
# Wires the send callback through to both the wrapper and login handler.
anon_handler.set_send_packet_callback(self._send_packet_with_delay)
self.handlers[hash_byte] = handler
self.handlers[hash_byte] = anon_handler
logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}")
def _format_region_names(self) -> str:
"""Build the comma-separated region-names string for an anon regions reply.
Mirrors firmware ``RegionMap::exportNamesTo`` with ``REGION_DENY_FLOOD``:
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; openhop_repeater models that via ``mesh.unscoped_flood_allow``
(falling back to ``mesh.global_flood_allow``, default allow).
"""
parts = []
mesh_cfg = self.config.get("mesh", {}) if isinstance(self.config, dict) else {}
unscoped_allow = mesh_cfg.get(
"unscoped_flood_allow", mesh_cfg.get("global_flood_allow", True)
)
if unscoped_allow:
parts.append("*")
if self.sqlite_handler:
try:
keys = self.sqlite_handler.get_transport_keys()
except Exception as e:
logger.warning(f"Failed to read transport keys for regions reply: {e}")
keys = []
for rec in keys or []:
if rec.get("flood_policy", "deny") != "allow":
continue
name = (rec.get("name") or "").strip()
if not name or name == "*":
continue # wildcard handled above
parts.append(name[1:] if name.startswith("#") else name)
return ",".join(parts)
@staticmethod
def _make_owner_info_fn(name: str, config: dict):
"""Build an owner-info callback returning ``(node_name, owner_info)``."""
def owner_info_fn():
cfg = config or {}
repeater_cfg = cfg.get("repeater", {})
node_name = repeater_cfg.get("node_name") or name or "pyMC"
owner = repeater_cfg.get("owner_info", "") or ""
return (node_name, owner)
return owner_info_fn
@staticmethod
def _make_features_fn(config: dict):
"""Build a feature-flags callback (bit0 = bridge, bit7 = forwarding disabled)."""
def features_fn():
cfg = config or {}
mode = cfg.get("repeater", {}).get("mode", "forward")
features = 0
if mode != "forward": # monitor / no_tx => not forwarding
features |= 0x80
return features
return features_fn
async def process_login_packet(self, packet):
try:
@@ -147,9 +237,16 @@ class LoginHelper:
packet.mark_do_not_retransmit()
return True
else:
# ANON_REQ to other nodes (e.g. owner-info to firmware) is normal; skip log to avoid spam
# ANON_REQ to other nodes (e.g. another repeater's regions/owner
# query overheard on-air) is normal; log at DEBUG so the dest is
# visible when diagnosing "why didn't my repeater answer".
ptype = getattr(packet, "get_payload_type", lambda: None)()
if ptype != PAYLOAD_TYPE_ANON_REQ:
if ptype == PAYLOAD_TYPE_ANON_REQ:
logger.debug(
f"ANON_REQ for hash 0x{dest_hash:02X} not addressed to a local "
f"identity ({sorted(f'0x{h:02X}' for h in self.handlers)}); ignoring"
)
else:
logger.debug(
f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward"
)
+1 -1
View File
@@ -12,7 +12,7 @@ class PathHelper:
async def process_path_packet(self, packet):
from pymc_core.protocol.crypto import CryptoUtils
from openhop_core.protocol.crypto import CryptoUtils
try:
if len(packet.payload) < 2:
+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"
+5 -5
View File
@@ -4,8 +4,8 @@ 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
logger = logging.getLogger("RoomServer")
@@ -102,8 +102,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,
)
@@ -357,7 +357,7 @@ class RoomServer:
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
)
# Calculate expected ACK (same algorithm as pymc_core)
# Calculate expected ACK (same algorithm as openhop_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]
+144 -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)
@@ -473,20 +477,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 +522,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 +603,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 +622,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")
+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",
+144 -56
View File
@@ -7,7 +7,15 @@ import socket
import sys
import time
from repeater.companion.utils import normalize_companion_identity_key, validate_companion_node_name
from repeater.companion.utils import (
CompanionContactCapacityError,
effective_max_contacts,
enforce_companion_contact_capacity,
format_companion_bridge_limits,
normalize_companion_identity_key,
parse_companion_bridge_kwargs,
validate_companion_node_name,
)
from repeater.config import NullRadio, get_radio_for_board, load_config, save_config
from repeater.config_manager import ConfigManager
from repeater.data_acquisition.glass_handler import GlassHandler
@@ -167,8 +175,8 @@ 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)
logger.info("Dispatcher initialized")
@@ -268,6 +276,12 @@ class RepeaterDaemon:
identity_manager=self.identity_manager,
packet_injector=self.router.inject_packet,
log_fn=logger.info,
sqlite_handler=(
self.repeater_handler.storage.sqlite_handler
if self.repeater_handler and self.repeater_handler.storage
else None
), # For anon regions-discovery replies
config=self.config, # For owner-info / feature-flags replies
)
# Register default repeater identity
@@ -293,7 +307,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,
)
@@ -381,7 +395,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(
@@ -417,7 +431,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", {})
@@ -479,8 +493,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
@@ -564,14 +578,39 @@ class RepeaterDaemon:
return _sync
bridge_kwargs = parse_companion_bridge_kwargs(settings)
max_contacts = effective_max_contacts(bridge_kwargs)
if sqlite_handler:
trimmed = enforce_companion_contact_capacity(
companion_hash_str,
max_contacts,
sqlite_handler,
trim=bool(settings.get("trim_contacts_on_overflow")),
companion_name=name,
)
if trimmed:
logger.warning(
"Companion '%s': trimmed %d contact(s) to fit "
"max_contacts=%d (trim_contacts_on_overflow)",
name,
trimmed,
max_contacts,
)
bridge = RepeaterCompanionBridge(
identity=identity,
packet_injector=self.router.inject_packet,
# Tag the injector with this companion's hash so inject_packet can
# skip its own frame server when echoing TX as raw RX (a node never
# hears its own transmission).
packet_injector=functools.partial(
self.router.inject_packet, origin_hash=companion_hash_str
),
node_name=node_name,
radio_config=radio_config,
sqlite_handler=sqlite_handler,
companion_hash=companion_hash_str,
on_prefs_saved=_make_sync_node_name_to_config(name),
**bridge_kwargs,
)
# Load contacts from SQLite
@@ -605,24 +644,29 @@ class RepeaterDaemon:
ch = Channel(name=row.get("name", ""), secret=raw)
bridge.channels.set(row.get("channel_idx", 0), ch)
# Preload queued messages from SQLite into bridge
for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str):
from pymc_core.companion.models import QueuedMessage
# Preload queued messages from SQLite into bridge, bounded by
# offline_queue_size (0 disables offline storage entirely).
retention = getattr(bridge.message_queue, "_max_size", None)
if retention != 0:
for msg_dict in sqlite_handler.companion_load_messages(
companion_hash_str, limit=retention or 100
):
from openhop_core.companion.models import QueuedMessage
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
)
)
# Ensure public channel (0) exists with default key for new companions
from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET
@@ -655,11 +699,15 @@ class RepeaterDaemon:
identity_type="companion",
)
limits = format_companion_bridge_limits(bridge_kwargs)
logger.info(
f"Loaded companion '{name}': hash=0x{companion_hash:02x}, "
f"port={tcp_port}, bind={bind_address}, client_idle_timeout_sec={client_idle_timeout_sec}"
f"port={tcp_port}, bind={bind_address}, "
f"client_idle_timeout_sec={client_idle_timeout_sec}{limits}"
)
except CompanionContactCapacityError as e:
logger.error("%s", e)
except Exception as e:
logger.error(f"Failed to load companion '{name}': {e}", exc_info=True)
@@ -669,8 +717,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
@@ -725,13 +773,35 @@ class RepeaterDaemon:
tcp_timeout_raw = settings.get("tcp_timeout", 120)
client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw)
bridge_kwargs = parse_companion_bridge_kwargs(settings)
max_contacts = effective_max_contacts(bridge_kwargs)
if sqlite_handler:
trimmed = enforce_companion_contact_capacity(
companion_hash_str,
max_contacts,
sqlite_handler,
trim=bool(settings.get("trim_contacts_on_overflow")),
companion_name=name,
)
if trimmed:
logger.warning(
"Hot-reload companion '%s': trimmed %d contact(s) to fit "
"max_contacts=%d (trim_contacts_on_overflow)",
name,
trimmed,
max_contacts,
)
bridge = RepeaterCompanionBridge(
identity=identity,
packet_injector=self.router.inject_packet,
packet_injector=functools.partial(
self.router.inject_packet, origin_hash=companion_hash_str
),
node_name=node_name,
radio_config=radio_config,
sqlite_handler=sqlite_handler,
companion_hash=companion_hash_str,
**bridge_kwargs,
)
if sqlite_handler:
@@ -762,23 +832,27 @@ class RepeaterDaemon:
ch = Channel(name=row.get("name", ""), secret=raw)
bridge.channels.set(row.get("channel_idx", 0), ch)
for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str):
from pymc_core.companion.models import QueuedMessage
retention = getattr(bridge.message_queue, "_max_size", None)
if retention != 0:
for msg_dict in sqlite_handler.companion_load_messages(
companion_hash_str, limit=retention or 100
):
from openhop_core.companion.models import QueuedMessage
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
)
)
if bridge.get_channel(0) is None:
bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET)
@@ -808,17 +882,28 @@ class RepeaterDaemon:
identity_type="companion",
)
limits = format_companion_bridge_limits(bridge_kwargs)
logger.info(
f"Hot-reload: Loaded companion '{name}': hash=0x{companion_hash:02x}, "
f"port={tcp_port}, bind={bind_address}, client_idle_timeout_sec={client_idle_timeout_sec}"
f"port={tcp_port}, bind={bind_address}, "
f"client_idle_timeout_sec={client_idle_timeout_sec}{limits}"
)
async def _on_raw_rx_for_companions(self, data: bytes, rssi: int, snr: float) -> None:
"""Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients."""
async def _on_raw_rx_for_companions(
self, data: bytes, rssi: int, snr: float, exclude_hash: str | None = None
) -> None:
"""Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients.
``exclude_hash`` skips the frame server for that companion hash; used when
echoing a companion's own injected TX so it never hears its own transmission.
OTA RX subscribers leave it unset, so received packets reach every companion.
"""
servers = getattr(self, "companion_frame_servers", [])
if not servers:
return
for fs in servers:
if exclude_hash is not None and getattr(fs, "companion_hash", None) == exclude_hash:
continue
try:
fs.push_rx_raw(snr, rssi, data)
except Exception as e:
@@ -1054,8 +1139,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", {})
@@ -1093,7 +1181,7 @@ class RepeaterDaemon:
logger.debug("Marked own advert as seen in duplicate cache")
logger.info(
"Sent flood advert '%s' at (% .6f, % .6f) source=%s",
"Sent flood advert '%s' at (%.6f, %.6f) source=%s",
node_name,
latitude,
longitude,
@@ -1247,7 +1335,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:
@@ -1317,7 +1405,7 @@ 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:
@@ -1325,7 +1413,7 @@ class RepeaterDaemon:
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
# 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:
@@ -1356,10 +1444,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",
@@ -1371,7 +1459,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:
+215 -36
View File
@@ -2,29 +2,52 @@ 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.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,
)
from repeater.policy_engine import PolicyDecision, PolicyEngine
logger = logging.getLogger("PacketRouter")
# Deliver PATH and protocol-response (PATH) to companion at most once per logical packet
# so the client is not spammed with duplicate telemetry when the mesh delivers multiple copies.
_COMPANION_DEDUPE_TTL_SEC = 60.0
# Drop reasons that are normal policy outcomes and should not be warning-level.
# TODO: create Enum in engine for drop reasons and use it here and in engine instead of string matching.
_EXPECTED_DROP_REASON_PREFIXES = (
"Duplicate",
"Max flood hops limit reached",
"Path hop count at maximum",
"Path would exceed MAX_PATH_SIZE",
"Direct: no path",
"Direct: not for us",
"Unscoped flood policy disabled",
"Transport code not allowed to flood",
"FLOOD loop detected",
"Marked do not retransmit",
"Repeat disabled",
"No TX mode",
"Duty cycle limit",
"Empty payload",
"Path too long",
"Invalid advert packet",
)
def _companion_dedup_key(packet) -> str | None:
"""Return a stable key for companion delivery deduplication, or None if not available."""
@@ -43,6 +66,32 @@ def _is_direct_final_hop(packet) -> bool:
return not path or len(path) == 0
def _is_expected_drop_reason(reason: str | None) -> bool:
if not isinstance(reason, str) or not reason:
return False
return any(reason.startswith(prefix) for prefix in _EXPECTED_DROP_REASON_PREFIXES)
def _drop_reason_from_recent_packets(handler, packet) -> str | None:
"""Best-effort drop reason lookup from handler recent packet records."""
recent_packets = getattr(handler, "recent_packets", None)
if not recent_packets:
return None
try:
packet_hash = packet.calculate_packet_hash().hex().upper()[:16]
except Exception:
return None
for record in reversed(list(recent_packets)):
if not isinstance(record, dict):
continue
if record.get("packet_hash") != packet_hash:
continue
reason = record.get("drop_reason")
if isinstance(reason, str) and reason:
return reason
return None
class PacketRouter:
def __init__(self, daemon_instance):
self.daemon = daemon_instance
@@ -141,6 +190,68 @@ class PacketRouter:
self._companion_delivered[key] = now + _COMPANION_DEDUPE_TTL_SEC
return True
def _policy_companion_decision(self, packet, metadata: dict) -> PolicyDecision | None:
"""Return cached policy decision used to gate companion delivery.
Stores the pre-check decision in shared metadata so the repeater engine
can reuse it and avoid a second full policy evaluation pass.
"""
handler = getattr(self.daemon, "repeater_handler", None)
if not handler:
return None
policy_engine = getattr(handler, "policy_engine", None)
if not isinstance(policy_engine, PolicyEngine) or not policy_engine.enabled:
return None
cached = metadata.get("_policy_precheck_decision")
if isinstance(cached, PolicyDecision):
return cached
mode = self.daemon.config.get("repeater", {}).get("mode", "forward")
route_type = getattr(packet, "header", 0) & PH_ROUTE_MASK
policy_context = {
"route_type": route_type,
"payload_type": packet.get_payload_type()
if hasattr(packet, "get_payload_type")
else None,
"payload_length": len(packet.payload or b""),
"path_hash_size": packet.get_path_hash_size()
if hasattr(packet, "get_path_hash_size")
else None,
"hop_count": packet.get_path_hash_count()
if hasattr(packet, "get_path_hash_count")
else None,
"rssi": metadata.get("rssi", getattr(packet, "rssi", 0)),
"snr": metadata.get("snr", getattr(packet, "snr", 0.0)),
"local_transmission": False,
"mode": mode,
}
decision = policy_engine.evaluate(packet, policy_context)
metadata["_policy_precheck_decision"] = decision
return decision
def _policy_blocks_companion(self, packet, metadata: dict) -> bool:
"""Return True when policy action is drop, making companion suppression final."""
decision = self._policy_companion_decision(packet, metadata)
if not isinstance(decision, PolicyDecision):
return False
if decision.action == "drop":
logger.debug(
"Policy pre-check blocked companion delivery: rule %s action=drop",
decision.rule_id,
)
return True
return False
def _companion_bridges_for_packet(self, packet, metadata: dict) -> dict:
"""Return companion bridges unless policy drop pre-check blocks delivery."""
companion_bridges = getattr(self.daemon, "companion_bridges", {})
if not companion_bridges:
return {}
if self._policy_blocks_companion(packet, metadata):
return {}
return companion_bridges
def _record_for_ui(self, packet, metadata: dict) -> None:
"""Record an injection-only packet for the web UI (storage + recent_packets)."""
handler = getattr(self.daemon, "repeater_handler", None)
@@ -160,7 +271,7 @@ class PacketRouter:
pass
await self.queue.put(packet)
async def inject_packet(self, packet, wait_for_ack: bool = False):
async def inject_packet(self, packet, wait_for_ack: bool = False, origin_hash=None):
try:
metadata = {
"rssi": getattr(packet, "rssi", 0),
@@ -172,14 +283,61 @@ class PacketRouter:
# (avoids duty-cycle or dispatcher races where a later packet goes out first)
async with self._inject_lock:
# Use local_transmission=True to bypass forwarding logic
await self.daemon.repeater_handler(packet, metadata, local_transmission=True)
sent = await self.daemon.repeater_handler(packet, metadata, local_transmission=True)
if not sent:
logger.warning("Injected packet failed local transmission")
return False
# Mark so when this packet is dequeued we don't pass to engine again (avoid double-send / double-count)
packet._injected_for_tx = True
# Echo this local TX to companion frame server clients as raw RX
# (PUSH_CODE_LOG_RX_DATA 0x88, snr=0/rssi=0 = local origin) so apps that
# decrypt locally from raw RX (e.g. RemoteTerm) see companion-originated
# traffic, matching what other mesh nodes would hear off the air. The
# originating companion (origin_hash) is excluded so it never hears its own TX.
push_rx = getattr(self.daemon, "_on_raw_rx_for_companions", None)
if push_rx is not None:
try:
raw = packet.write_to()
await push_rx(raw, 0, 0.0, exclude_hash=origin_hash)
servers = getattr(self.daemon, "companion_frame_servers", [])
pushed = sum(
1 for fs in servers if getattr(fs, "companion_hash", None) != origin_hash
)
logger.debug(
"Echoed injected TX as raw RX (0x88) to %d companion client(s) "
"(%d bytes, origin=%s excluded)",
pushed,
len(raw),
origin_hash,
)
except Exception as e:
logger.debug("Failed to echo injected TX to companions: %s", e)
# Enqueue so router can deliver to companion(s): TXT_MSG -> dest bridge, ACK -> all bridges (sender sees ACK)
await self.enqueue(packet)
if wait_for_ack:
ptype = getattr(packet, "get_payload_type", lambda: None)()
if ptype not in {
AckHandler.payload_type(),
AdvertHandler.payload_type(),
}:
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)
if not ack_ok:
logger.warning(
"Injected packet ACK timeout (crc=%08X)", expected_crc
)
return False
except Exception as e:
logger.warning("Injected packet ACK wait failed: %s", e)
return False
packet_len = len(packet.payload) if packet.payload else 0
logger.debug(
f"Injected packet processed by engine as local transmission ({packet_len} bytes)"
@@ -279,8 +437,10 @@ class PacketRouter:
rssi = getattr(packet, "rssi", 0)
snr = getattr(packet, "snr", 0.0)
await self.daemon.advert_helper.process_advert_packet(packet, rssi, snr)
# Also feed adverts to companion bridges (for contact/path updates)
for bridge in getattr(self.daemon, "companion_bridges", {}).values():
# Also feed adverts to companion bridges (for contact/path updates),
# but keep policy drop final just like the other companion paths.
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
@@ -291,7 +451,7 @@ class PacketRouter:
# When dest is remote (not handled), pass to engine so DIRECT/FLOOD ANON_REQ can be forwarded.
# Our own injected ANON_REQ is suppressed by the engine's duplicate (mark_seen) check.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
@@ -305,7 +465,7 @@ class PacketRouter:
elif payload_type == AckHandler.payload_type():
# 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 = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
@@ -314,7 +474,7 @@ class PacketRouter:
elif payload_type == TextMessageHandler.payload_type():
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
@@ -327,7 +487,7 @@ class PacketRouter:
elif payload_type == PathHandler.payload_type():
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
if self._should_deliver_path_to_companions(packet):
await companion_bridges[dest_hash].process_received_packet(packet)
@@ -356,7 +516,7 @@ class PacketRouter:
# to first hop instead of original requester).
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
dest_hash = packet.payload[0] if packet.payload and len(packet.payload) >= 1 else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
local_hash = getattr(self.daemon, "local_hash", None)
if dest_hash is not None and dest_hash in companion_bridges:
try:
@@ -402,7 +562,7 @@ class PacketRouter:
# PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.).
# Deliver at most once per logical packet so the client is not spammed with duplicates.
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if companion_bridges and self._should_deliver_path_to_companions(packet):
for bridge in companion_bridges.values():
try:
@@ -422,7 +582,7 @@ class PacketRouter:
elif payload_type == ProtocolRequestHandler.payload_type():
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = getattr(self.daemon, "companion_bridges", {})
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
@@ -443,22 +603,41 @@ class PacketRouter:
self._record_for_ui(packet, metadata)
elif payload_type == GroupTextHandler.payload_type():
# GRP_TXT: pass to all companions (they filter by channel); still forward
companion_bridges = getattr(self.daemon, "companion_bridges", {})
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge GRP_TXT error: {e}")
# GRP_TXT: pass to all companions (they filter by channel); still forward.
# Policy drop is final and blocks companion delivery.
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if companion_bridges:
for bridge in companion_bridges.values():
try:
await bridge.process_received_packet(packet)
except Exception as e:
logger.debug(f"Companion bridge GRP_TXT error: {e}")
# Only pass to repeater engine if not already processed by injection
# Skip engine for packets we injected for TX (already sent; avoid double-send/double-count)
if getattr(packet, "_injected_for_tx", False):
processed_by_injection = True
if self.daemon.repeater_handler and not processed_by_injection:
metadata = {
"rssi": getattr(packet, "rssi", 0),
"snr": getattr(packet, "snr", 0.0),
"timestamp": getattr(packet, "timestamp", 0),
}
await self.daemon.repeater_handler(packet, metadata)
sent = await self.daemon.repeater_handler(packet, metadata)
if sent is False:
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
)
if _is_expected_drop_reason(drop_reason):
logger.debug(
"Inbound packet intentionally not transmitted by repeater handler "
"(type=%s, header=0x%02x, reason=%s)",
payload_type,
getattr(packet, "header", 0),
drop_reason,
)
else:
logger.warning(
"Inbound packet not transmitted by repeater handler "
"(type=%s, header=0x%02x, reason=%s)",
payload_type,
getattr(packet, "header", 0),
drop_reason or "unknown",
)
+691
View File
@@ -0,0 +1,691 @@
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass
from typing import Any, Optional
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")
SUPPORTED_ACTIONS = {
"allow",
"drop",
"log_only",
}
def default_policy_engine_config() -> dict[str, Any]:
return {
"enabled": False,
"default_action": "allow",
"rules": [],
"objects": {},
}
@dataclass
class PolicyDecision:
action: str = "allow"
matched: bool = False
rule_id: Optional[Any] = None
reason: Optional[str] = None
class PolicyEngine:
"""Readable top-down rule evaluator for repeater policy decisions."""
def __init__(self, policy_config: Optional[dict] = None):
cfg = default_policy_engine_config()
if isinstance(policy_config, dict):
cfg.update(policy_config)
self.enabled = bool(cfg.get("enabled", False))
self.default_action = str(cfg.get("default_action", "allow"))
if self.default_action not in SUPPORTED_ACTIONS:
logger.warning(
"Policy default_action '%s' is not supported, using 'allow'",
self.default_action,
)
self.default_action = "allow"
rules = cfg.get("rules")
self.rules: list[dict[str, Any]] = rules if isinstance(rules, list) else []
objects = cfg.get("objects")
self.objects: dict[str, Any] = objects if isinstance(objects, dict) else {}
self._channel_decrypt_cache: dict[int, dict[str, Any]] = {}
self._inline_channel_secrets = self._collect_inline_rule_channel_secrets(self.rules)
@classmethod
def from_runtime_config(cls, runtime_config: Optional[dict]) -> "PolicyEngine":
if not isinstance(runtime_config, dict):
return cls()
return cls(runtime_config.get("policy_engine", {}))
def evaluate(self, packet, context: dict) -> PolicyDecision:
if not self.enabled:
return PolicyDecision(action="allow", matched=False, reason="policy_disabled")
for rule in self.rules:
if not isinstance(rule, dict):
continue
if not bool(rule.get("enabled", True)):
continue
if not self._rule_matches(rule, packet, context):
continue
action = self._resolve_action(rule)
rule_id = rule.get("id")
rule_name = rule.get("name") or "unnamed"
reason = f"Policy rule matched: id={rule_id}, name={rule_name}, action={action}"
return PolicyDecision(action=action, matched=True, rule_id=rule_id, reason=reason)
return PolicyDecision(action=self.default_action, matched=False, reason="default_action")
def _resolve_action(self, rule: dict) -> str:
then_block = rule.get("then", {})
action = None
if isinstance(then_block, dict):
action = then_block.get("action")
elif isinstance(then_block, str):
action = then_block
if not action:
action = rule.get("action")
action = str(action or "allow")
if action not in SUPPORTED_ACTIONS:
logger.warning("Unsupported policy action '%s', coercing to 'allow'", action)
return "allow"
return action
def _rule_matches(self, rule: dict, packet, context: dict) -> bool:
cond = rule.get("if", {})
# Support implicit single-condition form.
if isinstance(cond, dict) and "field" in cond:
return self._condition_matches(cond, packet, context)
if not isinstance(cond, dict):
return False
all_conds = cond.get("all")
any_conds = cond.get("any")
if isinstance(all_conds, list):
return all(self._condition_matches(c, packet, context) for c in all_conds)
if isinstance(any_conds, list):
return any(self._condition_matches(c, packet, context) for c in any_conds)
return False
def _condition_matches(self, condition: dict, packet, context: dict) -> bool:
if not isinstance(condition, dict):
return False
field = condition.get("field")
op = condition.get("op", "equals")
try:
expected = self._resolve_value(condition.get("value"))
actual = self._get_field_value(field, packet, context)
if field == "path_hashes":
actual = self._normalize_path_hash_values(actual)
expected = self._normalize_path_hash_values(expected)
if field == "channel_hash":
actual = self._normalize_channel_hash_values(actual)
expected = self._normalize_channel_hash_values(expected)
result = self._compare(actual, op, expected)
logger.debug(
"Condition eval: field=%s op=%s expected=%r actual=%r -> %s",
field,
op,
expected,
actual,
"MATCH" if result else "no match",
)
return result
except ValueError as exc:
logger.debug("Condition eval: field=%s raised ValueError: %s -> no match", field, exc)
return False
def _resolve_value(self, value: Any) -> Any:
if isinstance(value, str) and value.startswith("@"):
# Object reference format: @group.name
ref = value[1:]
parts = ref.split(".", 1)
if len(parts) == 2:
group, key = parts
group_obj = self.objects.get(group, {})
if isinstance(group_obj, dict):
return group_obj.get(key)
return value
def _get_field_value(self, field: Any, packet, context: dict) -> Any:
if not isinstance(field, str):
return None
# Existing packet/context fields only.
if field in context:
return context.get(field)
if field == "payload_hex":
payload = getattr(packet, "payload", None) or b""
return bytes(payload).hex()
if field == "channel_hash":
decrypted = getattr(packet, "decrypted", None)
if isinstance(decrypted, dict):
group_text = decrypted.get("group_text_data", {})
if isinstance(group_text, dict):
candidate = group_text.get("channel_hash")
if candidate is not None:
return candidate
try:
payload_type = (
packet.get_payload_type() if hasattr(packet, "get_payload_type") else None
)
except Exception:
payload_type = None
if payload_type in (PAYLOAD_TYPE_GRP_TXT, PAYLOAD_TYPE_GRP_DATA):
payload = (
packet.get_payload()
if hasattr(packet, "get_payload")
else getattr(packet, "payload", None)
)
if payload and len(payload) >= 1:
return payload[0]
if field == "channel_message_body":
channel_info = self._get_channel_decrypt_info(packet)
return channel_info.get("message_body")
if field == "channel_sender":
channel_info = self._get_channel_decrypt_info(packet)
return channel_info.get("sender")
if field == "channel_decryptable":
channel_info = self._get_channel_decrypt_info(packet)
return bool(channel_info.get("decryptable", False))
if field == "path_hashes":
if hasattr(packet, "get_path_hashes_hex"):
return packet.get_path_hashes_hex()
return []
if field == "transport_code_0":
if hasattr(packet, "transport_codes") and packet.transport_codes:
return packet.transport_codes[0]
return None
if field == "transport_code_1":
if hasattr(packet, "transport_codes") and len(packet.transport_codes) > 1:
return packet.transport_codes[1]
return None
return None
def _extract_channel_message_body(self, packet) -> Optional[str]:
channel_info = self._compute_channel_decrypt_info(packet)
return channel_info.get("message_body")
def _get_channel_decrypt_info(self, packet) -> dict[str, Any]:
packet_key = id(packet)
cached = self._channel_decrypt_cache.get(packet_key)
if isinstance(cached, dict):
return cached
computed = self._compute_channel_decrypt_info(packet)
self._channel_decrypt_cache[packet_key] = computed
return computed
def _compute_channel_decrypt_info(self, packet) -> dict[str, Any]:
decrypted = getattr(packet, "decrypted", None)
if isinstance(decrypted, dict):
group_text = decrypted.get("group_text_data", {})
if isinstance(group_text, dict):
sender = group_text.get("sender")
text = group_text.get("text")
if isinstance(text, str):
if not isinstance(sender, str) or not sender.strip():
sender, text = self._extract_sender_from_message(text)
return {
"decryptable": True,
"sender": sender,
"message_body": text,
}
try:
payload_type = (
packet.get_payload_type() if hasattr(packet, "get_payload_type") else None
)
except Exception:
return {
"decryptable": False,
"message_body": None,
}
if payload_type != PAYLOAD_TYPE_GRP_TXT:
return {
"decryptable": False,
"message_body": None,
}
payload = (
packet.get_payload()
if hasattr(packet, "get_payload")
else getattr(packet, "payload", None)
)
if not payload or len(payload) < 4:
return {
"decryptable": False,
"message_body": None,
}
channel_hash = payload[0]
cipher_mac = bytes(payload[1:3])
ciphertext = bytes(payload[3:])
secrets_tried = 0
for secret in self._iter_policy_channel_secrets():
secrets_tried += 1
derived = self._derive_channel_hash(secret)
secret_preview = secret[:8] + "..." if len(secret) > 8 else secret
if derived != channel_hash:
logger.debug(
"Channel decrypt: secret %s derived hash 0x%02X != packet hash 0x%02X, skipping",
secret_preview,
derived,
channel_hash,
)
continue
logger.debug(
"Channel decrypt: secret %s hash matches 0x%02X, attempting MAC+decrypt",
secret_preview,
channel_hash,
)
plaintext = self._decrypt_channel_message(secret, cipher_mac, ciphertext)
if plaintext is None:
logger.debug(
"Channel decrypt: secret %s MAC/decrypt failed",
secret_preview,
)
continue
parsed = self._parse_channel_plaintext(plaintext)
if not isinstance(parsed, dict):
logger.debug(
"Channel decrypt: secret %s parse failed",
secret_preview,
)
continue
content = parsed.get("content")
if not isinstance(content, str):
continue
sender, message_body = self._extract_sender_from_message(content)
logger.debug(
"Channel decrypt: SUCCESS with secret %s, sender=%r, message_body=%r",
secret_preview,
sender,
message_body[:40] if message_body else "",
)
return {
"decryptable": True,
"sender": sender.rstrip("\x00").rstrip(),
"message_body": message_body.rstrip("\x00").rstrip(),
}
if secrets_tried == 0:
logger.debug(
"Channel decrypt: no policy channel secrets configured "
"(objects.channels / objects.channel_hash_groups and inline rule secrets are empty/missing); "
"decryptable=False",
)
else:
logger.debug(
"Channel decrypt: no matching secret found (tried %d), decryptable=False",
secrets_tried,
)
return {
"decryptable": False,
"sender": None,
"message_body": None,
}
def _iter_policy_channel_secrets(self):
channels = self.objects.get("channels", {})
if isinstance(channels, dict):
channel_items = channels.values()
elif isinstance(channels, (list, tuple)):
channel_items = channels
else:
logger.debug(
"Channel decrypt: objects.channels has unsupported type %s",
type(channels).__name__,
)
return
for channel_cfg in channel_items:
if isinstance(channel_cfg, str):
yield channel_cfg
continue
if isinstance(channel_cfg, dict):
# Accept common schema variations used across policy/companion exports.
secret = (
channel_cfg.get("secret")
or channel_cfg.get("key")
or channel_cfg.get("psk")
or channel_cfg.get("channel_secret")
)
if secret:
yield str(secret)
else:
logger.debug(
"Channel decrypt: channel entry missing secret/key/psk/channel_secret keys",
)
continue
logger.debug(
"Channel decrypt: skipping unsupported channel entry type %s",
type(channel_cfg).__name__,
)
# Also accept full channel secrets provided via policy object groups.
channel_hash_groups = self.objects.get("channel_hash_groups", {})
if isinstance(channel_hash_groups, dict):
for group_values in channel_hash_groups.values():
values = (
group_values if isinstance(group_values, (list, tuple, set)) else [group_values]
)
for candidate in values:
secret = self._extract_channel_secret_literal(candidate)
if secret:
yield secret
elif channel_hash_groups not in ({}, None):
logger.debug(
"Channel decrypt: objects.channel_hash_groups has unsupported type %s",
type(channel_hash_groups).__name__,
)
for inline_secret in self._inline_channel_secrets:
yield inline_secret
@staticmethod
def _secret_bytes_for_hash(channel_secret: str) -> bytes:
try:
secret_bytes = bytes.fromhex(channel_secret)
except ValueError:
secret_bytes = channel_secret.encode("utf-8")
if len(secret_bytes) >= 32 and secret_bytes[16:32] == b"\x00" * 16:
return secret_bytes[:16]
if len(secret_bytes) > 32:
return secret_bytes[:32]
return secret_bytes
def _derive_channel_hash(self, channel_secret: str) -> int:
secret_bytes = self._secret_bytes_for_hash(channel_secret)
return hashlib.sha256(secret_bytes).digest()[0]
@staticmethod
def _decrypt_channel_message(
channel_secret: str, mac: bytes, ciphertext: bytes
) -> Optional[bytes]:
try:
try:
secret_bytes = bytes.fromhex(channel_secret)
except ValueError:
secret_bytes = channel_secret.encode("utf-8")
if len(secret_bytes) < 32:
secret_bytes = secret_bytes + b"\x00" * (32 - len(secret_bytes))
elif len(secret_bytes) > 32:
secret_bytes = secret_bytes[:32]
expected_mac = CryptoUtils._hmac_sha256(secret_bytes, ciphertext)[:2]
if mac != expected_mac:
return None
return CryptoUtils._aes_decrypt(secret_bytes[:16], ciphertext)
except Exception:
return None
@staticmethod
def _parse_channel_plaintext(plaintext: bytes) -> Optional[dict]:
if len(plaintext) < 5:
return None
try:
timestamp = int.from_bytes(plaintext[:4], "little")
flags = plaintext[4]
raw = plaintext[5:].decode("utf-8", errors="replace")
message_content = raw.rstrip("\x00")
message_type = "unknown"
if flags == 0x00:
message_type = "plain_text"
elif flags == 0x01:
message_type = "cli_command"
elif flags == 0x02:
message_type = "signed_text"
if len(plaintext) >= 7:
raw = plaintext[7:].decode("utf-8", errors="replace")
message_content = raw.rstrip("\x00")
return {
"timestamp": timestamp,
"flags": flags,
"message_type": message_type,
"content": message_content,
}
except Exception:
return None
@staticmethod
def _extract_sender_from_message(message_content: str) -> tuple[str, str]:
if ": " in message_content:
parts = message_content.split(": ", 1)
if len(parts) == 2:
return parts[0], parts[1]
return "Unknown", message_content
@staticmethod
def _extract_channel_secret_literal(value: Any) -> Optional[str]:
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
normalized_hex = raw[2:] if raw.lower().startswith("0x") else raw
if len(normalized_hex) in (32, 64) and all(
ch in "0123456789abcdefABCDEF" for ch in normalized_hex
):
return normalized_hex
return None
@classmethod
def _collect_inline_rule_channel_secrets(cls, rules: list) -> list[str]:
secrets: list[str] = []
def _consume_condition(cond: Any):
if not isinstance(cond, dict):
return
if cond.get("field") != "channel_hash":
return
raw_value = cond.get("value")
values = raw_value if isinstance(raw_value, (list, tuple, set)) else [raw_value]
for candidate in values:
secret = cls._extract_channel_secret_literal(candidate)
if secret:
secrets.append(secret)
for rule in rules:
if not isinstance(rule, dict):
continue
cond = rule.get("if", {})
if isinstance(cond, dict) and "field" in cond:
_consume_condition(cond)
continue
if not isinstance(cond, dict):
continue
for key in ("all", "any"):
branch = cond.get(key)
if isinstance(branch, list):
for item in branch:
_consume_condition(item)
return list(dict.fromkeys(secrets))
@staticmethod
def _normalize_path_hash_value(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, int):
parsed = value
if parsed < 0:
return None
if parsed <= 0xFF:
width = 2
elif parsed <= 0xFFFF:
width = 4
elif parsed <= 0xFFFFFF:
width = 6
else:
raise ValueError("path hash exceeds 3 bytes")
return f"{parsed:0{width}X}"
else:
raw = str(value).strip()
if not raw:
return None
if raw.lower().startswith("0x"):
raw = raw[2:]
if not raw:
return None
if len(raw) % 2 != 0:
raise ValueError("path hash hex length must be even")
if len(raw) not in (2, 4, 6):
raise ValueError("path hash must be 1, 2, or 3 bytes")
if not all(ch in "0123456789abcdefABCDEF" for ch in raw):
raise ValueError("path hash must be hex")
return raw.upper()
@classmethod
def _normalize_path_hash_values(cls, value: Any) -> Any:
if isinstance(value, (list, tuple, set)):
normalized = []
for item in value:
normalized_item = cls._normalize_path_hash_value(item)
if normalized_item is not None:
normalized.append(normalized_item)
lengths = {len(item) for item in normalized}
if len(lengths) > 1:
raise ValueError("path hashes cannot mix byte lengths")
return normalized
return cls._normalize_path_hash_value(value)
@staticmethod
def _normalize_channel_hash_value(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, int):
parsed = value
else:
raw = str(value).strip()
if not raw:
return None
normalized_hex = raw[2:] if raw.lower().startswith("0x") else raw
if len(normalized_hex) in (32, 64) and all(
ch in "0123456789abcdefABCDEF" for ch in normalized_hex
):
secret_bytes = PolicyEngine._secret_bytes_for_hash(normalized_hex)
parsed = hashlib.sha256(secret_bytes).digest()[0]
return f"0x{parsed:02X}"
if raw.lower().startswith("0x"):
parsed = int(raw, 16)
elif raw.isdigit():
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}"
@classmethod
def _normalize_channel_hash_values(cls, value: Any) -> Any:
if isinstance(value, (list, tuple, set)):
normalized = []
for item in value:
normalized_item = cls._normalize_channel_hash_value(item)
if normalized_item is not None:
normalized.append(normalized_item)
return normalized
return cls._normalize_channel_hash_value(value)
@staticmethod
def _compare(actual: Any, op: Any, expected: Any) -> bool:
op_name = str(op or "equals").lower()
try:
if op_name in ("equals", "eq", "=="):
return actual == expected
if op_name in ("not_equals", "ne", "!="):
return actual != expected
if op_name in ("greater_than", "gt", ">"):
return actual is not None and expected is not None and actual > expected
if op_name in ("greater_or_equal", "gte", ">="):
return actual is not None and expected is not None and actual >= expected
if op_name in ("less_than", "lt", "<"):
return actual is not None and expected is not None and actual < expected
if op_name in ("less_or_equal", "lte", "<="):
return actual is not None and expected is not None and actual <= expected
if op_name == "contains":
if isinstance(actual, (list, tuple, set)):
return expected in actual
if isinstance(actual, str) and expected is not None:
return str(expected) in actual
return False
if op_name in ("in", "is_in"):
if isinstance(expected, (list, tuple, set)):
return actual in expected
if isinstance(expected, str) and actual is not None:
return str(actual) in expected
return False
if op_name in ("intersects", "overlaps"):
if isinstance(actual, (list, tuple, set)) and isinstance(
expected, (list, tuple, set)
):
return len(set(actual).intersection(set(expected))) > 0
return False
if op_name == "starts_with":
return (
isinstance(actual, str)
and isinstance(expected, str)
and actual.startswith(expected)
)
if op_name == "ends_with":
return (
isinstance(actual, str)
and isinstance(expected, str)
and actual.endswith(expected)
)
except Exception:
return False
return False
+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
+2 -2
View File
@@ -21,10 +21,10 @@ website: "https://meshmapper.net"
brokers:
- name: "MeshMapper"
enabled: true
host: mqtt.meshmapper.cc
host: mqtt.meshmapper.net
port: 443
transport: "websockets"
audience: "mqtt.meshmapper.cc"
audience: "mqtt.meshmapper.net"
use_jwt_auth: true
format: letsmesh
retain_status: 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)
+7 -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,
File diff suppressed because it is too large Load Diff
+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(
+24 -3
View File
@@ -15,8 +15,12 @@ import time
from typing import Optional
import cherrypy
from openhop_core.companion.constants import DEFAULT_OFFLINE_QUEUE_SIZE
from repeater.companion.utils import validate_companion_node_name
from repeater.companion.utils import (
trim_companion_contacts_to_fit,
validate_companion_node_name,
)
from .auth.middleware import require_auth
@@ -257,6 +261,10 @@ class CompanionAPIEndpoints:
"is_running": b.is_running,
"contacts_count": b.contacts.get_count(),
"channels_count": b.channels.get_count(),
"max_contacts": b.contacts.max_contacts,
"offline_queue_size": getattr(
b.message_queue, "_max_size", DEFAULT_OFFLINE_QUEUE_SIZE
),
}
)
return self._success(items)
@@ -390,8 +398,11 @@ class CompanionAPIEndpoints:
if limit < 1:
raise cherrypy.HTTPError(400, "limit must be a positive integer")
bridge = self._get_bridge(**self._resolve_bridge_params(body))
# max_contacts lives on the ContactStore, not the bridge itself; reading it
# from bridge.contacts avoids silently falling back to the 1000 default for
# companions configured with a higher limit.
max_contacts = bridge.contacts.max_contacts
if limit is not None:
max_contacts = getattr(bridge, "max_contacts", 1000)
limit = min(limit, max_contacts)
companion_hash = getattr(bridge, "_companion_hash", None)
if not companion_hash:
@@ -403,6 +414,16 @@ class CompanionAPIEndpoints:
hours=hours,
limit=limit,
)
# The bulk import writes directly to SQLite, bypassing the ContactStore cap
# that every other path honors. Trim favourite-aware (oldest non-favourites
# first) so persisted contacts never exceed max_contacts.
try:
removed = trim_companion_contacts_to_fit(sqlite_handler, companion_hash, max_contacts)
except ValueError as exc:
raise cherrypy.HTTPError(
409,
f"Cannot trim imported contacts to fit max_contacts={max_contacts}: {exc}",
)
contact_rows = sqlite_handler.companion_load_contacts(companion_hash)
if contact_rows:
records = []
@@ -411,7 +432,7 @@ class CompanionAPIEndpoints:
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
return self._success({"imported": count})
return self._success({"imported": count, "removed": removed})
# ----- Channels -----
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-4267adba]{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-dad29312]{margin-left:0}.ml-4[data-v-dad29312]{margin-left:1rem}.ml-8[data-v-dad29312]{margin-left:2rem}.ml-12[data-v-dad29312]{margin-left:3rem}.ml-16[data-v-dad29312]{margin-left:4rem}.ml-20[data-v-dad29312]{margin-left:5rem}.ml-24[data-v-dad29312]{margin-left:6rem}.ml-28[data-v-dad29312]{margin-left:7rem}.ml-32[data-v-dad29312]{margin-left:8rem}.dropdown-enter-active[data-v-de709eb9],.dropdown-leave-active[data-v-de709eb9]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-de709eb9],.dropdown-leave-to[data-v-de709eb9]{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}.tab-fade-left[data-v-ff4fb67b]{background:linear-gradient(to right, var(--color-surface) 30%, transparent)}.tab-fade-right[data-v-ff4fb67b]{background:linear-gradient(to left, var(--color-surface) 30%, transparent)}.tab-fade-enter-active[data-v-ff4fb67b],.tab-fade-leave-active[data-v-ff4fb67b]{transition:opacity .2s}.tab-fade-enter-from[data-v-ff4fb67b],.tab-fade-leave-to[data-v-ff4fb67b]{opacity:0}
@@ -0,0 +1 @@
.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}
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 @@
import{Ct as e,c as t,g as n,i as r,k as i,l as a,s as o,u as s,xt as c}from"./runtime-core.esm-bundler-C5QBTNWE.js";import{u as l}from"./runtime-dom.esm-bundler-fKU3dih-.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=n({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(n,{emit:y}){let b=n,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(n,y)=>(i(),t(r,{to:`body`},[b.show?(i(),s(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[o(`div`,u,[o(`div`,d,[o(`h3`,f,e(b.title),1),o(`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]||=[o(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[o(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),o(`div`,p,[o(`div`,{class:c([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(i(),s(`svg`,m,[...y[5]||=[o(`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`?(i(),s(`svg`,h,[...y[6]||=[o(`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)]])):(i(),s(`svg`,g,[...y[7]||=[o(`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),o(`p`,_,e(b.message),1)]),o(`div`,v,[o(`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`},e(b.cancelText),1),o(`button`,{onClick:y[2]||=e=>x(`confirm`),class:c([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},e(b.confirmText),3)])])])):a(``,!0)]))}});export{y as t};
@@ -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 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};
@@ -0,0 +1 @@
import{N as e,T as t,U as n,_t as r,h as i,m as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./runtime-dom.esm-bundler-B3VeUO8l.js";function l(e=2e3){let t=n(!1);return{copy:async n=>{try{await navigator.clipboard.writeText(n)}catch{let e=document.createElement(`textarea`);e.value=n,document.body.appendChild(e),e.select(),document.execCommand(`copy`),document.body.removeChild(e)}t.value=!0,setTimeout(()=>{t.value=!1},e)},copied:t}}var u={class:`relative inline-grid`},d={class:`invisible col-start-1 row-start-1 select-none`,"aria-hidden":`true`},f=i({name:`CopyLabel`,__name:`CopyLabel`,props:{copied:{type:Boolean},label:{default:`Copy`},confirmed:{default:`Copied!`}},setup(n){let i=n;return(l,f)=>(t(),s(`span`,u,[o(`span`,d,r(i.label.length>=i.confirmed.length?i.label:i.confirmed),1),a(c,{name:`label-swap`,mode:`out-in`},{default:e(()=>[(t(),s(`span`,{key:n.copied?`confirmed`:`default`,class:`col-start-1 row-start-1 text-center`},r(n.copied?n.confirmed:n.label),1))]),_:1})]))}});export{l as n,f 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
@@ -0,0 +1 @@
.globe-stage[data-v-9debe317]{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-9debe317]:active{cursor:grabbing}.globe-stage canvas[data-v-9debe317]{width:100%;height:330px;display:block}.globe-tooltip[data-v-9debe317]{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-9debe317]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-9debe317]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-9debe317]{color:var(--color-text-muted)}.tooltip-value[data-v-9debe317]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-9debe317]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-9debe317]{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-9debe317]{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-9debe317]{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-9debe317]{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-9debe317]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-9debe317]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-9debe317]{opacity:.35}.page-tabs[data-v-9debe317]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-9debe317]{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-9debe317]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-9debe317]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-9debe317]{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-9debe317]{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-9debe317]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-9debe317]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-9debe317]{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-9debe317]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-9debe317]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-9debe317]{transform:rotate(180deg)}.accordion-body[data-v-9debe317]{padding:0 20px 16px}

Some files were not shown because too many files have changed in this diff Show More