Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f980e4e03 | |||
| 0ccc3536ee | |||
| 19ae4516da | |||
| bca3f3718f | |||
| 6e9b798ef3 | |||
| a152e0e846 | |||
| 8fd28b7d15 | |||
| 9f18a1db79 | |||
| de129c1c15 | |||
| 8b948e1305 | |||
| b9c82b5778 | |||
| 40c7933556 | |||
| b659aa00d5 | |||
| dcaa4ac949 | |||
| 9459dd5bba | |||
| 79684736bc | |||
| 23078de78b | |||
| 3d974136a6 | |||
| 291c1a6ed9 | |||
| aeea4bb3f8 | |||
| 063c8eeb8c | |||
| 99a04295da | |||
| 1f9be5a024 | |||
| 10b64bce62 | |||
| 7b85cfcec9 | |||
| 88820038ee | |||
| 524359de9f | |||
| 0b4571cb68 | |||
| 2b7b2b5b4e | |||
| eb717cc745 | |||
| c5dbfdcd4b | |||
| f4533e5ef6 | |||
| 183650228e | |||
| d5001a235d | |||
| 1569b11690 | |||
| 77480c6c1c | |||
| ca50656560 | |||
| d333deb1e5 | |||
| 7015e0eb15 | |||
| b5df705b87 | |||
| 9e8c152f0b | |||
| a308ddc00d | |||
| 5dfa98c57c | |||
| 00682e8086 | |||
| f3146ebc14 | |||
| ea6e660f34 | |||
| dac60443f0 | |||
| af603d78d0 | |||
| 879aac1556 | |||
| b3119f97f4 | |||
| da95c67cef | |||
| 767c070384 | |||
| 8926b3d593 | |||
| b5b2c60eb6 | |||
| e9a9f21cab | |||
| 225feda195 | |||
| 14b4804c26 | |||
| 4abc497e83 | |||
| 7fe1b19241 | |||
| 499f871262 | |||
| 7d57b34a04 | |||
| cd7058be99 | |||
| 416310befd | |||
| 9e26068a10 | |||
| d7e74e0a89 | |||
| e24cdca055 | |||
| ee92f5b1a9 | |||
| 5a9e1c87cc | |||
| 778adb6917 | |||
| 5fcb6255d5 | |||
| 2d5353ad7d |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)$
|
||||
|
||||
@@ -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 I’d 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 — it’s 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.
|
||||
|
||||
There’s still plenty of room for this project to grow and improve — but you’ve 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.
|
||||
|
||||
> **I’d 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 |
|
||||
> | USB–SPI 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 USB–SPI 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
|
||||
|
||||

|
||||
*Real-time monitoring dashboard showing packet statistics, neighbor discovery, and system status*
|
||||
|
||||
Real-time packet statistics, neighbor discovery, and system status.
|
||||
|
||||
### Statistics
|
||||
|
||||

|
||||
*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
|
||||
|
||||

|
||||
|
||||
#### Repeater Name
|
||||
|
||||

|
||||
|
||||
#### Hardware Type
|
||||
|
||||

|
||||
|
||||
#### Choose A Preset
|
||||
|
||||

|
||||
|
||||
#### TX Power
|
||||
|
||||
TX power defaults to 14 dBm and can be changed later.
|
||||
|
||||

|
||||
|
||||
#### Set A Password
|
||||
|
||||

|
||||
|
||||
#### Update TX settings
|
||||
|
||||

|
||||
|
||||
#### Run CAD Calibration
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### Example: Drop Channel packets over two hops
|
||||

|
||||
|
||||
```
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} \
|
||||
|
||||
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,13 +59,23 @@ class CompanionFrameServer(_BaseFrameServer):
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
async def _persist_companion_message(self, msg_dict: dict) -> None:
|
||||
"""Persist message to SQLite and pop from bridge queue."""
|
||||
"""Persist message to SQLite and pop from bridge queue.
|
||||
|
||||
The bridge's ``offline_queue_size`` (``message_queue._max_size``) doubles
|
||||
as the SQLite retention limit: 0 disables offline storage entirely, so the
|
||||
message is dropped instead of persisted.
|
||||
"""
|
||||
if not self.sqlite_handler:
|
||||
return
|
||||
retention = getattr(self.bridge.message_queue, "_max_size", None)
|
||||
if retention == 0:
|
||||
self.bridge.message_queue.pop_last()
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
self.sqlite_handler.companion_push_message,
|
||||
self.companion_hash,
|
||||
msg_dict,
|
||||
retention,
|
||||
)
|
||||
self.bridge.message_queue.pop_last()
|
||||
|
||||
|
||||
@@ -1,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -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 & 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 & 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};
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -1 +0,0 @@
|
||||
import{o as e}from"./index-DTUpsCzx.js";export{e as default};
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
@@ -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};
|
||||
@@ -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};
|
||||