71 Commits

Author SHA1 Message Date
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
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
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
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 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
148 changed files with 5723 additions and 608 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/pymc-repeater:main
# Storage defaults to Docker named volumes. This is the safest option for
# Portainer and fresh installs because Docker preserves the image ownership.
# To use host bind mounts instead, create the folders first and make them
# writable by UID/GID 15888:
# sudo mkdir -p /opt/pymc-repeater/config /opt/pymc-repeater/data
# sudo chown -R 15888:15888 /opt/pymc-repeater/config /opt/pymc-repeater/data
# Then uncomment and adjust these paths:
# PYMC_CONFIG_VOLUME=/opt/pymc-repeater/config
# PYMC_DATA_VOLUME=/opt/pymc-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
+3
View File
@@ -51,8 +51,10 @@ htmlcov/
*~
# Config
.env
config.yaml
config.yaml.backup
policy.yaml
identity.json
# Data
@@ -65,3 +67,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)$
+366 -264
View File
@@ -1,210 +1,231 @@
# pyMC_repeater
# pyMC Repeater
Repeater Daemon in Python using the `pymc_core` Lib.
Lightweight Python MeshCore repeater daemon built on `pymc_core`.
---
pyMC 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 `pymc_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
pyMC 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
git clone https://github.com/pyMC-dev/pyMC_Repeater.git
cd pyMC_Repeater
```
**Quick Install:**
### Quick Install
```bash
sudo ./manage.sh
sudo bash ./manage.sh install
```
This script 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
The installer will:
- Create a dedicated `repeater` service user with hardware access
- Install application files to `/opt/pymc_repeater`
- Create the configuration directory at `/etc/pymc_repeater`
- Create the log directory at `/var/log/pymc_repeater`
- Launch the interactive radio and hardware setup wizard
- Install and enable the `pymc-repeater` systemd service
After installation:
**After Installation:**
```bash
# View live logs
sudo journalctl -u pymc-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:
```
The main configuration file is created during installation:
```text
/etc/pymc_repeater/config.yaml
```
### Optional pyMC_Glass integration
The repeater now supports an additive `glass` config section for central control-plane integration.
When enabled, it sends periodic `/inform` payloads to pyMC_Glass, receives queued commands, and reports command results on the next inform cycle.
### 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/pymc_repeater
```
You can also launch the management menu:
```bash
sudo ./manage.sh
sudo systemctl restart pymc-repeater
```
### Optional pyMC_Glass Integration
pyMC 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
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.
pyMC 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/pyMC_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 | `pymc-repeater` |
| RAM | 1024 MB |
| Disk | 4 GB |
| CPU cores | 2 |
| Bridge | `vmbr0` |
| Storage | `local-lvm` |
| Password | `pymc` |
### After Installation
@@ -293,108 +332,167 @@ pct enter <CTID>
# View service logs
journalctl -u pymc-repeater -f
# Access web dashboard
http://<container-ip>:8000
# Manage the repeater
cd /opt/pymc_repeater && bash manage.sh
cd /opt/pymc_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 pyMC 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/pymc_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:
pymc-repeater:
image: ${PYMC_REPEATER_IMAGE:-pymcdev/pymc-repeater:main}
container_name: pymc-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:-pymc-repeater-config}:/etc/pymc_repeater
- ${PYMC_DATA_VOLUME:-pymc-repeater-data}:/var/lib/pymc_repeater
volumes:
pymc-repeater-config:
pymc-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 `pymc_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/pyMC-dev/pyMC_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.
+16 -1
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:
@@ -369,7 +374,7 @@ radio:
coding_rate: 8
# Preamble length in symbols
preamble_length: 17
preamble_length: 32
# Use implicit header mode
implicit_header: false
@@ -378,6 +383,16 @@ radio:
# 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
+12
View File
@@ -0,0 +1,12 @@
services:
pymc-repeater:
image: pymc-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}
+12 -9
View File
@@ -1,10 +1,6 @@
services:
pymc-repeater:
build:
context: .
args:
PUID: ${PUID:-1000}
PGID: ${PGID:-1000}
image: ${PYMC_REPEATER_IMAGE:-pymcdev/pymc-repeater:main}
container_name: pymc-repeater
restart: unless-stopped
ports:
@@ -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:-pymc-repeater-config}:/etc/pymc_repeater
- ${PYMC_DATA_VOLUME:-pymc-repeater-data}:/var/lib/pymc_repeater
volumes:
pymc-repeater-config:
pymc-repeater-data:
+46 -3
View File
@@ -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/pymc_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/pymc-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
+12 -2
View File
@@ -5,6 +5,9 @@ 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
@@ -16,7 +19,10 @@ ENV INSTALL_DIR=/opt/pymc_repeater \
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

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/pymc-repeater:main
# Storage defaults to Docker named volumes. This is the safest option for
# Portainer and fresh installs because Docker preserves the image ownership.
# To use host bind mounts instead, create the folders first and make them
# writable by UID/GID 15888:
# sudo mkdir -p /opt/pymc-repeater/config /opt/pymc-repeater/data
# sudo chown -R 15888:15888 /opt/pymc-repeater/config /opt/pymc-repeater/data
# Then uncomment and adjust these paths:
# PYMC_CONFIG_VOLUME=/opt/pymc-repeater/config
# PYMC_DATA_VOLUME=/opt/pymc-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
+22 -4
View File
@@ -503,10 +503,19 @@ if [ -n "$ARCH_TAG" ]; then
"$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 \
if "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"; then
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/pyMC_Repeater/${CHANNEL}"
RADIO_STORAGE_DIR="/var/lib/pymc_repeater"
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
@@ -907,10 +916,19 @@ python3 -m pip uninstall -y pymc_core 2>/dev/null || true
"$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 \
if "$VENV_PIP" install \
--upgrade \
--no-cache-dir \
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"
"pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}"; then
# Keep web/OTA updates aligned with manage.sh install/upgrade defaults.
RADIO_BASE_URL="https://raw.githubusercontent.com/rightup/pyMC_Repeater/${CHANNEL}"
RADIO_STORAGE_DIR="/var/lib/pymc_repeater"
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"
+81 -29
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,7 +407,7 @@
"connection_type": "network",
"radio_type": "pymc_tcp",
"tx_power": 22,
"preamble_length": 16
"preamble_length": 32
},
"kiss": {
"name": "KISS modem (serial)",
@@ -363,7 +415,7 @@
"connection_type": "usb",
"radio_type": "kiss",
"tx_power": 14,
"preamble_length": 17
"preamble_length": 32
}
}
}
+11 -1
View File
@@ -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()
+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 pymc_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 pymc_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 pymc_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 pymc_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,
}
)
+73
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."""
@@ -207,6 +265,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
@@ -481,7 +541,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,
+77 -2
View File
@@ -860,6 +860,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()
@@ -2290,6 +2331,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 +2641,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 +2651,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 +2682,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
@@ -310,6 +310,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)
+74 -15
View File
@@ -23,6 +23,7 @@ from pymc_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)
@@ -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())
+27
View File
@@ -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
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(
@@ -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}")
+102 -5
View File
@@ -6,7 +6,9 @@ This module processes login requests and manages authentication for all identiti
import asyncio
import logging
import time
from pymc_core.node.handlers.anon_request import AnonRateLimiter, AnonRequestHandler
from pymc_core.node.handlers.login_server import LoginServerHandler
from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ
@@ -14,15 +16,27 @@ 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; pyMC_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"
)
+124 -39
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
@@ -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
@@ -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 pymc_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)
@@ -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 pymc_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:
@@ -1093,7 +1178,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,
+203 -24
View File
@@ -19,12 +19,35 @@ from pymc_core.protocol.constants import (
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 pymc_core.protocol.constants import PAYLOAD_TYPE_GRP_DATA, PAYLOAD_TYPE_GRP_TXT
from pymc_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
+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
File diff suppressed because it is too large Load Diff
+24 -3
View File
@@ -15,8 +15,12 @@ import time
from typing import Optional
import cherrypy
from pymc_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
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
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{f as e,g as t,k as n,u as r}from"./runtime-core.esm-bundler-C5QBTNWE.js";var i=t({name:`HelpView`,__name:`Help`,setup(t){return(t,i)=>(n(),r(`div`,null,[...i[0]||=[e(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> pyMC Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.bg-gradient-light[data-v-a8af9668]{background:linear-gradient(#0d73774d,#aae8e833)}.bg-gradient-dark[data-v-a8af9668]{background:linear-gradient(#aae8e82e,#0d73771a)}.login-card[data-v-a8af9668]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffd9}.dark .login-card[data-v-a8af9668]{background:#1a1e1fcc}.input-glass[data-v-a8af9668]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-a8af9668]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-a8af9668]:focus{background:#fff}.dark .input-glass[data-v-a8af9668]:focus{background:#ffffff1a}.input-glass[data-v-a8af9668]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-a8af9668]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-a8af9668]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-a8af9668]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-a8af9668]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-a8af9668]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-a8af9668]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-a8af9668]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}@keyframes float-a8af9668{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-a8af9668{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-a8af9668{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-a8af9668{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-a8af9668]{animation:8s ease-in-out infinite pulse-slow-a8af9668}.animate-pulse-slower[data-v-a8af9668]{animation:10s ease-in-out infinite pulse-slower-a8af9668}.animate-pulse-slowest[data-v-a8af9668]{animation:12s ease-in-out infinite pulse-slowest-a8af9668}@keyframes shake-a8af9668{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-a8af9668]{animation:.5s ease-in-out shake-a8af9668}.form-group[data-v-a8af9668]{position:relative}.form-group:hover label[data-v-a8af9668]{color:#aae8e8e6;transition:color .3s}
@@ -0,0 +1 @@
.bg-gradient-light[data-v-5b583b9d]{background:linear-gradient(#0d73774d,#aae8e833)}.bg-gradient-dark[data-v-5b583b9d]{background:linear-gradient(#aae8e82e,#0d73771a)}.login-card[data-v-5b583b9d]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffd9}.dark .login-card[data-v-5b583b9d]{background:#1a1e1fcc}.input-glass[data-v-5b583b9d]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-5b583b9d]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-5b583b9d]:focus{background:#fff}.dark .input-glass[data-v-5b583b9d]:focus{background:#ffffff1a}.input-glass[data-v-5b583b9d]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-5b583b9d]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-5b583b9d]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-5b583b9d]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-5b583b9d]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-5b583b9d]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-5b583b9d]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-5b583b9d]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}@keyframes float-5b583b9d{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-5b583b9d{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-5b583b9d{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-5b583b9d{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-5b583b9d]{animation:8s ease-in-out infinite pulse-slow-5b583b9d}.animate-pulse-slower[data-v-5b583b9d]{animation:10s ease-in-out infinite pulse-slower-5b583b9d}.animate-pulse-slowest[data-v-5b583b9d]{animation:12s ease-in-out infinite pulse-slowest-5b583b9d}@keyframes shake-5b583b9d{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-5b583b9d]{animation:.5s ease-in-out shake-5b583b9d}.form-group[data-v-5b583b9d]{position:relative}.form-group:hover label[data-v-5b583b9d]{color:#aae8e8e6;transition:color .3s}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[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`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
@@ -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:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},g={class:`flex`},_=n({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(n,{emit:_}){let v=n,y=_,b={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(n,_)=>(i(),t(r,{to:`body`},[v.show?(i(),s(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[o(`div`,u,[o(`div`,d,[o(`div`,{class:c([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(i(),s(`svg`,f,[..._[2]||=[o(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(i(),s(`svg`,p,[..._[3]||=[o(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(i(),s(`svg`,m,[..._[4]||=[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`,h,e(v.message),1)]),o(`div`,g,[o(`button`,{onClick:_[0]||=e=>y(`close`),class:c([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):a(``,!0)]))}});export{_ 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
@@ -1 +0,0 @@
import{o as e}from"./index-DTUpsCzx.js";export{e as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{Ct as e,g as t,j as n,k as r,l as i,o as a,r as o,s,u as c,xt as l}from"./runtime-core.esm-bundler-C5QBTNWE.js";import{t as u}from"./system-DbBvxitf.js";import{t as d}from"./index-DTUpsCzx.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading dark:text-white`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading dark:text-white`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading dark:text-white`},w={key:0,class:`text-sm`},T={class:`ml-2 text-red-600 dark:text-red-300`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/10`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading dark:text-white`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=t({name:`SensorsView`,__name:`Sensors`,setup(t){let M=u(),N=a(()=>M.stats?.sensors??null),P=a(()=>N.value?.readings??[]),F=a(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(t,a)=>(r(),c(`div`,f,[s(`div`,p,[s(`div`,{class:`flex items-start justify-between gap-4`},[a[0]||=s(`div`,null,[s(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading dark:text-white`},`Sensors`),s(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),s(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/10 px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5`,onClick:R},` Refresh `)]),s(`div`,m,[(r(!0),c(o,null,n(F.value,t=>(r(),c(`div`,{key:t.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/10 p-3`},[s(`p`,h,e(t.label),1),s(`p`,g,e(t.value),1)]))),128))])]),N.value?i(``,!0):(r(),c(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(r(!0),c(o,null,n(P.value,(t,u)=>(r(),c(`div`,{key:`${t.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[s(`div`,v,[s(`div`,null,[s(`h2`,y,e(t.name||`Sensor ${u+1}`),1),s(`p`,b,`Type: `+e(t.type||`unknown`),1)]),s(`span`,{class:l([`rounded-full px-3 py-1 text-xs font-semibold`,t.ok?`bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300`:`bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300`])},e(t.ok?`OK`:`Error`),3)]),s(`div`,x,[s(`div`,S,[a[1]||=s(`span`,{class:`text-content-muted`},`Timestamp:`,-1),s(`span`,C,e(L(t.timestamp)),1)]),t.error?(r(),c(`div`,w,[a[2]||=s(`span`,{class:`text-content-muted`},`Error:`,-1),s(`span`,T,e(t.error),1)])):i(``,!0)]),s(`div`,E,[s(`table`,D,[a[4]||=s(`thead`,{class:`bg-black/5 dark:bg-white/5`},[s(`tr`,null,[s(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),s(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),s(`tbody`,null,[(r(!0),c(o,null,n(t.data||{},(t,n)=>(r(),c(`tr`,{key:String(n),class:`border-t border-stroke-subtle dark:border-white/10`},[s(`td`,O,e(n),1),s(`td`,k,e(I(t)),1)]))),128)),!t.data||Object.keys(t.data).length===0?(r(),c(`tr`,A,[...a[3]||=[s(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):i(``,!0)])])])]))),128)),N.value&&P.value.length===0?(r(),c(`div`,j,` Sensors are configured but no readings are available yet. `)):i(``,!0)]))}});export{M as default};
@@ -0,0 +1 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-OIM0xrD-.js";import{t as d}from"./index-KkHsVs4Y.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading dark:text-white`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading dark:text-white`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading dark:text-white`},w={key:0,class:`text-sm`},T={class:`ml-2 text-red-600 dark:text-red-300`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/10`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading dark:text-white`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading dark:text-white`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/10 px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/10 p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300`:`bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/5 dark:bg-white/5`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/10`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
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 @@
.glass-card[data-v-969cd812]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffff0d;border:1px solid #ffffff1a}.modal-enter-active[data-v-969cd812],.modal-leave-active[data-v-969cd812]{transition:opacity .3s}.modal-enter-from[data-v-969cd812],.modal-leave-to[data-v-969cd812]{opacity:0}.modal-enter-active .glass-card[data-v-969cd812],.modal-leave-active .glass-card[data-v-969cd812]{transition:transform .3s}.modal-enter-from .glass-card[data-v-969cd812],.modal-leave-to .glass-card[data-v-969cd812]{transform:scale(.9)}.slide-enter-active[data-v-969cd812],.slide-leave-active[data-v-969cd812]{transition:all .3s}.slide-enter-from[data-v-969cd812],.slide-leave-to[data-v-969cd812]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-969cd812{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-969cd812{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-969cd812{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-969cd812]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-969cd812}.animate-pulse-slower[data-v-969cd812]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-969cd812}.animate-pulse-slowest[data-v-969cd812]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-969cd812}
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 @@
.glass-card[data-v-71a51d65]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffff0d;border:1px solid #ffffff1a}.modal-enter-active[data-v-71a51d65],.modal-leave-active[data-v-71a51d65]{transition:opacity .3s}.modal-enter-from[data-v-71a51d65],.modal-leave-to[data-v-71a51d65]{opacity:0}.modal-enter-active .glass-card[data-v-71a51d65],.modal-leave-active .glass-card[data-v-71a51d65]{transition:transform .3s}.modal-enter-from .glass-card[data-v-71a51d65],.modal-leave-to .glass-card[data-v-71a51d65]{transform:scale(.9)}.slide-enter-active[data-v-71a51d65],.slide-leave-active[data-v-71a51d65]{transition:all .3s}.slide-enter-from[data-v-71a51d65],.slide-leave-to[data-v-71a51d65]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-71a51d65{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-71a51d65{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-71a51d65{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-71a51d65]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-71a51d65}.animate-pulse-slower[data-v-71a51d65]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-71a51d65}.animate-pulse-slowest[data-v-71a51d65]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-71a51d65}
@@ -0,0 +1 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-OIM0xrD-.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=5;function p(e,t){return e-t}function m(e){return l[e]??l[d]}function h(e,t){let n=t+f;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,bgColor:`bg-accent-red`,snr:e,quality:n===0?`None`:`Poor`}}if(e<n){let n=(e-t)/f<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,bgColor:n===2?`bg-orange-600 dark:bg-orange-400`:`bg-yellow-600 dark:bg-yellow-400`,snr:e,quality:`Fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,bgColor:`bg-accent-green`,snr:e,quality:r===5?`Excellent`:`Good`}}function g(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>m(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,bgColor:`bg-gray-400 dark:bg-gray-500`,snr:-999,quality:`None`};let n=p(e,t.value);return h(Math.max(-30,Math.min(20,n)),r.value)},noiseFloor:t,spreadingFactor:n,minSNR:r}}var _={class:`flex items-end gap-0.5`},v=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,_,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{g as n,v as t};
@@ -0,0 +1 @@
import{T as e,h as t,ht as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=t({__name:`Spinner`,props:{size:{default:`md`},color:{default:`primary`}},setup(t){return(i,a)=>(e(),r(`div`,{class:n([`rounded-full animate-spin`,{"w-3 h-3 border-b":t.size===`xs`,"w-4 h-4 border-b-2":t.size===`sm`,"w-8 h-8 border-b-2":t.size===`md`,"w-12 h-12 border-b-2":t.size===`lg`,"border-primary":t.color===`primary`,"border-white":t.color===`white`,"border-current":t.color===`current`}])},null,2))}});export{i as t};
@@ -1 +0,0 @@
import{g as e,k as t,u as n,xt as r}from"./runtime-core.esm-bundler-C5QBTNWE.js";var i=e({__name:`Spinner`,props:{size:{default:`md`},color:{default:`primary`}},setup(e){return(i,a)=>(t(),n(`div`,{class:r([`rounded-full animate-spin`,{"w-3 h-3 border-b":e.size===`xs`,"w-4 h-4 border-b-2":e.size===`sm`,"w-8 h-8 border-b-2":e.size===`md`,"w-12 h-12 border-b-2":e.size===`lg`,"border-primary":e.color===`primary`,"border-white":e.color===`white`,"border-current":e.color===`current`}])},null,2))}});export{i as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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 @@
var e=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{e as t};
File diff suppressed because one or more lines are too long

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