fix(bot): per-sender cooldown + empty-channel fallback (v1.20.1)

- Replace global _last_reply float with _last_reply_per_sender dict.
  A reply to one node no longer blocks all other senders for 5 s.
  LRU eviction keeps the dict bounded at 200 entries.

- _get_active_channels() now falls back to BotConfig defaults when
  the stored channel set is empty (user never saved a selection).
  Bot was silently deaf on first run despite the panel showing all
  channels pre-checked.

Closes: bot only replies to first sender in multi-node #test session.
This commit is contained in:
pe1hvh
2026-04-16 07:07:50 +02:00
parent ad91b27c62
commit da3a868ec6
43 changed files with 422 additions and 6337 deletions

333
BRIDGE.md
View File

@@ -1,333 +0,0 @@
# MeshCore Bridge — Cross-Frequency Message Bridge
### No MQTT, no broker, no cloud. Just LoRa ↔ LoRa.
![Status](https://img.shields.io/badge/Status-Production-green.svg)
![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)
![License](https://img.shields.io/badge/License-MIT-green.svg)
![Platform](https://img.shields.io/badge/Platform-Linux-orange.svg)
![Transport](https://img.shields.io/badge/Transport-Dual%20USB%20Serial-blueviolet.svg)
![Bridge](https://img.shields.io/badge/Bridge-Cross--Frequency%20LoRa%20↔%20LoRa-ff6600.svg)
A standalone daemon that connects two MeshCore devices operating on different radio frequencies. It forwards messages on a configurable bridge channel from one device to the other, effectively extending your mesh network across frequency boundaries.
## Table of Contents
- [1. Overview](#1-overview)
- [2. Features](#2-features)
- [3. Requirements](#3-requirements)
- [3.1. Requirement Status](#31-requirement-status)
- [4. Installation](#4-installation)
- [4.1. Quick Start](#41-quick-start)
- [4.2. systemd Service](#42-systemd-service)
- [5. Configuration](#5-configuration)
- [5.1. Bridge Settings](#51-bridge-settings)
- [5.2. Command-Line Options](#52-command-line-options)
- [6. How It Works](#6-how-it-works)
- [6.1. Message Flow](#61-message-flow)
- [6.2. Loop Prevention](#62-loop-prevention)
- [6.3. Private (Encrypted) Channels](#63-private-encrypted-channels)
- [7. Dashboard](#7-dashboard)
- [8. File Structure](#8-file-structure)
- [9. Assumptions](#9-assumptions)
- [10. Troubleshooting](#10-troubleshooting)
- [10.1. Bridge Won't Start](#101-bridge-wont-start)
- [10.2. Messages Not Forwarding](#102-messages-not-forwarding)
- [10.3. Port Conflicts](#103-port-conflicts)
- [10.4. Service Issues](#104-service-issues)
- [11. License](#11-license)
- [12. Author](#12-author)
---
## 1. Overview
The bridge runs as an independent process alongside (or instead of) the regular meshcore_gui instances. It imports the existing meshcore_gui modules (SharedData, Worker, models, config) as a library and requires **zero modifications** to the meshcore_gui codebase.
```
┌───────────────────────────────────────────┐
│ meshcore_bridge daemon │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ SharedData A │ │ BridgeEngine │ │
│ │ + Worker A │◄──►│ (forward & │ │
│ │ (ttyUSB1) │ │ dedup) │ │
│ └──────────────┘ └────────────────┘ │
│ ┌──────────────┐ │ │
│ │ SharedData B │◄────────┘ │
│ │ + Worker B │ │
│ │ (ttyUSB2) │ │
│ └──────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Bridge Dashboard (NiceGUI :9092) │ │
│ │ - Device A & B status │ │
│ │ - Forwarded message log │ │
│ │ - Bridge config view │ │
│ └───────────────────────────────────┘ │
└───────────────────────────────────────────┘
```
Key properties:
- **Separate process** — the bridge runs independently from meshcore_gui; both can run simultaneously on the same host
- **Loop prevention** — three mechanisms prevent message loops: direction filter, message hash tracking, and echo suppression
- **Private channels** — encrypted channels work transparently because the bridge operates at the plaintext level between firmware decryption and encryption
- **DOMCA dashboard** — status page on its own port showing both device connections, bridge statistics and a forwarded message log
- **YAML configuration** — all settings in a single `bridge_config.yaml` file
## 2. Features
- **Bidirectional forwarding** — Messages on the bridge channel are forwarded A→B and B→A automatically
- **Loop prevention** — Direction filter, message hash tracking, and echo suppression prevent infinite loops
- **Private channel support** — Encrypted channels work transparently; bridge operates at the plaintext level
- **DOMCA dashboard** — Live status page with device connections, bridge statistics and forwarded message log
- **YAML configuration** — All settings in a single `bridge_config.yaml` file
- **systemd integration** — Install as a background daemon with automatic restart
- **Zero meshcore_gui changes** — Imports existing modules as a library, 0 changed files
## 3. Requirements
- Python 3.10+
- meshcore_gui (installed or on PYTHONPATH)
- meshcore Python library (`pip install meshcore`)
- pyyaml (`pip install pyyaml`)
- Two MeshCore devices connected via USB serial
### 3.1. Requirement Status
| ID | Requirement | Status |
|----|-------------|--------|
| M1 | Bridge as separate process, meshcore_gui unchanged | ✅ |
| M2 | Forward messages on #bridge channel A↔B within <2s | ✅ |
| M3 | YAML config for channel, ports, polling interval | ✅ |
| M4 | 0 changed files in meshcore_gui/ | ✅ |
| M5 | GUI identical to meshcore_gui (DOMCA theme) | ✅ |
| M6 | Configurable port (--port=9092) | ✅ |
| M7 | Loop prevention via forwarded-hash set | ✅ |
| M8 | Two devices on different frequencies | ✅ |
| M9 | Private (encrypted) channels fully supported | ✅ |
---
## 4. Installation
### 4.1. Quick Start
```bash
# 1. Install the dependency
pip install pyyaml
# 2. Copy the config template and edit it
cp bridge_config.yaml /etc/meshcore/bridge_config.yaml
nano /etc/meshcore/bridge_config.yaml
# 3. Run the bridge
python meshcore_bridge.py --config=/etc/meshcore/bridge_config.yaml
# 4. Open the dashboard at http://your-host:9092
```
**Prerequisites:** Two MeshCore devices connected via USB serial to the same host, with the bridge channel configured on both devices using the same channel secret/password.
### 4.2. systemd Service
Install the bridge as a systemd daemon for production use:
```bash
# Run the installer script
sudo bash install_bridge.sh
# Edit the configuration
sudo nano /etc/meshcore/bridge_config.yaml
# Start the service
sudo systemctl start meshcore-bridge
sudo systemctl enable meshcore-bridge
```
**Useful service commands:**
| Command | Description |
|---------|-------------|
| `sudo systemctl status meshcore-bridge` | Check if the service is running |
| `sudo journalctl -u meshcore-bridge -f` | Follow the live log output |
| `sudo systemctl restart meshcore-bridge` | Restart after a configuration change |
| `sudo systemctl stop meshcore-bridge` | Stop the service |
**Uninstall:**
```bash
sudo bash install_bridge.sh --uninstall
```
---
## 5. Configuration
### 5.1. Bridge Settings
All settings are defined in `bridge_config.yaml`:
```yaml
bridge:
channel_name: "bridge" # Channel name (for display)
channel_idx_a: 3 # Channel index on device A
channel_idx_b: 3 # Channel index on device B
poll_interval_ms: 200 # Polling interval (ms)
forward_prefix: true # Add [sender] prefix
max_forwarded_cache: 500 # Loop prevention cache size
device_a:
port: /dev/ttyUSB1 # Serial port device A
baud: 115200 # Baud rate
label: "869.525 MHz" # Dashboard label
device_b:
port: /dev/ttyUSB2 # Serial port device B
baud: 115200 # Baud rate
label: "868.000 MHz" # Dashboard label
gui:
port: 9092 # Dashboard port
title: "MeshCore Bridge" # Browser tab title
```
### 5.2. Command-Line Options
| Flag | Description | Default |
|------|-------------|---------|
| `--config=PATH` | Path to YAML config file | `./bridge_config.yaml` |
| `--port=PORT` | Override GUI port | From config (9092) |
| `--debug-on` | Enable debug logging | Off |
| `--help` | Show usage info | — |
---
## 6. How It Works
### 6.1. Message Flow
1. **Device A** receives a channel message on the bridge channel via LoRa
2. MeshCore firmware decrypts the message (if private channel) and passes plaintext to the Worker
3. The Worker's EventHandler stores the message in **SharedData A**
4. **BridgeEngine** polls SharedData A, detects the new message, checks dedup hash set
5. BridgeEngine injects a `send_message` command into **SharedData B**'s command queue
6. Worker B picks up the command and transmits the message on Device B's bridge channel
7. MeshCore firmware on Device B encrypts (if private channel) and transmits via LoRa
The reverse direction (B→A) works identically.
### 6.2. Loop Prevention
The bridge uses three mechanisms to prevent message loops:
1. **Direction filter** — Only incoming messages (`direction='in'`) are forwarded. Messages we transmitted (`direction='out'`) are never forwarded.
2. **Message hash tracking** — Each forwarded message's hash is stored in a bounded set (configurable via `max_forwarded_cache`). If the same hash appears again, it is blocked.
3. **Echo suppression** — When a message is forwarded, the hash of the forwarded text (including `[sender]` prefix) is also registered, preventing the forwarded message from being re-forwarded when it appears on the target device.
### 6.3. Private (Encrypted) Channels
The bridge works transparently with both public and private channels. No extra configuration is needed beyond ensuring that both devices have the same channel secret/password:
- **Inbound**: MeshCore firmware decrypts → Worker receives plaintext → BridgeEngine reads plaintext
- **Outbound**: BridgeEngine injects command → Worker sends via meshcore lib → Firmware encrypts → LoRa TX
> **Prerequisite:** The bridge channel MUST be configured on both devices with **identical channel secret/password**. Only the frequency and channel index may differ.
---
## 7. Dashboard
The bridge dashboard is accessible at `http://your-host:9092` (or your configured port) and shows:
- **Configuration summary** — active channel, indices, poll interval
- **Device A status** — connection state, device name, radio frequency
- **Device B status** — connection state, device name, radio frequency
- **Bridge statistics** — messages forwarded (total, A→B, B→A), duplicates blocked, uptime
- **Forwarded message log** — last 200 forwarded messages with timestamps and direction
The dashboard uses the same DOMCA theme as meshcore_gui with dark/light mode toggle.
---
## 8. File Structure
```
meshcore_bridge.py # Entry point (~25 lines)
meshcore_bridge/
├── __init__.py # Package init
├── __main__.py # CLI, dual-worker setup, NiceGUI server (~180 lines)
├── config.py # YAML config loading (~130 lines)
├── bridge_engine.py # Core bridge logic (~250 lines)
└── gui/
├── __init__.py # GUI package init
├── dashboard.py # Bridge dashboard page (~180 lines)
└── panels/
├── __init__.py # Panels package init
├── status_panel.py # Device connection status (~180 lines)
└── log_panel.py # Forwarded message log (~100 lines)
bridge_config.yaml # Configuration template
install_bridge.sh # systemd service installer
BRIDGE.md # This documentation
```
**Total new code:** ~1,050 lines
**Changed files in meshcore_gui/:** 0 (zero)
---
## 9. Assumptions
- Both MeshCore devices are connected via USB serial to the same host (Raspberry Pi / Linux server)
- The bridge channel exists on both devices with the same name (but possibly different index)
- The bridge channel has identical channel secret/password on both devices
- The meshcore_gui package is importable (installed via `pip install -e .` or on PYTHONPATH)
- Sufficient CPU/RAM for two simultaneous MeshCore connections (~100MB)
- Messages are forwarded with a sender prefix `[original_sender]` for identification
---
## 10. Troubleshooting
### 10.1. Bridge Won't Start
- Check that both serial ports exist: `ls -l /dev/ttyUSB*`
- Verify meshcore_gui is importable: `python -c "from meshcore_gui.core.shared_data import SharedData"`
- Check pyyaml is installed: `pip install pyyaml`
### 10.2. Messages Not Forwarding
- Verify the bridge channel index matches on both devices
- Check that the channel secret is identical on both devices
- Look at the dashboard: are both devices showing "Connected"?
- Enable debug mode: `python meshcore_bridge.py --debug-on`
### 10.3. Port Conflicts
| Daemon | Default Port |
|---|---|
| meshcore_gui | 8081 |
| **meshcore_bridge** | **9092** |
| meshcore_observer | 9093 |
Change via `--port=XXXX` or in `bridge_config.yaml`.
### 10.4. Service Issues
```bash
sudo systemctl status meshcore-bridge
journalctl -u meshcore-bridge -f
sudo systemctl restart meshcore-bridge
```
---
## 11. License
MIT License — Copyright (c) 2026 PE1HVH
## 12. Author
**PE1HVH** — [GitHub](https://github.com/pe1hvh) — DOMCA MeshCore Project

View File

@@ -10,6 +10,52 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
---
## [1.20.1] - 2026-04-15
### Fixed
- **Bot global cooldown blocked all senders** (`services/bot.py`):
`_last_reply` was a single float, so a reply to any node would start a
5-second silence window for *all* other nodes. During testing with multiple
contacts, only the first `#test` message received a bot reply; subsequent
messages from different senders were silently dropped by Guard 5.
Fix: replaced `_last_reply: float` with `_last_reply_per_sender: Dict[str, float]`.
Each sender now has an independent cooldown window; a reply to one node
does not affect any other node. LRU eviction caps the dict at 200 entries
to prevent unbounded memory growth in long-running sessions.
- **Bot deaf on first run when no channels saved** (`services/bot.py`):
`_get_active_channels()` returned an empty frozenset when `BotConfigStore`
had no saved channel selection (i.e. the user had never clicked
"💾 Save channels"). The bot was therefore silent on all channels despite
the BOT panel showing all channels pre-checked. The `BotSettings.channels`
docstring already documented this as the intended fallback case, but the
code did not implement it.
Fix: `_get_active_channels()` now falls back to `BotConfig.channels`
(`{1, 4}``#test` and `#bot`) when the stored set is empty, matching
the documented intent.
---
## [1.20.0] - 2026-04-10
### Changed
- `services/device_identity.py`: `device_identity.json` upgraded from v1
(single flat object) to v2 (dict keyed by `source_device`). Multiple
GUI instances running on different serial ports (e.g. `/dev/ttyUSB0` and
`/dev/ttyUSB1`) each write their own entry without overwriting each other.
- `write_device_identity()` now reads the existing file before writing,
updating only the entry for the current `source_device`.
- `read_device_identity()` accepts an optional `source_device` parameter:
returns a single entry dict when specified, or the full multi-device dict
when called without arguments.
- `_load_raw()` (internal) handles v1 → v2 migration transparently on first
write: the old flat object is re-keyed under its `source_device` value.
- Console output now includes the device path:
`📝 Device identity saved → ~/.meshcore-gui/device_identity.json [/dev/ttyUSB1]`.
- No changes to `ble/worker.py` or any other module — API is fully backward
compatible.
## [1.19.0] - 2026-04-06
### FIXED

View File

@@ -1,636 +0,0 @@
# MeshCore Observer — Read-Only Archive Monitor
### Multi-source aggregation dashboard with optional MQTT uplink to LetsMesh.
![Status](https://img.shields.io/badge/Status-Production-green.svg)
![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)
![License](https://img.shields.io/badge/License-MIT-green.svg)
![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-orange.svg)
![Device](https://img.shields.io/badge/Device-None%20Required-blueviolet.svg)
![MQTT](https://img.shields.io/badge/MQTT-LetsMesh%20Uplink-ff6600.svg)
A standalone daemon that reads JSON archive files produced by `meshcore_gui` and `meshcore_bridge`, aggregates them from all sources, and presents a unified live dashboard. It never connects to a device and never writes to the archive — it only watches and displays.
## Table of Contents
- [1. Why This Project Exists](#1-why-this-project-exists)
- [2. Features](#2-features)
- [3. Requirements](#3-requirements)
- [4. Installation](#4-installation)
- [4.1. Base Installation (dashboard only)](#41-base-installation-dashboard-only)
- [4.2. MQTT Uplink Dependencies](#42-mqtt-uplink-dependencies)
- [4.3. Verify Installation](#43-verify-installation)
- [5. Quick Start](#5-quick-start)
- [6. Command-Line Options](#6-command-line-options)
- [7. Configuration](#7-configuration)
- [7.1. Observer Settings](#71-observer-settings)
- [7.2. Port Allocation](#72-port-allocation)
- [8. MQTT Uplink to LetsMesh](#8-mqtt-uplink-to-letsmesh)
- [8.1. Prerequisites](#81-prerequisites)
- [8.2. Automatic Key Setup (same machine)](#82-automatic-key-setup-same-machine)
- [8.3. Manual Key Setup (remote GUI)](#83-manual-key-setup-remote-gui)
- [8.4. MQTT Configuration](#84-mqtt-configuration)
- [8.5. Test and Go Live](#85-test-and-go-live)
- [8.6. Privacy Controls](#86-privacy-controls)
- [8.7. Multiple Brokers](#87-multiple-brokers)
- [8.8. How Authentication Works](#88-how-authentication-works)
- [8.9. MQTT Topics](#89-mqtt-topics)
- [9. systemd Installation](#9-systemd-installation)
- [10. How It Works](#10-how-it-works)
- [11. Dashboard Panels](#11-dashboard-panels)
- [12. Running Alongside Other Daemons](#12-running-alongside-other-daemons)
- [13. Troubleshooting](#13-troubleshooting)
- [13.1. Dashboard](#131-dashboard)
- [13.2. MQTT](#132-mqtt)
- [14. Version History](#14-version-history)
- [15. License](#15-license)
- [16. Author](#16-author)
---
## 1. Why This Project Exists
When running multiple MeshCore devices — a GUI instance on 869 MHz, a bridge between 869 and 868 MHz, perhaps another GUI on a different frequency — each writes its own archive files. There is no single place to see all traffic at once.
The Observer solves this by watching all archive files from all sources, merging them into one live dashboard. It requires no device, no serial port and no meshcore library. Just point it at the archive directory and it works.
With MQTT uplink enabled, the Observer can also contribute your node's received packets to the global [LetsMesh analyzer](https://analyzer.letsmesh.net), helping map the mesh network's reach and signal quality.
```
[meshcore_gui] ──writes──► ~/.meshcore-gui/archive/*.json ◄──reads── [Observer]
[meshcore_bridge] ──writes──► │
┌────┴────┐
▼ ▼
NiceGUI MQTT Uplink
Dashboard (optional)
:9093 │
analyzer.letsmesh.net
```
## 2. Features
- **Multi-source aggregation** — Automatically detects and merges archives from all GUI and Bridge instances
- **Live message feed** — Channel messages from all sources, sorted by timestamp, filterable by source and channel
- **Live RX log** — Packet log with SNR, RSSI, type, hops, and decoded path
- **Source overview** — Table of all detected archive files with entry counts
- **Statistics** — Uptime, totals, per-source breakdown
- **MQTT uplink to LetsMesh** — Publishes RX log packets to [analyzer.letsmesh.net](https://analyzer.letsmesh.net) via MQTT over WebSocket+TLS with Ed25519 JWT authentication. Privacy-configurable: choose which packet types to share
- **DOMCA theme** — Dark and light mode, consistent with meshcore_gui and meshcore_bridge
- **Zero device access** — No serial port, no BLE, no meshcore library required
## 3. Requirements
**Dashboard (always required):**
- Python 3.10+
- `nicegui`
- `pyyaml`
**MQTT uplink (optional):**
- `paho-mqtt` >= 2.0
- Node.js 18+ with `@michaelhart/meshcore-decoder`**required** for signing JWT tokens with the orlp/ed25519 algorithm used by LetsMesh
**Note on PyNaCl:** PyNaCl can be used as a fallback for token signing, but **only** with legacy 64-char seed keys. The current `device_identity.json` format uses 128-char orlp/ed25519 expanded keys which are **not compatible** with PyNaCl. For new installations, use Node.js + meshcore-decoder.
Without the MQTT packages the Observer runs fine — only the LetsMesh uplink is disabled.
---
## 4. Installation
### 4.1. Base Installation (dashboard only)
```bash
cd ~/meshcore-gui
source venv/bin/activate
pip install nicegui pyyaml
```
Verify:
```bash
python meshcore_observer.py --help
```
### 4.2. MQTT Uplink Dependencies
**Step 1 — paho-mqtt:**
```bash
pip install paho-mqtt
```
**Step 2 — Node.js (if not already installed):**
```bash
# Check if Node.js is available
node --version
# If not installed (Debian/Ubuntu/Raspberry Pi OS):
sudo apt update && sudo apt install -y nodejs npm
```
**Step 3 — meshcore-decoder:**
```bash
sudo npm install -g @michaelhart/meshcore-decoder
```
**Step 4 — Verify Node.js can find meshcore-decoder:**
```bash
node -e "require('@michaelhart/meshcore-decoder').createAuthToken; console.log('OK')"
```
If this prints `OK`, you're good. If it says `Cannot find module`, Node.js can't find the global install. Fix with:
```bash
# Check where npm installs global packages:
npm root -g
# If it's /usr/local/lib/node_modules, set NODE_PATH:
export NODE_PATH=/usr/local/lib/node_modules
node -e "require('@michaelhart/meshcore-decoder').createAuthToken; console.log('OK')"
```
If you need `NODE_PATH`, add it to your shell profile or systemd service (see [section 9](#9-systemd-installation)).
> **Note:** The Observer auto-detects `NODE_PATH` from `/usr/lib/node_modules`, `/usr/local/lib/node_modules`, and `~/.npm-global/lib/node_modules`. You only need to set it manually if npm uses a non-standard location.
### 4.3. Verify Installation
```bash
cd ~/meshcore-gui
source venv/bin/activate
# Dashboard only:
python -c "import nicegui, yaml; print('Dashboard deps OK')"
# MQTT uplink:
python -c "import paho.mqtt; print('paho-mqtt OK')"
node -e "require('@michaelhart/meshcore-decoder'); console.log('meshcore-decoder OK')"
```
---
## 5. Quick Start
```bash
cd ~/meshcore-gui
source venv/bin/activate
# Run with defaults (dashboard on port 9093, reads ~/.meshcore-gui/archive/)
python meshcore_observer.py
```
The dashboard opens at **http://localhost:9093**. The Observer immediately starts scanning for archive JSON files. If meshcore_gui or meshcore_bridge is running and writing archives, they will appear within seconds.
---
## 6. Command-Line Options
| Flag | Description | Default |
|------|-------------|---------|
| `--config=PATH` | Path to YAML configuration file | `./observer_config.yaml` |
| `--port=PORT` | Override dashboard port | `9093` |
| `--debug-on` | Enable verbose debug logging | Off |
| `--mqtt-dry-run` | Log MQTT payloads without publishing (also enables MQTT) | Off |
| `--help` | Show usage information | — |
Examples:
```bash
python meshcore_observer.py
python meshcore_observer.py --config=/etc/meshcore/observer_config.yaml
python meshcore_observer.py --port=9094 --debug-on
python meshcore_observer.py --mqtt-dry-run --debug-on
```
---
## 7. Configuration
All settings are optional. The Observer works with sensible defaults and no config file.
### 7.1. Observer Settings
**observer_config.yaml:**
```yaml
observer:
archive_dir: "~/.meshcore-gui/archive"
poll_interval_s: 2.0
max_messages_display: 100
max_rxlog_display: 50
gui:
port: 9093
title: "MeshCore Observer"
```
### 7.2. Port Allocation
| Daemon | Default Port |
|---|---|
| meshcore_gui | 8081 / 9090 |
| meshcore_bridge | 9092 |
| **meshcore_observer** | **9093** |
---
## 8. MQTT Uplink to LetsMesh
The Observer can publish RX log packets to the LetsMesh network analyzer at [analyzer.letsmesh.net](https://analyzer.letsmesh.net). MQTT is **disabled by default**.
```
[Observer]
│ RX log entries from archive
├── filter by packet type (privacy)
├── transform to LetsMesh JSON format
├── sign JWT with Ed25519 private key
└──► mqtt-eu-v1.letsmesh.net:443 (WebSocket+TLS)
analyzer.letsmesh.net
```
### 8.1. Prerequisites
Before enabling MQTT, ensure:
1. **MQTT dependencies are installed** (see [section 4.2](#42-mqtt-uplink-dependencies))
2. **meshcore_gui has the fixed `device_identity.py`** — version ≥ 1.2.0 that writes the full 128-char orlp/ed25519 private key. Without this fix, `device_identity.json` contains only a 64-char key that is **not the Ed25519 seed** but a clamped scalar, which causes "Not authorized" errors.
Verify your identity file:
```bash
cat ~/.meshcore-gui/device_identity.json | python -c "
import json, sys
d = json.load(sys.stdin)
pub = d.get('public_key', '')
priv = d.get('private_key', '')
print(f'Public key: {len(pub)} chars — {pub[:16]}...')
print(f'Private key: {len(priv)} chars')
if len(priv) == 128:
print('✅ Correct format (128-char orlp expanded key)')
elif len(priv) == 64:
print('❌ Legacy format (64 chars) — update device_identity.py and restart meshcore_gui')
else:
print(f'❌ Unexpected length: {len(priv)}')
"
```
3. **Your IATA airport code** — 3-letter code for your nearest airport (e.g. `AMS`, `JFK`, `LHR`)
### 8.2. Automatic Key Setup (same machine)
When meshcore_gui and the Observer run on the **same machine**, keys are shared automatically via `~/.meshcore-gui/device_identity.json`. No manual key configuration needed — just add the MQTT section to your config and the Observer reads the keys at startup.
### 8.3. Manual Key Setup (remote GUI)
When meshcore_gui runs on a **different machine**, you need to transfer the keys.
**Option A — Copy the identity file (recommended):**
Copy `~/.meshcore-gui/device_identity.json` from the GUI machine to the Observer machine, then:
```bash
cd ~/meshcore-gui
source venv/bin/activate
python -m meshcore_observer.setup_mqtt_keys --identity /path/to/device_identity.json
```
This saves the private key to `~/.meshcore-observer-key` (chmod 600) and writes the public key to `observer_config.yaml`.
**Option B — Interactive setup:**
```bash
cd ~/meshcore-gui
source venv/bin/activate
python -m meshcore_observer.setup_mqtt_keys
```
You will need:
- The **public key** (64 hex chars) — visible in meshcore_gui device info
- The **private key** (128 hex chars) — from `device_identity.json` on the GUI machine
**Option C — Environment variable:**
```bash
export MESHCORE_PRIVATE_KEY="<128-char hex private key>"
export MESHCORE_PUBLIC_KEY="<64-char hex public key>"
```
### 8.4. MQTT Configuration
Edit `observer_config.yaml`:
```yaml
mqtt:
enabled: true
iata: "AMS" # Your nearest airport code
device_name: "PE1HVH Observer" # Name shown on analyzer.letsmesh.net
# Keys — only needed if meshcore_gui runs on a different machine.
# On the same machine, keys are read from device_identity.json automatically.
# public_key: "D955E72C..."
# private_key_file: "~/.meshcore-observer-key"
brokers:
- name: "letsmesh-eu"
server: "mqtt-eu-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: true
upload_packet_types: [] # [] = all types
status_interval_s: 300
reconnect_delay_s: 10
```
### 8.5. Test and Go Live
**Step 1 — Dry run** (log payloads without publishing):
```bash
python meshcore_observer.py --mqtt-dry-run --debug-on
```
Check the output for:
- `"Loaded device identity from ..."` — keys found
- `"Using Node.js meshcore-decoder for MQTT auth tokens"` — signing works
- `"[DRY RUN] Would connect to ..."` — broker config OK
**Step 2 — Live:**
```bash
python meshcore_observer.py
```
The dashboard MQTT panel shows connection status, packet counters, and any errors. Within minutes your packets should appear on [analyzer.letsmesh.net](https://analyzer.letsmesh.net).
### 8.6. Privacy Controls
Control which packet types are shared:
```yaml
# Only advertisements (network discovery, no message content)
upload_packet_types: [4]
# Advertisements and group text metadata
upload_packet_types: [4, 5]
# Everything (default)
upload_packet_types: []
```
Packet types: 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT, 5=GRP_TXT, 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE.
The raw packet payload (hex bytes) is always included for shared types. If you do not want to share message content, use `[4]` (ADVERT only).
### 8.7. Multiple Brokers
Publish to EU and US brokers simultaneously for redundancy:
```yaml
brokers:
- name: "letsmesh-eu"
server: "mqtt-eu-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: true
- name: "letsmesh-us"
server: "mqtt-us-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: true
```
### 8.8. How Authentication Works
LetsMesh uses Ed25519 JWT tokens — no registration required. Your device key pair *is* your identity:
1. meshcore_gui exports the device's Ed25519 keypair to `device_identity.json`
2. The Observer generates a JWT signed with the 128-char orlp/ed25519 private key via Node.js meshcore-decoder
3. MQTT username: `v1_{PUBLIC_KEY}` (uppercase, 64 hex chars)
4. MQTT password: the signed JWT token
5. The broker verifies the signature against the public key from the username
6. Topics are scoped to `meshcore/{IATA}/{PUBLIC_KEY}/`
7. Tokens auto-refresh before expiry (default 1 hour)
**Key format:** The private key in `device_identity.json` is 128 hex chars (64 bytes) in orlp/ed25519 expanded format: `[clamped_scalar(32)][nonce_prefix(32)]`. This is **not** a seed+pubkey concatenation — it is the output of `SHA-512(seed)` with clamping. The public key comes from `send_appstart()` and is stored separately.
### 8.9. MQTT Topics
| Topic | Content | QoS | Retained |
|---|---|---|---|
| `meshcore/{IATA}/{KEY}/packets` | RX log entries (JSON) | 0 | No |
| `meshcore/{IATA}/{KEY}/status` | Online/offline status | 1 | Yes |
The status topic uses MQTT Last Will and Testament (LWT): if the Observer disconnects unexpectedly, the broker automatically publishes an offline status.
---
## 9. systemd Installation
For running the Observer as a background service on Linux.
**Install:**
```bash
cd ~/meshcore-gui
source venv/bin/activate
bash install_observer.sh
```
The installer detects the venv and current user automatically, creates a systemd service, and offers to start it immediately.
**If you need a custom NODE_PATH** (see [section 4.2](#42-mqtt-uplink-dependencies)), edit the service file after installation:
```bash
sudo systemctl edit meshcore-observer
```
Add:
```ini
[Service]
Environment="NODE_PATH=/usr/local/lib/node_modules"
```
Then reload:
```bash
sudo systemctl daemon-reload
sudo systemctl restart meshcore-observer
```
**Service commands:**
| Command | Description |
|---------|-------------|
| `sudo systemctl start meshcore-observer` | Start the service |
| `sudo systemctl stop meshcore-observer` | Stop the service |
| `sudo systemctl restart meshcore-observer` | Restart after config change |
| `sudo systemctl status meshcore-observer` | Check status |
| `sudo journalctl -u meshcore-observer -f` | Follow live logs |
**Uninstall:**
```bash
cd ~/meshcore-gui
bash install_observer.sh --uninstall
```
---
## 10. How It Works
The Observer uses a polling-based file watcher (`ArchiveWatcher`) that:
1. Scans the archive directory for `*_messages.json` and `*_rxlog.json` files
2. Checks each file's `mtime` (modification timestamp)
3. If unchanged since last poll → skip (no disk I/O)
4. If changed → read, parse, extract only new entries (delta detection)
5. Feeds new entries to the dashboard panels (and optionally to MQTT uplink)
This is efficient and safe:
- **No file locking conflicts** — meshcore_gui uses atomic writes (temp file + rename)
- **No race conditions** — Observer only reads completed files
- **No crash on corruption** — Malformed JSON is logged and skipped
- **No crash on missing files** — Vanished files are removed from tracking
---
## 11. Dashboard Panels
### Sources
Table of all detected archive files with source name, message count, and RX log count.
### Messages
Aggregated message feed from all sources. Newest messages on top. Filterable by source and channel.
### RX Log
Aggregated packet log from all sources. Columns: Time, Source, SNR, RSSI, Type, Hops, Path, Hash.
### Statistics
Observer uptime, total messages and RX log entries seen, number of active sources, per-source breakdown.
### MQTT Uplink
Connection status per broker (green/red dot), total packets published, filtered count, skipped count, last publish timestamp, and any errors.
---
## 12. Running Alongside Other Daemons
The Observer is designed to coexist with meshcore_gui and meshcore_bridge:
```
┌──────────────────┐ ┌──────────────────┐
│ meshcore_gui │ │ meshcore_bridge │
│ :8081 │ │ :9092 │
│ writes archive │ │ writes archive │
└────────┬─────────┘ └────────┬──────────┘
│ │
▼ ▼
~/.meshcore-gui/archive/
┌──────────────────┐
│ meshcore_observer │
│ :9093 │──────► mqtt-eu-v1.letsmesh.net
│ reads archive │ (optional MQTT uplink)
└──────────────────┘
```
All three can run simultaneously. The Observer only reads atomically-written files and never interferes with the other daemons.
---
## 13. Troubleshooting
### 13.1. Dashboard
**"Waiting for archive files..."**
- Verify meshcore_gui or meshcore_bridge is running and has received at least one message
- Check: `ls ~/.meshcore-gui/archive/`
- If using a custom path, verify `archive_dir` in your config
**No messages despite archive files existing**
- Check file permissions
- Run with `--debug-on`
- Verify archive files have `"version": 1` in their JSON
**Port conflict**
- Change with `--port=9094` or in `observer_config.yaml`
### 13.2. MQTT
**"Not authorized" (rc=5)**
This is almost always a key format issue. Check in order:
1. **Is the private key 128 chars?**
```bash
python -c "
import json
d = json.load(open('$HOME/.meshcore-gui/device_identity.json'))
print(f\"private_key length: {len(d.get('private_key',''))}\")"
```
If 64: you need the fixed `device_identity.py` in meshcore_gui. Deploy it and restart meshcore_gui.
2. **Does the public key match the GUI?**
The `public_key` in `device_identity.json` must match what meshcore_gui shows under device info (uppercase hex). If it doesn't, the identity file was written by a buggy version — deploy the fix and restart.
3. **Is meshcore-decoder available?**
```bash
node -e "require('@michaelhart/meshcore-decoder').createAuthToken; console.log('OK')"
```
If this fails, see [section 4.2](#42-mqtt-uplink-dependencies).
4. **Run with debug:**
```bash
python meshcore_observer.py --mqtt-dry-run --debug-on
```
Look for `"Node.js token generation failed"` or `"PyNaCl"` fallback messages.
**"Connecting..." but never connects**
- Check firewall allows outbound connections to port 443
- Try the US broker: change `server` to `mqtt-us-v1.letsmesh.net`
- Check DNS resolution: `nslookup mqtt-eu-v1.letsmesh.net`
**Packets not appearing on analyzer.letsmesh.net**
- Use `--mqtt-dry-run` to verify payload format
- Check `upload_packet_types` is not filtering everything
- Verify archive files contain `raw_payload` data
- The analyzer may take a few minutes to index new nodes
**"PyNaCl fallback requires a 64-char Ed25519 seed"**
- Node.js meshcore-decoder is not available, and the private key is 128 chars
- Solution: install meshcore-decoder (see [section 4.2](#42-mqtt-uplink-dependencies))
---
## 14. Version History
| Version | Date | Description |
|---|---|---|
| 1.2.0 | 2026-02-26 | Fix: 128-char orlp/ed25519 private key support, NODE_PATH auto-detection, improved key validation |
| 1.1.0 | 2026-02-26 | Fase 2: MQTT uplink to LetsMesh (WebSocket+TLS, Ed25519 JWT, privacy filter) |
| 1.0.0 | 2026-02-26 | Fase 1: Read-only archive monitor dashboard |
---
## 15. License
MIT License — see LICENSE file
## 16. Author
**PE1HVH** — [GitHub](https://github.com/pe1hvh)

View File

@@ -449,13 +449,40 @@ This creates `venv/` and installs all core dependencies (`nicegui`, `meshcore`,
Use the appropriate installer for your transport:
```bash
# Serial connection
# Serial connection — single device (will prompt for serial port)
bash install_scripts/install_serial.sh
# BLE connection
bash install_scripts/install_ble_stable.sh
```
**Serial — single device with explicit port:**
```bash
SERIAL_PORT=/dev/ttyUSB0 WEB_PORT=8081 bash install_scripts/install_serial.sh
```
**Serial — multiple devices on the same machine:**
Each device gets its own systemd service, named after its serial port (e.g. `meshcore-gui-ttyUSB1`). Assign a unique web port per instance:
```bash
SERIAL_PORT=/dev/ttyUSB1 WEB_PORT=8081 bash install_scripts/install_serial.sh
SERIAL_PORT=/dev/ttyUSB2 WEB_PORT=8082 bash install_scripts/install_serial.sh
```
**List all installed serial instances:**
```bash
bash install_scripts/install_serial.sh --list
```
**Uninstall a specific serial instance:**
```bash
SERIAL_PORT=/dev/ttyUSB1 bash install_scripts/install_serial.sh --uninstall
```
**Serial environment variables** (optional):
```bash
@@ -572,7 +599,9 @@ For example: `http://raspberrypi5nas:8081` or `http://192.168.2.234:8081`. This
### 7.7. Running Multiple Instances
You can run multiple instances simultaneously (e.g. for different MeshCore devices) by assigning each a different port:
You can run multiple instances simultaneously (e.g. for different MeshCore devices) by assigning each a different port.
#### Foreground / background (manual)
```bash
# Two serial devices
@@ -584,6 +613,39 @@ python meshcore_gui.py /dev/ttyACM0 --port=8081 &
python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF --port=8082 &
```
#### systemd — multiple services (recommended for production)
`install_serial.sh` derives the service name from the serial port, so each device gets its own independent service that starts on boot and restarts on failure:
```bash
SERIAL_PORT=/dev/ttyUSB1 WEB_PORT=8081 bash install_scripts/install_serial.sh
SERIAL_PORT=/dev/ttyUSB2 WEB_PORT=8082 bash install_scripts/install_serial.sh
```
This creates two services: `meshcore-gui-ttyUSB1` and `meshcore-gui-ttyUSB2`.
**Manage individual instances:**
```bash
sudo systemctl start meshcore-gui-ttyUSB1
sudo systemctl stop meshcore-gui-ttyUSB2
sudo systemctl restart meshcore-gui-ttyUSB1
sudo systemctl status meshcore-gui-ttyUSB2
journalctl -u meshcore-gui-ttyUSB1 -f
```
**List all installed instances:**
```bash
bash install_scripts/install_serial.sh --list
```
**Uninstall one instance:**
```bash
SERIAL_PORT=/dev/ttyUSB1 bash install_scripts/install_serial.sh --uninstall
```
Each instance gets its own log file, cache and archive, all keyed by the device identifier (serial port or BLE address).
### 7.8. Migrating Existing Data

395
config.py
View File

@@ -1,395 +0,0 @@
"""
Application configuration for MeshCore GUI.
Contains only global runtime settings.
Bot configuration lives in :mod:`meshcore_gui.services.bot`.
UI display constants live in :mod:`meshcore_gui.gui.constants`.
The ``DEBUG`` flag defaults to False and can be activated at startup
with the ``--debug-on`` command-line option.
Debug output is written to both stdout and a rotating log file at
``~/.meshcore-gui/logs/meshcore_gui.log``.
"""
import json
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, Dict, List
# ==============================================================================
# VERSION
# ==============================================================================
VERSION: str = "1.14.1"
# ==============================================================================
# OPERATOR / LANDING PAGE
# ==============================================================================
# Operator callsign shown on the landing page SVG and drawer footer.
# Change this to your own callsign (e.g. "PE1HVH", "PE1HVH/MIT").
OPERATOR_CALLSIGN: str = "PE1HVH"
# Path to the landing page SVG file.
# The placeholder ``{callsign}`` inside the SVG is replaced at runtime
# with ``OPERATOR_CALLSIGN``.
#
# Default: the bundled DOMCA splash (static/landing_default.svg).
# To use a custom SVG, point this to your own file, e.g.:
# LANDING_SVG_PATH = DATA_DIR / "landing.svg"
LANDING_SVG_PATH: Path = Path(__file__).parent / "static" / "landing_default.svg"
# ==============================================================================
# MAP DEFAULTS
# ==============================================================================
# Default map centre used as the initial view *before* the device reports
# its own GPS position. Once the device advertises a valid adv_lat/adv_lon
# pair, every map will re-centre on the device's actual location.
#
# Change these values to match the location of your device / station.
# Current default: Zwolle, The Netherlands (52.5168, 6.0830).
DEFAULT_MAP_CENTER: tuple[float, float] = (52.5168, 6.0830)
# Default zoom level for all Leaflet maps (higher = more zoomed in).
DEFAULT_MAP_ZOOM: int = 9
# ==============================================================================
# DIRECTORY STRUCTURE
# ==============================================================================
# Base data directory — all persistent data lives under this root.
# Existing services (cache, pins, archive) each define their own
# sub-directory; this constant centralises the root for new consumers.
DATA_DIR: Path = Path.home() / ".meshcore-gui"
# Log directory for debug and error log files.
LOG_DIR: Path = DATA_DIR / "logs"
# Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total).
LOG_FILE: Path = LOG_DIR / "meshcore_gui.log"
def set_log_file_for_device(device_id: str) -> None:
"""Set the log file name based on the device identifier.
Transforms ``F0:9E:9E:75:A3:01`` into
``~/.meshcore-gui/logs/F0_9E_9E_75_A3_01_meshcore_gui.log`` and
``/dev/ttyUSB0`` into ``~/.meshcore-gui/logs/_dev_ttyUSB0_meshcore_gui.log``.
Must be called **before** the first ``debug_print()`` call so the
lazy logger initialisation picks up the correct path.
"""
global LOG_FILE
safe_name = (
device_id
.replace("literal:", "")
.replace(":", "_")
.replace("/", "_")
)
LOG_FILE = LOG_DIR / f"{safe_name}_meshcore_gui.log"
# Maximum size per log file in bytes (5 MB).
LOG_MAX_BYTES: int = 5 * 1024 * 1024
# Number of rotated backup files to keep.
LOG_BACKUP_COUNT: int = 3
# ==============================================================================
# DEBUG
# ==============================================================================
DEBUG: bool = False
# Internal file logger — initialised lazily on first debug_print() call.
_file_logger: logging.Logger | None = None
def _init_file_logger() -> logging.Logger:
"""Create and configure the rotating file logger (called once)."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("meshcore_gui.debug")
logger.setLevel(logging.DEBUG)
logger.propagate = False
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
)
logger.addHandler(handler)
return logger
def _caller_module() -> str:
"""Return a short module label for the calling code.
Walks two frames up (debug_print -> caller) and extracts the
module ``__name__``. The common ``meshcore_gui.`` prefix is
stripped for brevity, e.g. ``ble.worker`` instead of
``meshcore_gui.ble.worker``.
"""
frame = sys._getframe(2) # 0=_caller_module, 1=debug_print, 2=actual caller
module = frame.f_globals.get("__name__", "<unknown>")
if module.startswith("meshcore_gui."):
module = module[len("meshcore_gui."):]
return module
def _init_meshcore_logger() -> None:
"""Route meshcore library debug output to our rotating log file.
The meshcore library uses ``logging.getLogger("meshcore")`` throughout,
but never attaches a handler. Without this function all library-level
debug output (raw send/receive, event dispatching, command flow)
is silently dropped because Python's root logger only forwards
WARNING and above.
Call once at startup (or lazily from ``debug_print``) so that
``MESHCORE_LIB_DEBUG=True`` actually produces visible output.
"""
LOG_DIR.mkdir(parents=True, exist_ok=True)
mc_logger = logging.getLogger("meshcore")
# Guard against duplicate handlers on repeated calls
if any(isinstance(h, RotatingFileHandler) for h in mc_logger.handlers):
return
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s LIB [%(name)s]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
mc_logger.addHandler(handler)
# Also add a stdout handler so library output appears in the console
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(
logging.Formatter(
"%(asctime)s LIB [%(name)s]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
mc_logger.addHandler(stdout_handler)
def debug_print(msg: str) -> None:
"""Print a debug message when ``DEBUG`` is enabled.
Output goes to both stdout and the rotating log file.
The calling module name is automatically included so that
exception context is immediately clear, e.g.::
DEBUG [ble.worker]: send_appstart attempt 3 exception: TimeoutError
"""
global _file_logger
if not DEBUG:
return
module = _caller_module()
formatted = f"DEBUG [{module}]: {msg}"
# stdout (existing behaviour, now with module tag)
print(formatted)
# Rotating log file
if _file_logger is None:
_file_logger = _init_file_logger()
# Also wire up the meshcore library logger so MESHCORE_LIB_DEBUG
# output actually appears in the same log file + stdout.
_init_meshcore_logger()
_file_logger.debug(formatted)
def pp(obj: Any, indent: int = 2) -> str:
"""Pretty-format a dict, list, or other object for debug output.
Use inside f-strings::
debug_print(f"payload={pp(r.payload)}")
Dicts/lists get indented JSON; everything else falls back to repr().
"""
if isinstance(obj, (dict, list)):
try:
return json.dumps(obj, indent=indent, default=str, ensure_ascii=False)
except (TypeError, ValueError):
return repr(obj)
return repr(obj)
def debug_data(label: str, obj: Any) -> None:
"""Print a labelled data structure with pretty indentation.
Combines a header line with pretty-printed data below it::
debug_data("get_contacts result", r.payload)
Output::
DEBUG [worker]: get_contacts result ↓
{
"name": "PE1HVH",
"contacts": 629,
...
}
"""
if not DEBUG:
return
formatted = pp(obj)
# Single-line values stay on the same line
if '\n' not in formatted:
debug_print(f"{label}: {formatted}")
else:
# Multi-line: indent each line for readability
indented = '\n'.join(f" {line}" for line in formatted.splitlines())
debug_print(f"{label}\n{indented}")
# ==============================================================================
# CHANNELS
# ==============================================================================
# Maximum number of channel slots to probe on the device.
# MeshCore supports up to 8 channels (indices 0-7).
MAX_CHANNELS: int = 8
# Enable or disable caching of the channel list to disk.
# When False (default), channels are always fetched fresh from the
# device at startup, guaranteeing the GUI always reflects the actual
# device configuration. When True, channels are loaded from cache
# for instant GUI population and then refreshed from the device.
# Note: channel *keys* (for packet decryption) are always cached
# regardless of this setting.
CHANNEL_CACHE_ENABLED: bool = False
# ==============================================================================
# BOT DEVICE NAME
# ==============================================================================
# Fixed device name applied when the BOT checkbox is enabled.
# The original device name is saved and restored when BOT is disabled.
BOT_DEVICE_NAME: str = "ZwolsBotje"
# Default device name used as fallback when restoring from BOT mode
# and no original name was saved (e.g. after a restart).
DEVICE_NAME: str = "PE1HVH T1000e"
# ==============================================================================
# CACHE / REFRESH
# ==============================================================================
# Default timeout (seconds) for meshcore command responses.
# Increase if you see frequent 'no_event_received' errors during startup.
DEFAULT_TIMEOUT: float = 10.0
# Enable debug logging inside the meshcore library itself.
# When True, raw send/receive data and event parsing are logged.
MESHCORE_LIB_DEBUG: bool = True
# ==============================================================================
# TRANSPORT MODE (auto-detected from CLI argument)
# ==============================================================================
# "serial" or "ble" — set at startup by main() based on the device argument.
TRANSPORT: str = "serial"
def is_ble_address(device_id: str) -> bool:
"""Detect whether *device_id* looks like a BLE MAC address.
Heuristic:
- Starts with ``literal:`` → BLE
- Matches ``XX:XX:XX:XX:XX:XX`` (6 colon-separated hex pairs) → BLE
- Everything else (``/dev/…``, ``COM…``) → Serial
"""
if device_id.lower().startswith("literal:"):
return True
parts = device_id.split(":")
if len(parts) == 6 and all(len(p) == 2 for p in parts):
try:
for p in parts:
int(p, 16)
return True
except ValueError:
pass
return False
TRANSPORT: str = "serial"
# Serial connection defaults.
SERIAL_BAUDRATE: int = 115200
SERIAL_CX_DELAY: float = 0.1
# BLE connection defaults.
# BLE pairing PIN for the MeshCore device (T1000e default: 123456).
# Used by the built-in D-Bus agent to answer pairing requests
# automatically — eliminates the need for bt-agent.service.
BLE_PIN: str = "123456"
# Maximum number of reconnect attempts after a disconnect.
RECONNECT_MAX_RETRIES: int = 5
# Base delay in seconds between reconnect attempts (multiplied by
# attempt number for linear backoff: 5s, 10s, 15s, 20s, 25s).
RECONNECT_BASE_DELAY: float = 5.0
# Interval in seconds between periodic contact refreshes from the device.
# Contacts are merged (new/changed contacts update the cache; contacts
# only present in cache are kept so offline nodes are preserved).
CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes
# ==============================================================================
# EXTERNAL LINKS (drawer menu)
# ==============================================================================
EXT_LINKS = [
('MeshCore', 'https://meshcore.co.uk'),
('Handleiding', 'https://www.pe1hvh.nl/pdf/MeshCore_Complete_Handleiding.pdf'),
('Netwerk kaart', 'https://meshcore.co.uk/map'),
('LocalMesh NL', 'https://www.localmesh.nl/'),
]
# ==============================================================================
# ARCHIVE / RETENTION
# ==============================================================================
# Retention period for archived messages (in days).
# Messages older than this are automatically removed during cleanup.
MESSAGE_RETENTION_DAYS: int = 30
# Retention period for RX log entries (in days).
# RX log entries older than this are automatically removed during cleanup.
RXLOG_RETENTION_DAYS: int = 7
# Retention period for contacts (in days).
# Contacts not seen for longer than this are removed from cache.
CONTACT_RETENTION_DAYS: int = 90
# BBS channel configuration is managed at runtime via BbsConfigStore.
# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json
# and edited through the BBS Settings panel in the GUI.

View File

@@ -1,238 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# MeshCore Bridge — systemd Service Installer
# =============================================================================
#
# Installs meshcore_bridge as a systemd daemon service.
#
# Usage:
# sudo bash install_scripts/install_bridge.sh # from project root
# cd install_scripts && sudo bash install_bridge.sh # from install_scripts/
# sudo bash install_scripts/install_bridge.sh --uninstall
#
# What this script does:
# 1. Copies meshcore_bridge.py and meshcore_bridge/ to /opt/meshcore-bridge/
# 2. Copies bridge_config.yaml to /etc/meshcore/ (if not already present)
# 3. Creates a systemd service unit file
# 4. Reloads systemd and enables the service
#
# After installation:
# sudo nano /etc/meshcore/bridge_config.yaml # edit configuration
# sudo systemctl start meshcore-bridge # start the service
# sudo systemctl enable meshcore-bridge # auto-start on boot
# sudo systemctl status meshcore-bridge # check status
# journalctl -u meshcore-bridge -f # follow logs
#
# Author: PE1HVH
# SPDX-License-Identifier: MIT
# Copyright: (c) 2026 PE1HVH
# =============================================================================
set -euo pipefail
SERVICE_NAME="meshcore-bridge"
INSTALL_DIR="/opt/meshcore-bridge"
CONFIG_DIR="/etc/meshcore"
CONFIG_FILE="${CONFIG_DIR}/bridge_config.yaml"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# ── Resolve project root ──
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "$(basename "${SCRIPT_DIR}")" == "install_scripts" ]]; then
PROJECT_DIR="$(dirname "${SCRIPT_DIR}")"
else
PROJECT_DIR="${SCRIPT_DIR}"
fi
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
# ── Check root ──
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (sudo)."
exit 1
fi
# ── Uninstall mode ──
if [[ "${1:-}" == "--uninstall" ]]; then
info "Uninstalling ${SERVICE_NAME}..."
if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
info "Stopping service..."
systemctl stop "${SERVICE_NAME}"
fi
if systemctl is-enabled --quiet "${SERVICE_NAME}" 2>/dev/null; then
info "Disabling service..."
systemctl disable "${SERVICE_NAME}"
fi
if [[ -f "${SERVICE_FILE}" ]]; then
info "Removing service file..."
rm -f "${SERVICE_FILE}"
systemctl daemon-reload
fi
if [[ -d "${INSTALL_DIR}" ]]; then
info "Removing installation directory..."
rm -rf "${INSTALL_DIR}"
fi
warn "Configuration preserved at ${CONFIG_DIR}/"
info "Uninstall complete."
exit 0
fi
# ── Install mode ──
info "Installing ${SERVICE_NAME}..."
# Verify source files exist
if [[ ! -f "${PROJECT_DIR}/meshcore_bridge.py" ]]; then
error "meshcore_bridge.py not found in ${PROJECT_DIR}"
error "Run this script from the project directory or from install_scripts/."
exit 1
fi
if [[ ! -d "${PROJECT_DIR}/meshcore_bridge" ]]; then
error "meshcore_bridge/ directory not found in ${PROJECT_DIR}"
exit 1
fi
# Detect Python interpreter
PYTHON_BIN=""
for candidate in python3.12 python3.11 python3.10 python3; do
if command -v "${candidate}" &>/dev/null; then
PYTHON_BIN="$(command -v "${candidate}")"
break
fi
done
if [[ -z "${PYTHON_BIN}" ]]; then
error "Python 3.10+ not found. Install Python first."
exit 1
fi
PYTHON_VERSION=$("${PYTHON_BIN}" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
info "Using Python ${PYTHON_VERSION} at ${PYTHON_BIN}"
# Check dependencies
info "Checking dependencies..."
"${PYTHON_BIN}" -c "import yaml" 2>/dev/null || {
error "pyyaml not installed. Run: pip install pyyaml"
exit 1
}
"${PYTHON_BIN}" -c "from meshcore_gui.core.shared_data import SharedData" 2>/dev/null || {
error "meshcore_gui not importable. Ensure it is installed or on PYTHONPATH."
exit 1
}
info "All dependencies satisfied."
# Create install directory
info "Copying files to ${INSTALL_DIR}/..."
mkdir -p "${INSTALL_DIR}"
cp "${PROJECT_DIR}/meshcore_bridge.py" "${INSTALL_DIR}/"
cp -r "${PROJECT_DIR}/meshcore_bridge" "${INSTALL_DIR}/"
chmod +x "${INSTALL_DIR}/meshcore_bridge.py"
# Copy config (preserve existing)
mkdir -p "${CONFIG_DIR}"
if [[ -f "${CONFIG_FILE}" ]]; then
warn "Config already exists at ${CONFIG_FILE} — not overwriting."
warn "New template saved as ${CONFIG_FILE}.new"
cp "${PROJECT_DIR}/bridge_config.yaml" "${CONFIG_FILE}.new"
else
info "Installing config template at ${CONFIG_FILE}"
cp "${PROJECT_DIR}/bridge_config.yaml" "${CONFIG_FILE}"
fi
# Detect meshcore_gui location for PYTHONPATH
MESHCORE_GUI_DIR=""
MESHCORE_GUI_PARENT=$("${PYTHON_BIN}" -c "
import meshcore_gui, os
print(os.path.dirname(os.path.dirname(meshcore_gui.__file__)))
" 2>/dev/null || true)
if [[ -n "${MESHCORE_GUI_PARENT}" ]]; then
MESHCORE_GUI_DIR="${MESHCORE_GUI_PARENT}"
info "meshcore_gui found at ${MESHCORE_GUI_DIR}"
fi
# Build PYTHONPATH
PYTHONPATH_VALUE="${INSTALL_DIR}"
if [[ -n "${MESHCORE_GUI_DIR}" ]]; then
PYTHONPATH_VALUE="${INSTALL_DIR}:${MESHCORE_GUI_DIR}"
fi
# Create systemd service file
info "Creating systemd service..."
cat > "${SERVICE_FILE}" << EOF
[Unit]
Description=MeshCore Bridge — Cross-Frequency Message Bridge Daemon
Documentation=file://${INSTALL_DIR}/BRIDGE.md
After=network.target
Wants=network.target
[Service]
Type=simple
ExecStart=${PYTHON_BIN} ${INSTALL_DIR}/meshcore_bridge.py --config=${CONFIG_FILE}
WorkingDirectory=${INSTALL_DIR}
Environment="PYTHONPATH=${PYTHONPATH_VALUE}"
# Restart policy
Restart=on-failure
RestartSec=10
StartLimitIntervalSec=300
StartLimitBurst=5
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home /var/log
PrivateTmp=true
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=multi-user.target
EOF
# Reload systemd
info "Reloading systemd daemon..."
systemctl daemon-reload
# Copy documentation
if [[ -f "${PROJECT_DIR}/BRIDGE.md" ]]; then
cp "${PROJECT_DIR}/BRIDGE.md" "${INSTALL_DIR}/"
fi
# ── Summary ──
echo
info "============================================="
info " Installation complete!"
info "============================================="
echo
info "Files installed:"
info " Application: ${INSTALL_DIR}/"
info " Config: ${CONFIG_FILE}"
info " Service: ${SERVICE_FILE}"
echo
info "Next steps:"
info " 1. Edit configuration: sudo nano ${CONFIG_FILE}"
info " 2. Start the service: sudo systemctl start ${SERVICE_NAME}"
info " 3. Enable auto-start: sudo systemctl enable ${SERVICE_NAME}"
info " 4. Check status: sudo systemctl status ${SERVICE_NAME}"
info " 5. Follow logs: journalctl -u ${SERVICE_NAME} -f"
info " 6. Open dashboard: http://localhost:9092"
echo
info "To uninstall: sudo bash install_scripts/install_bridge.sh --uninstall"

View File

@@ -1,233 +0,0 @@
#!/usr/bin/env bash
# ============================================================================
# MeshCore Observer — systemd Service Installer
# ============================================================================
#
# Installs a systemd service for the MeshCore Observer daemon.
# Automatically detects the venv and current user.
#
# Usage:
# bash install_scripts/install_observer.sh # from project root
# cd install_scripts && bash install_observer.sh # from install_scripts/
#
# Optional:
# bash install_scripts/install_observer.sh --uninstall
#
# Requirements:
# - meshcore-gui project with venv/ directory
# - nicegui and pyyaml installed in the venv
# - sudo access (for systemd)
#
# Author: PE1HVH
# SPDX-License-Identifier: MIT
# Copyright: (c) 2026 PE1HVH
# ============================================================================
set -euo pipefail
# ── Colors ──
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
SERVICE_NAME="meshcore-observer"
# ── Resolve project root ──
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "$(basename "${SCRIPT_DIR}")" == "install_scripts" ]]; then
PROJECT_DIR="$(dirname "${SCRIPT_DIR}")"
else
PROJECT_DIR="${SCRIPT_DIR}"
fi
# ── Uninstall mode ──
if [[ "${1:-}" == "--uninstall" ]]; then
info "Removing ${SERVICE_NAME} service..."
sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
sudo systemctl disable "${SERVICE_NAME}" 2>/dev/null || true
sudo rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
sudo systemctl daemon-reload
sudo systemctl reset-failed 2>/dev/null || true
ok "Service removed"
exit 0
fi
# ── Detect environment ──
info "Detecting environment..."
if [[ ! -f "${PROJECT_DIR}/meshcore_observer.py" ]] && [[ ! -d "${PROJECT_DIR}/meshcore_observer" ]]; then
error "Cannot find meshcore_observer.py or meshcore_observer/ in ${PROJECT_DIR}
Run this script from the project directory or from install_scripts/."
fi
CURRENT_USER="$(whoami)"
VENV_PYTHON="${PROJECT_DIR}/venv/bin/python"
# Check venv
if [[ ! -x "${VENV_PYTHON}" ]]; then
# Try parent directory venv (observer may be in meshcore-gui project)
PARENT_VENV="$(dirname "${PROJECT_DIR}")/venv/bin/python"
if [[ -x "${PARENT_VENV}" ]]; then
VENV_PYTHON="${PARENT_VENV}"
warn "Using parent directory venv: ${VENV_PYTHON}"
else
error "Virtual environment not found at: ${VENV_PYTHON}
Create it first:
python3 -m venv venv
source venv/bin/activate
pip install nicegui pyyaml"
fi
fi
# ── Check dependencies ──
info "Checking dependencies..."
"${VENV_PYTHON}" -c "import nicegui" 2>/dev/null || {
error "nicegui not installed in venv. Run:
source venv/bin/activate
pip install nicegui"
}
"${VENV_PYTHON}" -c "import yaml" 2>/dev/null || {
error "pyyaml not installed in venv. Run:
source venv/bin/activate
pip install pyyaml"
}
ok "All dependencies satisfied"
# ── Detect NODE_PATH for meshcore-decoder (MQTT auth) ──
NODE_PATH_VALUE=""
if command -v node &>/dev/null; then
NPM_GLOBAL="$(npm root -g 2>/dev/null || true)"
if [[ -n "${NPM_GLOBAL}" ]] && [[ -d "${NPM_GLOBAL}" ]]; then
NODE_PATH_VALUE="${NPM_GLOBAL}"
info "Node.js global modules: ${NPM_GLOBAL}"
fi
fi
# ── Optional settings ──
WEB_PORT="${WEB_PORT:-9093}"
ARCHIVE_DIR="${ARCHIVE_DIR:-~/.meshcore-gui/archive}"
DEBUG_ON="${DEBUG_ON:-}"
if [[ -z "${DEBUG_ON}" ]]; then
read -rp "Enable debug logging? [y/N] " dbg
if [[ "${dbg}" == "y" || "${dbg}" == "Y" ]]; then
DEBUG_ON="yes"
else
DEBUG_ON="no"
fi
fi
DEBUG_FLAG=""
if [[ "${DEBUG_ON}" == "yes" ]]; then
DEBUG_FLAG="--debug-on"
fi
# ── Config file ──
CONFIG_FLAG=""
CONFIG_FILE="${PROJECT_DIR}/observer_config.yaml"
if [[ -f "${CONFIG_FILE}" ]]; then
CONFIG_FLAG="--config=${CONFIG_FILE}"
info "Using config: ${CONFIG_FILE}"
else
info "No observer_config.yaml found — using defaults"
fi
# ── Summary ──
echo ""
echo "═══════════════════════════════════════════════════"
echo " MeshCore Observer — Service Installer"
echo "═══════════════════════════════════════════════════"
echo " Project dir: ${PROJECT_DIR}"
echo " User: ${CURRENT_USER}"
echo " Python: ${VENV_PYTHON}"
echo " Archive dir: ${ARCHIVE_DIR}"
echo " Web port: ${WEB_PORT}"
echo " Config: ${CONFIG_FILE}"
echo " Debug: ${DEBUG_ON}"
if [[ -n "${NODE_PATH_VALUE}" ]]; then
echo " NODE_PATH: ${NODE_PATH_VALUE}"
fi
echo "═══════════════════════════════════════════════════"
echo ""
read -rp "Continue? [y/N] " confirm
if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then
info "Aborted."
exit 0
fi
# ── Install systemd service ──
info "Installing systemd service..."
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# Build optional Environment line for NODE_PATH
ENV_LINE=""
if [[ -n "${NODE_PATH_VALUE}" ]]; then
ENV_LINE="Environment=\"NODE_PATH=${NODE_PATH_VALUE}\""
fi
sudo tee "${SERVICE_FILE}" > /dev/null << SERVICE_EOF
[Unit]
Description=MeshCore Observer — Read-Only Archive Monitor Dashboard
[Service]
Type=simple
User=${CURRENT_USER}
WorkingDirectory=${PROJECT_DIR}
ExecStart=${VENV_PYTHON} meshcore_observer.py ${CONFIG_FLAG} --port=${WEB_PORT} ${DEBUG_FLAG}
Restart=on-failure
RestartSec=30
${ENV_LINE}
[Install]
WantedBy=multi-user.target
SERVICE_EOF
sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}"
ok "${SERVICE_NAME}.service installed and enabled"
# ── Done ──
echo ""
echo "═══════════════════════════════════════════════════"
echo -e " ${GREEN}Installation complete!${NC}"
echo "═══════════════════════════════════════════════════"
echo ""
echo " Commands:"
echo " sudo systemctl start ${SERVICE_NAME} # Start"
echo " sudo systemctl stop ${SERVICE_NAME} # Stop"
echo " sudo systemctl restart ${SERVICE_NAME} # Restart"
echo " sudo systemctl status ${SERVICE_NAME} # Status"
echo " journalctl -u ${SERVICE_NAME} -f # Live logs"
echo ""
echo " Dashboard: http://localhost:${WEB_PORT}"
echo ""
echo " Uninstall:"
echo " bash install_scripts/install_observer.sh --uninstall"
echo ""
echo "═══════════════════════════════════════════════════"
# Optionally start immediately
echo ""
read -rp "Start service now? [y/N] " start_now
if [[ "${start_now}" == "y" || "${start_now}" == "Y" ]]; then
sudo systemctl start "${SERVICE_NAME}"
sleep 2
if systemctl is-active --quiet "${SERVICE_NAME}"; then
ok "Service is running!"
echo ""
info "View live logs: journalctl -u ${SERVICE_NAME} -f"
else
warn "Service could not start. Check logs:"
echo " journalctl -u ${SERVICE_NAME} --no-pager -n 20"
fi
fi

View File

@@ -1,17 +1,32 @@
#!/usr/bin/env bash
# ============================================================================
# MeshCore GUI — Serial Installer
# MeshCore GUI — Serial Installer (multi-instance)
# ============================================================================
#
# Installs a systemd service for the serial-based MeshCore GUI.
# Automatically detects paths and the current user.
# The service name is derived from the serial port, so multiple instances
# can coexist on the same machine.
#
# Usage:
# bash install_scripts/install_serial.sh # from project root
# cd install_scripts && bash install_serial.sh # from install_scripts/
#
# Optional:
# bash install_scripts/install_serial.sh --uninstall
# Optional env vars:
# SERIAL_PORT=/dev/ttyUSB0 Serial device (will prompt if omitted)
# WEB_PORT=8081 NiceGUI web port (default: 8081)
# BAUD=115200 Baud rate (default: 115200)
# SERIAL_CX_DLY=0.1 Serial connect delay (default: 0.1)
# DEBUG_ON=yes|no Enable debug logging (will prompt if omitted)
#
# Examples — two instances on the same machine:
# SERIAL_PORT=/dev/ttyUSB0 WEB_PORT=8081 bash install_scripts/install_serial.sh
# SERIAL_PORT=/dev/ttyUSB1 WEB_PORT=8082 bash install_scripts/install_serial.sh
#
# Uninstall a specific instance:
# SERIAL_PORT=/dev/ttyUSB0 bash install_scripts/install_serial.sh --uninstall
#
# List all installed instances:
# bash install_scripts/install_serial.sh --list
#
# Requirements:
# - meshcore-gui project with venv/ directory
@@ -26,7 +41,7 @@ RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
@@ -41,15 +56,66 @@ else
PROJECT_DIR="${SCRIPT_DIR}"
fi
# ── List mode ──
if [[ "${1:-}" == "--list" ]]; then
echo ""
echo "Installed MeshCore GUI instances:"
echo "─────────────────────────────────────────────────"
found=0
for f in /etc/systemd/system/meshcore-gui-*.service; do
[[ -f "$f" ]] || continue
name="$(basename "$f" .service)"
status="$(systemctl is-active "$name" 2>/dev/null || echo inactive)"
port="$(grep -oP '(?<=--port=)\S+' "$f" 2>/dev/null || echo '?')"
device="$(grep -oP '(?<=ExecStart=.{60,200} )/dev/\S+' "$f" 2>/dev/null | head -1 || echo '?')"
echo " ${name}"
echo " device : ${device}"
echo " port : ${port}"
echo " status : ${status}"
echo ""
found=1
done
if [[ $found -eq 0 ]]; then
echo " (none found)"
fi
echo "─────────────────────────────────────────────────"
exit 0
fi
# ── Resolve serial port (needed for service name) ──
SERIAL_PORT="${SERIAL_PORT:-}"
if [[ -z "${SERIAL_PORT}" ]]; then
echo ""
echo -e "${YELLOW}Serial device not specified.${NC}"
echo "You can specify it in two ways:"
echo ""
echo " 1. As an environment variable:"
echo " SERIAL_PORT=/dev/ttyACM0 bash $0"
echo ""
echo " 2. Enter manually:"
read -rp " Serial device (e.g. /dev/ttyACM0 or /dev/ttyUSB0): " SERIAL_PORT
echo ""
fi
if [[ -z "${SERIAL_PORT}" ]]; then
error "No serial device specified. Aborted."
fi
# Derive a safe service name from the device path
# e.g. /dev/ttyUSB1 → meshcore-gui-ttyUSB1
DEVICE_SLUG="$(basename "${SERIAL_PORT}")"
SERVICE_NAME="meshcore-gui-${DEVICE_SLUG}"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
# ── Uninstall mode ──
if [[ "${1:-}" == "--uninstall" ]]; then
info "Removing meshcore-gui service..."
sudo systemctl stop meshcore-gui 2>/dev/null || true
sudo systemctl disable meshcore-gui 2>/dev/null || true
sudo rm -f /etc/systemd/system/meshcore-gui.service
info "Removing ${SERVICE_NAME}..."
sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
sudo systemctl disable "${SERVICE_NAME}" 2>/dev/null || true
sudo rm -f "${SERVICE_FILE}"
sudo systemctl daemon-reload
sudo systemctl reset-failed 2>/dev/null || true
ok "Service removed"
ok "Service '${SERVICE_NAME}' removed"
exit 0
fi
@@ -85,25 +151,6 @@ else
error "Cannot determine entry point."
fi
# Serial port (env or prompt)
SERIAL_PORT="${SERIAL_PORT:-}"
if [[ -z "${SERIAL_PORT}" ]]; then
echo ""
echo -e "${YELLOW}Serial device not specified.${NC}"
echo "You can specify it in two ways:"
echo ""
echo " 1. As an environment variable:"
echo " SERIAL_PORT=/dev/ttyACM0 bash $0"
echo ""
echo " 2. Enter manually:"
read -rp " Serial device (e.g. /dev/ttyACM0 or /dev/ttyUSB0): " SERIAL_PORT
echo ""
fi
if [[ -z "${SERIAL_PORT}" ]]; then
error "No serial device specified. Aborted."
fi
# Optional settings
BAUD="${BAUD:-115200}"
SERIAL_CX_DLY="${SERIAL_CX_DLY:-0.1}"
@@ -132,6 +179,11 @@ if ! id -nG "${CURRENT_USER}" | grep -qw "dialout"; then
warn " (then log out/in)"
fi
# Warn if this service already exists
if [[ -f "${SERVICE_FILE}" ]]; then
warn "Service '${SERVICE_NAME}' already exists and will be overwritten."
fi
# Summary
echo ""
echo "═══════════════════════════════════════════════════"
@@ -146,6 +198,7 @@ echo " Baudrate: ${BAUD}"
echo " CX delay: ${SERIAL_CX_DLY}"
echo " Web port: ${WEB_PORT}"
echo " Debug: ${DEBUG_ON}"
echo " Service name: ${SERVICE_NAME}"
echo "═══════════════════════════════════════════════════"
echo ""
read -rp "Continue? [y/N] " confirm
@@ -187,11 +240,10 @@ ok "Python files are syntactically correct"
# ── Step 3: Install systemd service ──
info "Step 3/3: Installing systemd service..."
SERVICE_FILE="/etc/systemd/system/meshcore-gui.service"
sudo tee "${SERVICE_FILE}" > /dev/null << SERVICE_EOF
[Unit]
Description=MeshCore GUI (Serial)
Description=MeshCore GUI (${SERIAL_PORT})
[Service]
Type=simple
@@ -206,8 +258,8 @@ WantedBy=multi-user.target
SERVICE_EOF
sudo systemctl daemon-reload
sudo systemctl enable meshcore-gui
ok "meshcore-gui.service installed and enabled"
sudo systemctl enable "${SERVICE_NAME}"
ok "'${SERVICE_NAME}' installed and enabled"
# ── Done ──
echo ""
@@ -216,14 +268,17 @@ echo -e " ${GREEN}Installation complete!${NC}"
echo "═══════════════════════════════════════════════════"
echo ""
echo " Commands:"
echo " sudo systemctl start meshcore-gui # Start"
echo " sudo systemctl stop meshcore-gui # Stop"
echo " sudo systemctl restart meshcore-gui # Restart"
echo " sudo systemctl status meshcore-gui # Status"
echo " journalctl -u meshcore-gui -f # Live logs"
echo " sudo systemctl start ${SERVICE_NAME}"
echo " sudo systemctl stop ${SERVICE_NAME}"
echo " sudo systemctl restart ${SERVICE_NAME}"
echo " sudo systemctl status ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
echo ""
echo " Uninstall:"
echo " bash install_scripts/install_serial.sh --uninstall"
echo " All instances:"
echo " bash install_scripts/install_serial.sh --list"
echo ""
echo " Uninstall this instance:"
echo " SERIAL_PORT=${SERIAL_PORT} bash install_scripts/install_serial.sh --uninstall"
echo ""
echo "═══════════════════════════════════════════════════"
@@ -231,14 +286,14 @@ echo "════════════════════════
echo ""
read -rp "Start service now? [y/N] " start_now
if [[ "${start_now}" == "y" || "${start_now}" == "Y" ]]; then
sudo systemctl start meshcore-gui
sudo systemctl start "${SERVICE_NAME}"
sleep 2
if systemctl is-active --quiet meshcore-gui; then
if systemctl is-active --quiet "${SERVICE_NAME}"; then
ok "Service is running!"
echo ""
info "View live logs: journalctl -u meshcore-gui -f"
info "View live logs: journalctl -u ${SERVICE_NAME} -f"
else
warn "Service could not start. Check logs:"
echo " journalctl -u meshcore-gui --no-pager -n 20"
echo " journalctl -u ${SERVICE_NAME} --no-pager -n 20"
fi
fi

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# ============================================================================
# MeshCore GUI — Virtual Environment Setup
# ============================================================================
#
# Creates a venv and installs core Python dependencies.
#
# Usage:
# bash install_scripts/install_venv.sh # from project root
# cd install_scripts && bash install_venv.sh # from install_scripts/
#
# ============================================================================
set -euo pipefail
# ── Resolve project root ──
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "$(basename "${SCRIPT_DIR}")" == "install_scripts" ]]; then
PROJECT_DIR="$(dirname "${SCRIPT_DIR}")"
else
PROJECT_DIR="${SCRIPT_DIR}"
fi
cd "${PROJECT_DIR}"
echo "Creating virtual environment in ${PROJECT_DIR}/venv ..."
python3 -m venv venv
source venv/bin/activate
pip install nicegui meshcore bleak meshcoredecoder
echo "Done. Activate with: source ${PROJECT_DIR}/venv/bin/activate"

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env python3
"""
MeshCore Bridge — Cross-Frequency Message Bridge Daemon
========================================================
Standalone daemon that connects two meshcore_gui instances on
different frequencies by forwarding messages on a configurable
bridge channel. Requires zero modifications to the existing
meshcore_gui codebase.
Usage:
python meshcore_bridge.py
python meshcore_bridge.py --config=bridge_config.yaml
python meshcore_bridge.py --port=9092
python meshcore_bridge.py --debug-on
Author: PE1HVH
Version: 1.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
from meshcore_bridge.__main__ import main
if __name__ == "__main__":
main()

View File

@@ -1,9 +0,0 @@
"""
MeshCore Bridge — Cross-Frequency Message Bridge Daemon.
Standalone daemon that connects two meshcore_gui instances on
different frequencies by forwarding messages on a configurable
bridge channel.
"""
__version__ = "1.0.0"

View File

@@ -1,220 +0,0 @@
#!/usr/bin/env python3
"""
MeshCore Bridge — Entry Point
==============================
Parses command-line arguments, loads YAML configuration, creates two
SharedData/Worker pairs (one per device), initialises the BridgeEngine,
registers the NiceGUI dashboard page and starts the server.
Usage:
python meshcore_bridge.py
python meshcore_bridge.py --config=bridge_config.yaml
python meshcore_bridge.py --port=9092
python meshcore_bridge.py --debug-on
Author: PE1HVH
Version: 1.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import asyncio
import sys
import threading
import time
from pathlib import Path
from nicegui import app, ui
# Allow overriding DEBUG before anything imports it
import meshcore_gui.config as gui_config
try:
from meshcore import MeshCore, EventType # noqa: F401
except ImportError:
print("ERROR: meshcore library not found")
print("Install with: pip install meshcore")
sys.exit(1)
from meshcore_gui.ble.worker import create_worker
from meshcore_gui.core.shared_data import SharedData
from meshcore_bridge.config import BridgeConfig, DEFAULT_CONFIG_PATH
from meshcore_bridge.bridge_engine import BridgeEngine
from meshcore_bridge.gui.dashboard import BridgeDashboard
# Global instances (needed by NiceGUI page decorators)
_dashboard: BridgeDashboard | None = None
@ui.page('/')
def _page_dashboard():
"""NiceGUI page handler — bridge dashboard."""
if _dashboard:
_dashboard.render()
def _print_usage():
"""Show usage information."""
print("MeshCore Bridge — Cross-Frequency Message Bridge Daemon")
print("=" * 58)
print()
print("Usage: python meshcore_bridge.py [OPTIONS]")
print()
print("Options:")
print(" --config=PATH Path to bridge_config.yaml (default: ./bridge_config.yaml)")
print(" --port=PORT Override GUI port from config (default: 9092)")
print(" --debug-on Enable verbose debug logging")
print(" --help Show this help message")
print()
print("Configuration:")
print(" All settings are defined in bridge_config.yaml.")
print(" See BRIDGE.md for full documentation.")
print()
print("Examples:")
print(" python meshcore_bridge.py")
print(" python meshcore_bridge.py --config=/etc/meshcore/bridge_config.yaml")
print(" python meshcore_bridge.py --port=9092 --debug-on")
def _parse_flags(argv):
"""Parse CLI arguments into a flag dict.
Handles ``--flag=value`` and boolean ``--flag``.
"""
flags = {}
for a in argv:
if '=' in a and a.startswith('--'):
key, value = a.split('=', 1)
flags[key] = value
elif a.startswith('--'):
flags[a] = True
return flags
def _bridge_poll_loop(engine: BridgeEngine, interval_ms: int):
"""Background thread that runs the bridge polling loop.
Args:
engine: BridgeEngine instance.
interval_ms: Polling interval in milliseconds.
"""
interval_s = interval_ms / 1000.0
while True:
try:
engine.poll_and_forward()
except Exception as e:
gui_config.debug_print(f"Bridge poll error: {e}")
time.sleep(interval_s)
def main():
"""Main entry point.
Loads configuration, creates dual workers, starts the bridge
engine and the NiceGUI dashboard.
"""
global _dashboard
flags = _parse_flags(sys.argv[1:])
if '--help' in flags:
_print_usage()
sys.exit(0)
# ── Load configuration ──
config_path = Path(flags.get('--config', str(DEFAULT_CONFIG_PATH)))
if config_path.exists():
print(f"Loading config from: {config_path}")
cfg = BridgeConfig.from_yaml(config_path)
else:
print(f"Config not found at {config_path}, using defaults.")
print(f"Run with --help for usage information.")
cfg = BridgeConfig()
# ── CLI overrides ──
if '--debug-on' in flags:
cfg.debug = True
gui_config.DEBUG = True
if '--port' in flags:
try:
cfg.gui_port = int(flags['--port'])
except ValueError:
print(f"ERROR: Invalid port: {flags['--port']}")
sys.exit(1)
cfg.config_path = str(config_path)
# ── Startup banner ──
print("=" * 58)
print("MeshCore Bridge — Cross-Frequency Message Bridge Daemon")
print("=" * 58)
print(f"Config: {config_path}")
print(f"Device A: {cfg.device_a.port} ({cfg.device_a.label})")
print(f"Device B: {cfg.device_b.port} ({cfg.device_b.label})")
print(f"Channel: #{cfg.channel_name} (A:idx={cfg.channel_idx_a}, B:idx={cfg.channel_idx_b})")
print(f"Poll interval:{cfg.poll_interval_ms}ms")
print(f"GUI port: {cfg.gui_port}")
print(f"Forward prefix: {'ON' if cfg.forward_prefix else 'OFF'}")
print(f"Debug mode: {'ON' if cfg.debug else 'OFF'}")
print("=" * 58)
# ── Create dual SharedData instances ──
shared_a = SharedData(f"bridge_a_{cfg.device_a.port.replace('/', '_')}")
shared_b = SharedData(f"bridge_b_{cfg.device_b.port.replace('/', '_')}")
# ── Create BridgeEngine ──
engine = BridgeEngine(shared_a, shared_b, cfg)
# ── Create workers (one per device) ──
gui_config.SERIAL_BAUDRATE = cfg.device_a.baud
worker_a = create_worker(
cfg.device_a.port,
shared_a,
baudrate=cfg.device_a.baud,
)
gui_config.SERIAL_BAUDRATE = cfg.device_b.baud
worker_b = create_worker(
cfg.device_b.port,
shared_b,
baudrate=cfg.device_b.baud,
)
# ── Start workers ──
print(f"Starting worker A ({cfg.device_a.port})...")
worker_a.start()
print(f"Starting worker B ({cfg.device_b.port})...")
worker_b.start()
# ── Start bridge polling thread ──
print(f"Starting bridge engine (poll every {cfg.poll_interval_ms}ms)...")
poll_thread = threading.Thread(
target=_bridge_poll_loop,
args=(engine, cfg.poll_interval_ms),
daemon=True,
)
poll_thread.start()
# ── Create dashboard ──
_dashboard = BridgeDashboard(shared_a, shared_b, engine, cfg)
# ── Start NiceGUI server (blocks) ──
print(f"Starting GUI on port {cfg.gui_port}...")
ui.run(
show=False,
host='0.0.0.0',
title=cfg.gui_title,
port=cfg.gui_port,
reload=False,
storage_secret='meshcore-bridge-secret',
)
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
# =============================================================================
# MeshCore Bridge — Configuration
# =============================================================================
#
# Cross-frequency message bridge daemon configuration.
# See BRIDGE.md for full documentation.
#
# IMPORTANT: The bridge channel must exist on BOTH devices with
# IDENTICAL channel secret/password. Only the frequency
# and channel index may differ.
bridge:
channel_name: "bridge" # Channel name (for display / logging)
channel_idx_a: 3 # Channel index on device A
channel_idx_b: 3 # Channel index on device B
poll_interval_ms: 200 # Polling interval (milliseconds)
forward_prefix: true # Add [sender] prefix to forwarded messages
max_forwarded_cache: 500 # Loop prevention cache size (number of hashes)
device_a:
port: /dev/ttyUSB1 # Serial port for device A
baud: 115200 # Baud rate
label: "869.525 MHz" # Display label for dashboard
device_b:
port: /dev/ttyUSB2 # Serial port for device B
baud: 115200 # Baud rate
label: "868.000 MHz" # Display label for dashboard
gui:
port: 9092 # Web dashboard port
title: "MeshCore Bridge" # Browser tab title

View File

@@ -1,315 +0,0 @@
"""
Core bridge logic: message monitoring, forwarding and loop prevention.
BridgeEngine polls two SharedData stores and forwards messages on the
configured bridge channel from one instance to the other. Loop
prevention is achieved via a bounded set of forwarded message hashes
and by filtering outbound (direction='out') messages.
Thread safety: all SharedData access goes through the existing lock
mechanism in SharedData. BridgeEngine itself is called from a single
asyncio task (the polling loop in __main__).
"""
import hashlib
import time
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from meshcore_gui.core.models import Message
from meshcore_gui.core.shared_data import SharedData
from meshcore_bridge.config import BridgeConfig
@dataclass
class ForwardedEntry:
"""Record of a forwarded message for the bridge log."""
time: str
direction: str # "A→B" or "B→A"
sender: str
text: str
channel: Optional[int]
class BridgeEngine:
"""Core bridge logic: poll, filter, forward and deduplicate.
Monitors two SharedData instances for new incoming messages on the
configured bridge channel and forwards them to the opposite instance
via put_command().
Attributes:
stats: Runtime statistics dict exposed to the GUI dashboard.
"""
def __init__(
self,
shared_a: SharedData,
shared_b: SharedData,
config: BridgeConfig,
) -> None:
self._a = shared_a
self._b = shared_b
self._cfg = config
# Channel indices per device
self._ch_idx_a = config.channel_idx_a
self._ch_idx_b = config.channel_idx_b
# Loop prevention: bounded set of forwarded hashes
self._forwarded_hashes: OrderedDict = OrderedDict()
self._max_cache = config.max_forwarded_cache
# Tracking last seen message count per side
self._last_count_a: int = 0
self._last_count_b: int = 0
# Forwarded message log (for dashboard)
self._log: List[ForwardedEntry] = []
self._max_log: int = 200
# Runtime statistics
self.stats = {
"forwarded_a_to_b": 0,
"forwarded_b_to_a": 0,
"duplicates_blocked": 0,
"last_forward_time": "",
"started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"uptime_seconds": 0,
}
self._start_time = time.time()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def poll_and_forward(self) -> int:
"""Check both stores for new bridge-channel messages and forward.
Returns:
Number of messages forwarded in this poll cycle.
"""
self.stats["uptime_seconds"] = int(time.time() - self._start_time)
count = 0
# A → B
count += self._poll_side(
source=self._a,
target=self._b,
source_ch=self._ch_idx_a,
target_ch=self._ch_idx_b,
direction_label="A→B",
last_count_attr="_last_count_a",
stat_key="forwarded_a_to_b",
)
# B → A
count += self._poll_side(
source=self._b,
target=self._a,
source_ch=self._ch_idx_b,
target_ch=self._ch_idx_a,
direction_label="B→A",
last_count_attr="_last_count_b",
stat_key="forwarded_b_to_a",
)
return count
def get_log(self) -> List[ForwardedEntry]:
"""Return a copy of the forwarded message log (newest first)."""
return list(reversed(self._log))
def get_total_forwarded(self) -> int:
"""Total number of messages forwarded since start."""
return (
self.stats["forwarded_a_to_b"]
+ self.stats["forwarded_b_to_a"]
)
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _poll_side(
self,
source: SharedData,
target: SharedData,
source_ch: int,
target_ch: int,
direction_label: str,
last_count_attr: str,
stat_key: str,
) -> int:
"""Poll one side for new messages and forward to the other.
Args:
source: SharedData to read from.
target: SharedData to write to.
source_ch: Channel index on the source device.
target_ch: Channel index on the target device.
direction_label: "A→B" or "B→A" for logging.
last_count_attr: Name of the self._last_count_* attribute.
stat_key: Key in self.stats to increment.
Returns:
Number of messages forwarded.
"""
forwarded = 0
snapshot = source.get_snapshot()
msgs = snapshot["messages"]
last_count = getattr(self, last_count_attr)
# Detect list shrinkage (e.g. after reconnect/reload)
if len(msgs) < last_count:
setattr(self, last_count_attr, 0)
last_count = 0
new_msgs = msgs[last_count:]
setattr(self, last_count_attr, len(msgs))
for msg in new_msgs:
if self._should_forward(msg, source_ch):
self._forward(msg, target, target_ch, direction_label)
self.stats[stat_key] += 1
forwarded += 1
return forwarded
def _should_forward(self, msg: Message, expected_channel: int) -> bool:
"""Determine whether a message should be forwarded.
Filtering rules:
1. Channel must match the bridge channel for this side.
2. Outbound messages (direction='out') are never forwarded
— they are our own transmissions (including previous forwards).
3. Messages whose hash is already in the forwarded set are
duplicates (loop prevention).
Args:
msg: Message to evaluate.
expected_channel: Bridge channel index on this device.
Returns:
True if the message should be forwarded.
"""
# Rule 1: channel filter
if msg.channel != expected_channel:
return False
# Rule 2: never forward our own transmissions
if msg.direction == "out":
return False
# Rule 3: loop prevention via hash set
msg_hash = self._compute_hash(msg)
if msg_hash in self._forwarded_hashes:
self.stats["duplicates_blocked"] += 1
return False
return True
def _forward(
self,
msg: Message,
target: SharedData,
target_ch: int,
direction_label: str,
) -> None:
"""Forward a message to the target SharedData via put_command().
Args:
msg: Message to forward.
target: Target SharedData instance.
target_ch: Channel index on the target device.
direction_label: "A→B" or "B→A" for logging.
"""
msg_hash = self._compute_hash(msg)
# Register hash for loop prevention
self._forwarded_hashes[msg_hash] = True
if len(self._forwarded_hashes) > self._max_cache:
self._forwarded_hashes.popitem(last=False)
# Also register the hash of the text we're about to send so
# the *other* direction won't re-forward our forwarded message
# if it appears on the target device's bridge channel.
forward_text = self._build_forward_text(msg)
echo_hash = self._text_hash(forward_text)
self._forwarded_hashes[echo_hash] = True
if len(self._forwarded_hashes) > self._max_cache:
self._forwarded_hashes.popitem(last=False)
# Inject send command into the target's command queue
target.put_command({
"action": "send_message",
"channel": target_ch,
"text": forward_text,
"_bot": True, # suppress outgoing Message creation in CommandHandler
})
# Update stats and log
now = datetime.now().strftime("%H:%M:%S")
self.stats["last_forward_time"] = now
entry = ForwardedEntry(
time=now,
direction=direction_label,
sender=msg.sender,
text=msg.text,
channel=msg.channel,
)
self._log.append(entry)
if len(self._log) > self._max_log:
self._log.pop(0)
def _build_forward_text(self, msg: Message) -> str:
"""Build the text to transmit on the target device.
When forward_prefix is enabled, the original sender name is
prepended so recipients can identify the origin.
Args:
msg: Original message.
Returns:
Text string to send.
"""
if self._cfg.forward_prefix:
return f"[{msg.sender}] {msg.text}"
return msg.text
@staticmethod
def _compute_hash(msg: Message) -> str:
"""Compute a deduplication hash for a message.
Uses the message_hash field when available (deterministic
packet ID from MeshCore firmware). Falls back to a SHA-256
digest of channel + sender + text.
Args:
msg: Message to hash.
Returns:
Hash string.
"""
if msg.message_hash:
return f"mh:{msg.message_hash}"
raw = f"{msg.channel}:{msg.sender}:{msg.text}"
return f"ct:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
@staticmethod
def _text_hash(text: str) -> str:
"""Hash a plain text string for echo suppression.
Args:
text: Text to hash.
Returns:
Hash string.
"""
return f"tx:{hashlib.sha256(text.encode()).hexdigest()[:16]}"

View File

@@ -1,140 +0,0 @@
"""
Bridge-specific configuration.
Loads settings from a YAML configuration file and provides typed
access to all bridge parameters. Falls back to sensible defaults
when keys are missing.
Dependencies:
pyyaml (6.x)
"""
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import yaml
# Default config file location (next to meshcore_bridge.py)
DEFAULT_CONFIG_PATH: Path = Path(__file__).parent.parent / "bridge_config.yaml"
@dataclass
class DeviceConfig:
"""Configuration for a single MeshCore device connection."""
port: str = "/dev/ttyUSB0"
baud: int = 115200
label: str = ""
@dataclass
class BridgeConfig:
"""Complete bridge daemon configuration."""
# Bridge channel settings
channel_name: str = "bridge"
channel_idx_a: int = 3
channel_idx_b: int = 3
poll_interval_ms: int = 200
forward_prefix: bool = True
max_forwarded_cache: int = 500
# Device connections
device_a: DeviceConfig = field(default_factory=lambda: DeviceConfig(
port="/dev/ttyUSB1", label="869.525 MHz",
))
device_b: DeviceConfig = field(default_factory=lambda: DeviceConfig(
port="/dev/ttyUSB2", label="868.000 MHz",
))
# GUI settings
gui_port: int = 9092
gui_title: str = "MeshCore Bridge"
# Runtime flags (set from CLI, not YAML)
debug: bool = False
config_path: str = ""
@classmethod
def from_yaml(cls, path: Path) -> "BridgeConfig":
"""Load configuration from a YAML file.
Missing keys fall back to dataclass defaults.
Args:
path: Path to the YAML configuration file.
Returns:
Populated BridgeConfig instance.
Raises:
FileNotFoundError: If the config file does not exist.
yaml.YAMLError: If the file contains invalid YAML.
"""
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
bridge_section = raw.get("bridge", {})
device_a_section = raw.get("device_a", {})
device_b_section = raw.get("device_b", {})
gui_section = raw.get("gui", {})
dev_a = DeviceConfig(
port=device_a_section.get("port", "/dev/ttyUSB1"),
baud=device_a_section.get("baud", 115200),
label=device_a_section.get("label", "Device A"),
)
dev_b = DeviceConfig(
port=device_b_section.get("port", "/dev/ttyUSB2"),
baud=device_b_section.get("baud", 115200),
label=device_b_section.get("label", "Device B"),
)
return cls(
channel_name=bridge_section.get("channel_name", "bridge"),
channel_idx_a=bridge_section.get("channel_idx_a", 3),
channel_idx_b=bridge_section.get("channel_idx_b", 3),
poll_interval_ms=bridge_section.get("poll_interval_ms", 200),
forward_prefix=bridge_section.get("forward_prefix", True),
max_forwarded_cache=bridge_section.get("max_forwarded_cache", 500),
device_a=dev_a,
device_b=dev_b,
gui_port=gui_section.get("port", 9092),
gui_title=gui_section.get("title", "MeshCore Bridge"),
)
def to_yaml(self, path: Path) -> None:
"""Write the current configuration to a YAML file.
Args:
path: Destination file path.
"""
data = {
"bridge": {
"channel_name": self.channel_name,
"channel_idx_a": self.channel_idx_a,
"channel_idx_b": self.channel_idx_b,
"poll_interval_ms": self.poll_interval_ms,
"forward_prefix": self.forward_prefix,
"max_forwarded_cache": self.max_forwarded_cache,
},
"device_a": {
"port": self.device_a.port,
"baud": self.device_a.baud,
"label": self.device_a.label,
},
"device_b": {
"port": self.device_b.port,
"baud": self.device_b.baud,
"label": self.device_b.label,
},
"gui": {
"port": self.gui_port,
"title": self.gui_title,
},
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
yaml.dump(data, fh, default_flow_style=False, sort_keys=False)

View File

@@ -1 +0,0 @@
"""Bridge GUI package."""

View File

@@ -1,193 +0,0 @@
"""
Bridge status dashboard — NiceGUI page with DOMCA theme.
Thin orchestrator that owns the layout, injects the DOMCA theme,
and runs a periodic update timer that refreshes all panels.
Visually consistent with the meshcore_gui dashboard.
"""
from nicegui import ui
from meshcore_gui.core.shared_data import SharedData
from meshcore_gui import config as gui_config
from meshcore_bridge.bridge_engine import BridgeEngine
from meshcore_bridge.config import BridgeConfig
from meshcore_bridge.gui.panels.status_panel import StatusPanel
from meshcore_bridge.gui.panels.log_panel import LogPanel
# ── DOMCA Theme (identical to meshcore_gui/gui/dashboard.py) ─────────
# Subset of the DOMCA theme CSS needed for the bridge dashboard.
_DOMCA_HEAD = '''
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ── DOMCA theme variables (dark) ── */
body.body--dark {
--bg: #0A1628;
--title: #48CAE4; --subtitle: #48CAE4;
}
/* ── DOMCA theme variables (light) ── */
body.body--light {
--bg: #FFFFFF;
--title: #0077B6; --subtitle: #0077B6;
}
/* ── DOMCA page background ── */
body.body--dark { background: #0A1628 !important; }
body.body--light { background: #f4f8fb !important; }
body.body--dark .q-page { background: #0A1628 !important; }
body.body--light .q-page { background: #f4f8fb !important; }
/* ── DOMCA header ── */
body.body--dark .q-header { background: #0d1f35 !important; }
body.body--light .q-header { background: #0077B6 !important; }
/* ── DOMCA cards — dark mode readable ── */
body.body--dark .q-card {
background: #112240 !important;
color: #e0f0f8 !important;
border: 1px solid rgba(0,119,182,0.15) !important;
}
body.body--dark .q-card .text-gray-600 { color: #48CAE4 !important; }
body.body--dark .q-card .text-xs { color: #c0dce8 !important; }
body.body--dark .q-card .text-sm { color: #d0e8f2 !important; }
/* ── Dark mode: fields ── */
body.body--dark .q-field__control { background: #0c1a2e !important; color: #e0f0f8 !important; }
body.body--dark .q-field__native { color: #e0f0f8 !important; }
/* ── Bridge-specific ── */
.bridge-header-text {
font-family: 'JetBrains Mono', monospace;
color: white;
}
</style>
'''
class BridgeDashboard:
"""Bridge status dashboard page.
Provides a NiceGUI-based status view showing both device
connections, bridge statistics and the forwarded message log.
"""
def __init__(
self,
shared_a: SharedData,
shared_b: SharedData,
engine: BridgeEngine,
config: BridgeConfig,
) -> None:
self._shared_a = shared_a
self._shared_b = shared_b
self._engine = engine
self._cfg = config
# Panels (created in render)
self._status: StatusPanel | None = None
self._log: LogPanel | None = None
# Header status label
self._header_status = None
def render(self) -> None:
"""Build the complete bridge dashboard layout and start the timer."""
# Create panel instances
self._status = StatusPanel(
self._shared_a, self._shared_b, self._engine, self._cfg,
)
self._log = LogPanel(self._engine)
# Inject DOMCA theme
ui.add_head_html(_DOMCA_HEAD)
# Default to dark mode
dark = ui.dark_mode(True)
# ── Header ────────────────────────────────────────────────
with ui.header().classes("items-center px-4 py-2 shadow-md"):
ui.icon("swap_horiz").classes("text-white text-2xl")
ui.label(
f"MeshCore Bridge v1.0.0"
).classes("text-lg font-bold ml-2 bridge-header-text")
ui.label(
f"({self._cfg.device_a.label}{self._cfg.device_b.label})"
).classes("text-xs ml-2 bridge-header-text").style("opacity: 0.65")
ui.space()
self._header_status = ui.label("Starting...").classes(
"text-sm opacity-70 bridge-header-text"
)
ui.button(
icon="brightness_6",
on_click=lambda: dark.toggle(),
).props("flat round dense color=white").tooltip("Toggle dark / light")
# ── Main Content ──────────────────────────────────────────
with ui.column().classes("w-full max-w-5xl mx-auto p-4 gap-4"):
# Config summary
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("settings", color="primary").classes("text-lg")
ui.label("Bridge Configuration").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
with ui.row().classes("gap-4 flex-wrap"):
for lbl, val in [
("Channel", f"#{self._cfg.channel_name}"),
("Channel idx A", str(self._cfg.channel_idx_a)),
("Channel idx B", str(self._cfg.channel_idx_b)),
("Poll interval", f"{self._cfg.poll_interval_ms}ms"),
("Prefix", "ON" if self._cfg.forward_prefix else "OFF"),
("Loop cache", str(self._cfg.max_forwarded_cache)),
]:
with ui.column().classes("gap-0"):
ui.label(lbl).classes("text-xs opacity-50")
ui.label(val).classes("text-xs font-bold").style(
"font-family: 'JetBrains Mono', monospace"
)
# Status panel
self._status.render()
# Log panel
self._log.render()
# ── Update timer (500ms, same as meshcore_gui) ────────────
ui.timer(0.5, self._on_timer)
def _on_timer(self) -> None:
"""Periodic UI update callback."""
# Update header status
snap_a = self._shared_a.get_snapshot()
snap_b = self._shared_b.get_snapshot()
conn_a = snap_a.get("connected", False)
conn_b = snap_b.get("connected", False)
total = self._engine.get_total_forwarded()
if conn_a and conn_b:
status = f"✅ Both connected — {total} forwarded"
elif conn_a:
status = f"⚠️ Device B disconnected — {total} forwarded"
elif conn_b:
status = f"⚠️ Device A disconnected — {total} forwarded"
else:
status = "❌ Both devices disconnected"
if self._header_status:
self._header_status.set_text(status)
# Update panels
if self._status:
self._status.update()
if self._log:
self._log.update()

View File

@@ -1,6 +0,0 @@
"""Bridge GUI panels."""
from meshcore_bridge.gui.panels.status_panel import StatusPanel
from meshcore_bridge.gui.panels.log_panel import LogPanel
__all__ = ["StatusPanel", "LogPanel"]

View File

@@ -1,84 +0,0 @@
"""
Log panel — forwarded message log for bridge troubleshooting.
Displays the last N forwarded messages with direction indicator,
sender, timestamp and message text. Layout follows the DOMCA
theme and message panel style used by meshcore_gui.
"""
from typing import Optional
from nicegui import ui
from meshcore_bridge.bridge_engine import BridgeEngine
class LogPanel:
"""Forwarded message log panel for the bridge dashboard.
Shows a scrollable list of forwarded messages, newest first,
with direction indicators (A→B / B→A).
"""
def __init__(self, engine: BridgeEngine) -> None:
self._engine = engine
self._log_container: Optional[ui.column] = None
self._last_count: int = 0
def render(self) -> None:
"""Build the log panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("history", color="primary").classes("text-lg")
ui.label("Forwarded Messages").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
self._log_container = ui.column().classes(
"w-full gap-0 max-h-96 overflow-y-auto"
).style(
"font-family: 'JetBrains Mono', monospace; font-size: 0.75rem"
)
with self._log_container:
ui.label("Waiting for messages...").classes(
"text-xs opacity-40 py-2"
)
def update(self) -> None:
"""Refresh the log if new entries are available."""
log_entries = self._engine.get_log()
current_count = len(log_entries)
if current_count == self._last_count:
return
self._last_count = current_count
if not self._log_container:
return
self._log_container.clear()
with self._log_container:
if not log_entries:
ui.label("Waiting for messages...").classes(
"text-xs opacity-40 py-2"
)
return
for entry in log_entries[:200]:
direction_color = (
"text-blue-400" if "A→B" in entry.direction
else "text-green-400"
)
with ui.row().classes("w-full items-baseline gap-1 py-0.5"):
ui.label(entry.time).classes("text-xs opacity-50 shrink-0")
ui.label(entry.direction).classes(
f"text-xs font-bold shrink-0 {direction_color}"
)
ui.label(f"{entry.sender}:").classes(
"text-xs font-bold shrink-0"
)
ui.label(entry.text).classes(
"text-xs opacity-80 truncate"
)

View File

@@ -1,192 +0,0 @@
"""
Status panel — connection status for both bridge devices.
Shows connectivity state, device info, radio frequency and
bridge engine statistics in a layout consistent with the DOMCA
theme used by meshcore_gui.
"""
from typing import Dict, Optional
from nicegui import ui
from meshcore_gui.core.shared_data import SharedData
from meshcore_bridge.bridge_engine import BridgeEngine
from meshcore_bridge.config import BridgeConfig
class StatusPanel:
"""Connection status panel for the bridge dashboard.
Displays two device status cards (A and B) and a bridge
statistics summary card.
"""
def __init__(
self,
shared_a: SharedData,
shared_b: SharedData,
engine: BridgeEngine,
config: BridgeConfig,
) -> None:
self._a = shared_a
self._b = shared_b
self._engine = engine
self._cfg = config
# UI element references (populated by render)
self._status_a: Optional[ui.label] = None
self._status_b: Optional[ui.label] = None
self._device_a_name: Optional[ui.label] = None
self._device_b_name: Optional[ui.label] = None
self._freq_a: Optional[ui.label] = None
self._freq_b: Optional[ui.label] = None
self._connected_a: Optional[ui.icon] = None
self._connected_b: Optional[ui.icon] = None
# Stats labels
self._fwd_count: Optional[ui.label] = None
self._fwd_a_to_b: Optional[ui.label] = None
self._fwd_b_to_a: Optional[ui.label] = None
self._dupes_blocked: Optional[ui.label] = None
self._last_fwd: Optional[ui.label] = None
self._uptime: Optional[ui.label] = None
def render(self) -> None:
"""Build the status panel UI."""
with ui.row().classes("w-full gap-4 flex-wrap"):
self._render_device_card("A", self._cfg.device_a)
self._render_device_card("B", self._cfg.device_b)
self._render_stats_card()
def _render_device_card(self, side: str, dev_cfg) -> None:
"""Render a single device status card."""
with ui.card().classes("flex-1 min-w-[280px]"):
with ui.row().classes("items-center gap-2 mb-2"):
icon = ui.icon("link", color="green").classes("text-lg")
ui.label(f"Device {side}").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
ui.label(f"({dev_cfg.label})").classes(
"text-xs opacity-60"
).style("font-family: 'JetBrains Mono', monospace")
with ui.column().classes("gap-1"):
with ui.row().classes("items-center gap-2"):
ui.label("Port:").classes("text-xs opacity-60 w-20")
ui.label(dev_cfg.port).classes("text-xs").style(
"font-family: 'JetBrains Mono', monospace"
)
with ui.row().classes("items-center gap-2"):
ui.label("Status:").classes("text-xs opacity-60 w-20")
status_lbl = ui.label("Connecting...").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Device:").classes("text-xs opacity-60 w-20")
name_lbl = ui.label("-").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Frequency:").classes("text-xs opacity-60 w-20")
freq_lbl = ui.label("-").classes("text-xs")
# Store references for updates
if side == "A":
self._status_a = status_lbl
self._device_a_name = name_lbl
self._freq_a = freq_lbl
self._connected_a = icon
else:
self._status_b = status_lbl
self._device_b_name = name_lbl
self._freq_b = freq_lbl
self._connected_b = icon
def _render_stats_card(self) -> None:
"""Render the bridge statistics card."""
with ui.card().classes("flex-1 min-w-[280px]"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("swap_horiz", color="primary").classes("text-lg")
ui.label("Bridge Statistics").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
with ui.column().classes("gap-1"):
with ui.row().classes("items-center gap-2"):
ui.label("Total forwarded:").classes("text-xs opacity-60 w-32")
self._fwd_count = ui.label("0").classes("text-xs font-bold")
with ui.row().classes("items-center gap-2"):
ui.label("A → B:").classes("text-xs opacity-60 w-32")
self._fwd_a_to_b = ui.label("0").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("B → A:").classes("text-xs opacity-60 w-32")
self._fwd_b_to_a = ui.label("0").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Dupes blocked:").classes("text-xs opacity-60 w-32")
self._dupes_blocked = ui.label("0").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Last forward:").classes("text-xs opacity-60 w-32")
self._last_fwd = ui.label("-").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Uptime:").classes("text-xs opacity-60 w-32")
self._uptime = ui.label("0s").classes("text-xs")
def update(self) -> None:
"""Refresh all status labels from current SharedData state."""
self._update_device("A", self._a)
self._update_device("B", self._b)
self._update_stats()
def _update_device(self, side: str, shared: SharedData) -> None:
"""Update device status labels for one side."""
snap = shared.get_snapshot()
if side == "A":
status_lbl = self._status_a
name_lbl = self._device_a_name
freq_lbl = self._freq_a
icon = self._connected_a
else:
status_lbl = self._status_b
name_lbl = self._device_b_name
freq_lbl = self._freq_b
icon = self._connected_b
if status_lbl:
status_lbl.set_text(snap.get("status", "Unknown"))
if name_lbl:
name_lbl.set_text(snap.get("name", "-") or "-")
if freq_lbl:
freq = snap.get("radio_freq", 0)
freq_lbl.set_text(f"{freq:.3f} MHz" if freq else "-")
if icon:
connected = snap.get("connected", False)
icon.props(f'name={"link" if connected else "link_off"}')
icon._props["color"] = "green" if connected else "red"
icon.update()
def _update_stats(self) -> None:
"""Update bridge statistics labels."""
s = self._engine.stats
total = self._engine.get_total_forwarded()
if self._fwd_count:
self._fwd_count.set_text(str(total))
if self._fwd_a_to_b:
self._fwd_a_to_b.set_text(str(s["forwarded_a_to_b"]))
if self._fwd_b_to_a:
self._fwd_b_to_a.set_text(str(s["forwarded_b_to_a"]))
if self._dupes_blocked:
self._dupes_blocked.set_text(str(s["duplicates_blocked"]))
if self._last_fwd:
self._last_fwd.set_text(s["last_forward_time"] or "-")
if self._uptime:
secs = s["uptime_seconds"]
h, rem = divmod(secs, 3600)
m, sec = divmod(rem, 60)
self._uptime.set_text(f"{h}h {m}m {sec}s" if h else f"{m}m {sec}s")

View File

@@ -334,9 +334,25 @@ class _BaseWorker(abc.ABC):
if channels:
self._channels = channels
self.shared.set_channels(channels)
debug_print(f"Cache channels: {[c['name'] for c in channels]}")
debug_print(f"Cache -> channels: {[c['name'] for c in channels]}")
else:
debug_print("Channel cache disabled — skipping cached channels")
# CHANNEL_CACHE_ENABLED is off, but channel names are always cached
# independently. Use them to pre-populate the GUI so channel names
# are visible immediately, before BLE discovery completes.
cached_names = self._cache.get_channel_names()
if cached_names:
name_channels = [
{"idx": idx, "name": name}
for idx, name in sorted(cached_names.items())
]
self._channels = name_channels
self.shared.set_channels(name_channels)
debug_print(
f"Cache -> channel names (fallback): "
f"{[c['name'] for c in name_channels]}"
)
else:
debug_print("Channel cache disabled and no cached names -- skipping")
contacts = self._cache.get_contacts()
if contacts:
@@ -350,9 +366,20 @@ class _BaseWorker(abc.ABC):
secret_bytes = bytes.fromhex(secret_hex)
if len(secret_bytes) >= 16:
self._decoder.add_channel_key(idx, secret_bytes[:16], source="cache")
debug_print(f"Cache channel key [{idx}]")
debug_print(f"Cache -> channel key [{idx}]")
except (ValueError, TypeError) as exc:
debug_print(f"Cache bad channel key [{idx_str}]: {exc}")
debug_print(f"Cache -> bad channel key [{idx_str}]: {exc}")
# Derive decoder keys for hashtag channels from their cached names.
# Hashtag keys are never stored in channel_keys (they are derived from
# the name), so we reconstruct them here to ensure the decoder can
# decrypt hashtag channel messages before BLE discovery completes.
cached_names = self._cache.get_channel_names()
cached_key_indices = {int(k) for k in self._cache.get_channel_keys()}
for idx, name in cached_names.items():
if name.startswith("#") and idx not in cached_key_indices:
self._decoder.add_channel_key_from_name(idx, name)
debug_print(f"Cache -> hashtag key derived for [{idx}] {name}")
cached_orig_name = self._cache.get_original_device_name()
if cached_orig_name:
@@ -584,6 +611,9 @@ class _BaseWorker(abc.ABC):
self._channels = discovered
self.shared.set_channels(discovered)
# Always persist channel names regardless of CHANNEL_CACHE_ENABLED,
# so the GUI can display them immediately on next startup.
self._cache.set_channel_names({ch["idx"]: ch["name"] for ch in discovered})
if CHANNEL_CACHE_ENABLED:
self._cache.set_channels(discovered)
debug_print("Channel list cached to disk")

View File

@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
VERSION: str = "1.19.0"
VERSION: str = "1.20.1"
# ==============================================================================

View File

@@ -123,7 +123,12 @@ class MeshBot:
self._enabled = enabled_check
self._config_store = config_store
self._pinned_check = pinned_check
self._last_reply: float = 0.0
# Per-sender cooldown tracker.
# Key: sender name (str); Value: timestamp of last reply (float).
# Replaces the previous single-float global cooldown which caused the
# bot to silently ignore all senders for BOT_COOLDOWN_SECONDS after
# replying to the first one.
self._last_reply_per_sender: Dict[str, float] = {}
def check_and_reply(
self,
@@ -143,7 +148,7 @@ class MeshBot:
2. Message is on the configured channel.
3. Sender is not the bot itself.
4. Sender name does not end with 'Bot' (prevent loops).
5. Cooldown period has elapsed.
5. Per-sender cooldown period has elapsed.
6. Message text contains a recognised keyword.
Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here.
@@ -194,10 +199,14 @@ class MeshBot:
debug_print(f"BOT: skipping message from other bot '{sender}'")
return
# Guard 5: cooldown?
# Guard 5: per-sender cooldown.
# Each sender gets an independent cooldown window so a reply to one
# node does not silence the bot for all other nodes simultaneously.
now = time.time()
if now - self._last_reply < self._config.cooldown_seconds:
debug_print("BOT: cooldown active, skipping")
sender_key = sender or ""
last_for_sender = self._last_reply_per_sender.get(sender_key, 0.0)
if now - last_for_sender < self._config.cooldown_seconds:
debug_print(f"BOT: cooldown active for '{sender}', skipping")
return
# Guard 6: keyword match
@@ -215,7 +224,12 @@ class MeshBot:
path=path_str,
)
self._last_reply = now
self._last_reply_per_sender[sender_key] = now
# Evict oldest entry when the dict grows too large (prevents unbounded
# memory use in long-running sessions with many unique senders).
if len(self._last_reply_per_sender) > 200:
oldest = min(self._last_reply_per_sender, key=self._last_reply_per_sender.get)
del self._last_reply_per_sender[oldest]
self._sink({
"action": "send_message",
@@ -252,16 +266,28 @@ class MeshBot:
"""Return the effective channel set.
When a :class:`BotConfigStore` is present, its channel selection
is always authoritative — including an empty set (bot silent on
all channels until the user saves a selection in the BOT panel).
The hardcoded :attr:`BotConfig.channels` fallback is only used
when no config store is wired (e.g. in unit tests).
is authoritative — **unless the stored set is empty**, in which case
the hardcoded :attr:`BotConfig.channels` fallback is used. An empty
stored set means the user has not yet saved a channel selection in the
BOT panel; the bot should still respond on the default channels rather
than being silently deaf.
The :attr:`BotConfigStore` fallback is only bypassed entirely when no
config store is wired (e.g. in unit tests).
Returns:
Frozenset of active channel indices.
"""
if self._config_store is not None:
return frozenset(self._config_store.get_settings().channels)
stored = frozenset(self._config_store.get_settings().channels)
if stored:
return stored
# Empty set → fall through to BotConfig defaults (see BotSettings
# docstring: "Empty set means 'use BotConfig defaults'").
debug_print(
"BOT: no channels saved in config store — "
"falling back to BotConfig defaults"
)
return self._config.channels
@staticmethod

View File

@@ -167,6 +167,41 @@ class DeviceCache:
self._data["channel_keys"] = keys
self.save()
# ------------------------------------------------------------------
# Channel names
# ------------------------------------------------------------------
def get_channel_names(self) -> Dict[int, str]:
"""Return cached channel names as ``{idx: name}``.
Always available regardless of ``CHANNEL_CACHE_ENABLED``.
Keys are returned as integers for direct use as channel indices.
"""
raw: Dict[str, str] = self._data.get("channel_names", {})
result: Dict[int, str] = {}
for k, v in raw.items():
try:
result[int(k)] = v
except (ValueError, TypeError):
pass
return result
def set_channel_names(self, names: Dict[int, str]) -> None:
"""Store a complete channel-name mapping and persist to disk.
Replaces any previously cached names with the supplied mapping.
Intended to be called after every successful channel discovery so
the most recent names are always available at next startup,
independent of ``CHANNEL_CACHE_ENABLED``.
Args:
names: Mapping of channel index to channel name,
e.g. ``{0: "Public", 1: "#localmesh"}``.
"""
self._data["channel_names"] = {str(k): v for k, v in names.items()}
self.save()
debug_print(f"Cache: channel names saved: {names}")
# ------------------------------------------------------------------
# Contacts (merge strategy)
# ------------------------------------------------------------------

View File

@@ -10,21 +10,30 @@ keys. The resulting JSON file is placed outside the git repo at::
The MeshCore Observer reads this file automatically for MQTT
authentication — no manual key setup required.
File format::
Multi-device file format (v2)::
{
"public_key": "64-char hex UPPERCASE (from send_appstart)",
"private_key": "128-char hex lowercase (full orlp/ed25519 expanded key)",
"device_name": "PE1HVH T1000e",
"firmware_version": "1.2.3",
"source_device": "/dev/ttyUSB1",
"updated_at": "2026-02-26T15:00:00+00:00"
"/dev/ttyUSB0": {
"public_key": "64-char hex UPPERCASE (from send_appstart)",
"private_key": "128-char hex lowercase (full orlp/ed25519 expanded key)",
"device_name": "PE1HVH T1000e",
"firmware_version": "1.2.3",
"source_device": "/dev/ttyUSB0",
"updated_at": "2026-02-26T15:00:00+00:00"
},
"/dev/ttyUSB1": {
...
}
}
Author: PE1HVH
Version: 1.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
Backward compatibility: if the file still contains the v1 flat-object
format (``"public_key"`` at the top level), it is automatically
migrated to the v2 format on the first write.
Author: PE1HVH
Version: 1.1.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import json
@@ -39,6 +48,37 @@ from meshcore_gui.config import DATA_DIR, debug_print
IDENTITY_FILE: Path = DATA_DIR / "device_identity.json"
def _load_raw() -> dict:
"""Load the raw identity file and return a v2-format dict.
Performs automatic migration when a v1 flat-object file is detected.
Returns an empty dict if the file does not exist or is unreadable.
"""
if not IDENTITY_FILE.exists():
return {}
try:
data = json.loads(IDENTITY_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
debug_print(f"DeviceIdentity: read error: {exc}")
return {}
if not isinstance(data, dict):
debug_print("DeviceIdentity: unexpected root type, resetting")
return {}
# v1 detection: public_key is a string directly at the root level
if "public_key" in data and isinstance(data["public_key"], str):
src = data.get("source_device", "unknown")
debug_print(
f"DeviceIdentity: v1 format detected — migrating entry "
f"for '{src}' to v2 multi-device format"
)
return {src: data}
return data
def write_device_identity(
public_key: str,
private_key_bytes: bytes,
@@ -46,34 +86,27 @@ def write_device_identity(
firmware_version: str = "",
source_device: str = "",
) -> bool:
"""Write the device identity file for MeshCore Observer.
"""Write (or update) the device identity entry for *source_device*.
The file stores one entry per device, keyed by ``source_device``
(e.g. ``"/dev/ttyUSB0"``). Entries for other devices are left
untouched.
Args:
public_key: 64-char hex public key (from send_appstart).
This is the key shown in the GUI and registered
at LetsMesh. MUST be used for MQTT username.
public_key: 64-char hex public key (from send_appstart).
Used for MQTT username at LetsMesh.
private_key_bytes: 64 raw bytes from export_private_key() in
orlp/ed25519 expanded format. All 64 bytes
are needed for createAuthToken().
device_name: Device display name.
firmware_version: Firmware version string.
source_device: Device path (e.g. ``/dev/ttyUSB1``).
Used as the dict key.
Returns:
True if the file was written successfully.
"""
try:
# The 64 bytes from export_private_key() are in orlp/ed25519
# *expanded* format:
# bytes 0..31 = clamped scalar (NOT the raw seed)
# bytes 32..63 = nonce prefix (NOT the public key)
#
# The public key is NOT contained in these 64 bytes — it must
# come from send_appstart() which returns the actual device
# public key as shown in the GUI and registered at LetsMesh.
#
# For MQTT auth via meshcore-decoder's createAuthToken(), the
# full 64 bytes are needed as privateKeyHex (128 hex chars).
if len(private_key_bytes) != 64:
debug_print(
f"DeviceIdentity: unexpected key length "
@@ -81,9 +114,6 @@ def write_device_identity(
)
return False
# Full 64-byte private key in MeshCore/orlp format
private_key_hex = private_key_bytes.hex()
if not public_key or len(public_key) != 64:
debug_print(
f"DeviceIdentity: no valid public key from appstart "
@@ -91,7 +121,9 @@ def write_device_identity(
)
return False
identity = {
private_key_hex = private_key_bytes.hex()
entry = {
"public_key": public_key.upper(),
"private_key": private_key_hex.lower(),
"device_name": device_name,
@@ -100,19 +132,27 @@ def write_device_identity(
"updated_at": datetime.now(timezone.utc).isoformat(),
}
# Load existing data (handles v1 -> v2 migration transparently)
all_identities = _load_raw()
# Update only this device's entry; others remain unchanged
all_identities[source_device] = entry
DATA_DIR.mkdir(parents=True, exist_ok=True)
IDENTITY_FILE.write_text(
json.dumps(identity, indent=2) + "\n",
json.dumps(all_identities, indent=2) + "\n",
encoding="utf-8",
)
# Restrictive permissions — file contains the private key
# Restrictive permissions — file contains private keys
IDENTITY_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) # 600
debug_print(
f"DeviceIdentity: written to {IDENTITY_FILE} "
f"(pub={public_key[:12]}... priv={private_key_hex[:12]}...)"
f"[{source_device}] "
f"(pub={public_key[:12]}... priv={private_key_hex[:12]}...) "
f"— total devices in file: {len(all_identities)}"
)
print(f"📝 Device identity saved → {IDENTITY_FILE}")
print(f"📝 Device identity saved → {IDENTITY_FILE} [{source_device}]")
return True
except Exception as exc:
@@ -121,27 +161,42 @@ def write_device_identity(
return False
def read_device_identity() -> Optional[dict]:
"""Read the device identity file.
def read_device_identity(
source_device: Optional[str] = None,
) -> Optional[dict]:
"""Read one or all device identity entries.
Args:
source_device: If given, return only the entry for that device
(e.g. ``"/dev/ttyUSB1"``). If *None*, return
the full multi-device dict.
Returns:
Dict with ``public_key`` and ``private_key`` (hex strings),
or None if the file does not exist or is invalid.
* When *source_device* is specified: a single entry dict with
``public_key`` and ``private_key`` (hex strings), or *None*
if not found / keys invalid.
* When *source_device* is *None*: the full ``{device: entry}``
dict (may be empty dict, never None).
"""
if not IDENTITY_FILE.exists():
return None
all_identities = _load_raw()
try:
data = json.loads(IDENTITY_FILE.read_text(encoding="utf-8"))
pub = data.get("public_key", "")
priv = data.get("private_key", "")
if len(pub) == 64 and len(priv) in (64, 128):
return data
if source_device is None:
return all_identities or {}
entry = all_identities.get(source_device)
if entry is None:
debug_print(
f"DeviceIdentity: invalid key lengths in {IDENTITY_FILE} "
f"(pub={len(pub)}, priv={len(priv)})"
f"DeviceIdentity: no entry for '{source_device}' in {IDENTITY_FILE}"
)
return None
except (json.JSONDecodeError, OSError) as exc:
debug_print(f"DeviceIdentity: read error: {exc}")
return None
pub = entry.get("public_key", "")
priv = entry.get("private_key", "")
if len(pub) == 64 and len(priv) in (64, 128):
return entry
debug_print(
f"DeviceIdentity: invalid key lengths for '{source_device}' "
f"(pub={len(pub)}, priv={len(priv)})"
)
return None

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env python3
"""
MeshCore Observer — Read-Only Archive Monitor Dashboard
=========================================================
Standalone daemon that reads archive JSON files produced by
meshcore_gui and meshcore_bridge, aggregates them, and presents
a unified NiceGUI monitoring dashboard. 100% read-only — never
modifies archive files.
Usage:
python meshcore_observer.py
python meshcore_observer.py --config=observer_config.yaml
python meshcore_observer.py --port=9093
python meshcore_observer.py --debug-on
Author: PE1HVH
Version: 1.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
from meshcore_observer.__main__ import main
if __name__ == "__main__":
main()

View File

@@ -1,9 +0,0 @@
"""
MeshCore Observer — Read-Only Archive Monitor Dashboard.
Standalone daemon that reads archive JSON files produced by
meshcore_gui and meshcore_bridge, aggregates them, and presents
a unified NiceGUI monitoring dashboard.
"""
__version__ = "1.2.0"

View File

@@ -1,243 +0,0 @@
#!/usr/bin/env python3
"""
MeshCore Observer — Entry Point
=================================
Parses command-line arguments, loads YAML configuration, creates the
ArchiveWatcher, optionally starts the MQTT uplink, registers the
NiceGUI dashboard page and starts the server.
Usage:
python meshcore_observer.py
python meshcore_observer.py --config=observer_config.yaml
python meshcore_observer.py --port=9093
python meshcore_observer.py --debug-on
python meshcore_observer.py --mqtt-dry-run
Author: PE1HVH
Version: 1.1.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import logging
import sys
from pathlib import Path
from nicegui import ui
from meshcore_observer import __version__
from meshcore_observer.config import ObserverConfig, DEFAULT_CONFIG_PATH
from meshcore_observer.archive_watcher import ArchiveWatcher
from meshcore_observer.gui.dashboard import ObserverDashboard
logger = logging.getLogger("meshcore_observer")
# Global instance (needed by NiceGUI page decorator)
_dashboard: ObserverDashboard | None = None
@ui.page("/")
def _page_dashboard():
"""NiceGUI page handler — observer dashboard."""
if _dashboard:
_dashboard.render()
def _print_usage():
"""Show usage information."""
print("MeshCore Observer — Read-Only Archive Monitor Dashboard")
print("=" * 58)
print()
print("Usage: python meshcore_observer.py [OPTIONS]")
print()
print("Options:")
print(" --config=PATH Path to observer_config.yaml (default: ./observer_config.yaml)")
print(" --port=PORT Override GUI port from config (default: 9093)")
print(" --debug-on Enable verbose debug logging")
print(" --mqtt-dry-run MQTT dry run: log payloads without publishing")
print(" --help Show this help message")
print()
print("Configuration:")
print(" All settings are defined in observer_config.yaml.")
print()
print("Examples:")
print(" python meshcore_observer.py")
print(" python meshcore_observer.py --config=/etc/meshcore/observer_config.yaml")
print(" python meshcore_observer.py --port=9093 --debug-on")
print(" python meshcore_observer.py --mqtt-dry-run")
def _parse_flags(argv):
"""Parse CLI arguments into a flag dict.
Handles ``--flag=value`` and boolean ``--flag``.
"""
flags = {}
for a in argv:
if "=" in a and a.startswith("--"):
key, value = a.split("=", 1)
flags[key] = value
elif a.startswith("--"):
flags[a] = True
return flags
def _setup_logging(debug: bool) -> None:
"""Configure logging for the observer process."""
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%H:%M:%S",
)
def _create_mqtt_uplink(cfg: ObserverConfig):
"""Create and validate MQTT uplink if enabled.
Args:
cfg: Observer configuration.
Returns:
MqttUplink instance or None if disabled/invalid.
"""
if not cfg.mqtt.enabled:
logger.info("MQTT uplink: disabled")
return None
# Validate configuration
errors = cfg.mqtt.validate()
if errors:
for err in errors:
logger.error("MQTT config error: %s", err)
logger.error("MQTT uplink disabled due to configuration errors")
return None
try:
from meshcore_observer.mqtt_uplink import MqttUplink
except ImportError as exc:
logger.error("Cannot import MqttUplink: %s", exc)
logger.error(
"Install dependencies: pip install paho-mqtt PyNaCl"
)
return None
uplink = MqttUplink(cfg.mqtt, debug=cfg.debug)
mode = "DRY RUN" if cfg.mqtt.dry_run else "LIVE"
logger.info(
"MQTT uplink: enabled (%s) — IATA=%s, key=%s...",
mode, cfg.mqtt.iata, cfg.mqtt.resolve_public_key()[:12],
)
return uplink
def main():
"""Main entry point.
Loads configuration, creates ArchiveWatcher, optionally starts
MQTT uplink, starts the NiceGUI dashboard.
"""
global _dashboard
flags = _parse_flags(sys.argv[1:])
if "--help" in flags:
_print_usage()
sys.exit(0)
# ── Load configuration ──
config_path = Path(flags.get("--config", str(DEFAULT_CONFIG_PATH)))
if config_path.exists():
print(f"Loading config from: {config_path}")
cfg = ObserverConfig.from_yaml(config_path)
else:
print(f"Config not found at {config_path}, using defaults.")
print("Run with --help for usage information.")
cfg = ObserverConfig()
# ── CLI overrides ──
if "--debug-on" in flags:
cfg.debug = True
if "--port" in flags:
try:
cfg.gui_port = int(flags["--port"])
except ValueError:
print(f"ERROR: Invalid port: {flags['--port']}")
sys.exit(1)
if "--mqtt-dry-run" in flags:
cfg.mqtt.dry_run = True
# Also enable MQTT if not already
if not cfg.mqtt.enabled:
cfg.mqtt.enabled = True
cfg.config_path = str(config_path)
# ── Setup logging ──
_setup_logging(cfg.debug)
# ── Startup banner ──
print("=" * 58)
print("MeshCore Observer — Read-Only Archive Monitor Dashboard")
print("=" * 58)
print(f"Version: {__version__}")
print(f"Config: {config_path}")
print(f"Archive dir: {cfg.archive_dir}")
print(f"Poll interval:{cfg.poll_interval_s}s")
print(f"GUI port: {cfg.gui_port}")
print(f"Debug mode: {'ON' if cfg.debug else 'OFF'}")
print(f"MQTT uplink: {'ENABLED' if cfg.mqtt.enabled else 'DISABLED'}")
if cfg.mqtt.enabled:
mode = "DRY RUN" if cfg.mqtt.dry_run else "LIVE"
print(f"MQTT mode: {mode}")
print(f"MQTT IATA: {cfg.mqtt.iata}")
enabled_brokers = [b.name for b in cfg.mqtt.brokers if b.enabled]
print(f"MQTT brokers: {', '.join(enabled_brokers) or 'none'}")
print("=" * 58)
# ── Verify archive directory ──
archive_path = Path(cfg.archive_dir)
if not archive_path.exists():
logger.warning(
"Archive directory does not exist yet: %s"
"will start scanning when it appears.",
cfg.archive_dir,
)
# ── Create ArchiveWatcher ──
watcher = ArchiveWatcher(cfg.archive_dir, debug=cfg.debug)
# ── Create MQTT uplink (if enabled) ──
mqtt_uplink = _create_mqtt_uplink(cfg)
if mqtt_uplink:
mqtt_uplink.start()
# ── Create dashboard ──
_dashboard = ObserverDashboard(watcher, cfg, mqtt_uplink=mqtt_uplink)
# ── Start NiceGUI server (blocks) ──
print(f"Starting GUI on port {cfg.gui_port}...")
try:
ui.run(
show=False,
host="0.0.0.0",
title=cfg.gui_title,
port=cfg.gui_port,
reload=False,
storage_secret="meshcore-observer-secret",
)
finally:
# Graceful MQTT shutdown
if mqtt_uplink:
mqtt_uplink.shutdown()
if __name__ == "__main__":
main()

View File

@@ -1,274 +0,0 @@
"""
Archive file watcher for MeshCore Observer.
Scans the archive directory for ``*_messages.json`` and ``*_rxlog.json``
files, tracks their modification times, and returns only NEW entries
since the previous poll. 100% read-only — never writes to archive files.
Thread safety: all public methods acquire ``_lock`` before touching state.
"""
import json
import logging
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
ARCHIVE_VERSION = 1
@dataclass
class _FileState:
"""Tracking state for a single archive JSON file."""
path: Path
last_mtime: float = 0.0
last_entry_count: int = 0
address: str = ""
@dataclass
class PollResult:
"""New entries discovered during a single poll cycle.
Attributes:
new_messages: List of (source_address, message_dict) tuples.
new_rxlog: List of (source_address, entry_dict) tuples.
"""
new_messages: List[Tuple[str, dict]] = field(default_factory=list)
new_rxlog: List[Tuple[str, dict]] = field(default_factory=list)
class ArchiveWatcher:
"""Polls archive directory for new messages and RX log entries.
Designed for timer-based polling from the NiceGUI main thread.
Each call to :meth:`poll` scans the archive directory, detects
changed files via ``stat().st_mtime``, reads only changed files,
and returns new entries as a :class:`PollResult`.
Args:
archive_dir: Path to the archive directory.
debug: Enable verbose debug logging.
"""
def __init__(self, archive_dir: str, debug: bool = False) -> None:
self._archive_dir = Path(archive_dir).expanduser().resolve()
self._debug = debug
self._lock = threading.Lock()
# Tracking state: filepath_str → _FileState
self._msg_files: Dict[str, _FileState] = {}
self._rxlog_files: Dict[str, _FileState] = {}
# Aggregated totals
self._total_messages_seen: int = 0
self._total_rxlog_seen: int = 0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def poll(self) -> PollResult:
"""Scan archive directory and return new entries since last poll.
Returns:
PollResult with new messages and RX log entries.
Empty lists when nothing has changed (no disk I/O).
"""
with self._lock:
result = PollResult()
if not self._archive_dir.exists():
return result
# Discover current files on disk
current_msg_paths = set()
current_rxlog_paths = set()
try:
for f in self._archive_dir.iterdir():
name = f.name
if name.endswith("_messages.json"):
current_msg_paths.add(str(f))
elif name.endswith("_rxlog.json"):
current_rxlog_paths.add(str(f))
except OSError as exc:
logger.error("Error scanning archive dir: %s", exc)
return result
# Prune vanished files
vanished_msg = set(self._msg_files.keys()) - current_msg_paths
for vp in vanished_msg:
del self._msg_files[vp]
vanished_rxlog = set(self._rxlog_files.keys()) - current_rxlog_paths
for vp in vanished_rxlog:
del self._rxlog_files[vp]
# Check message files
for fpath_str in current_msg_paths:
new_entries = self._check_file(
fpath_str, self._msg_files, "messages",
)
if new_entries:
result.new_messages.extend(new_entries)
self._total_messages_seen += len(new_entries)
# Check rxlog files
for fpath_str in current_rxlog_paths:
new_entries = self._check_file(
fpath_str, self._rxlog_files, "entries",
)
if new_entries:
result.new_rxlog.extend(new_entries)
self._total_rxlog_seen += len(new_entries)
return result
def get_sources(self) -> List[Dict]:
"""Return metadata about all tracked archive sources.
Returns:
List of dicts with keys: address, path, last_updated, message_count,
rxlog_count.
"""
with self._lock:
# Collect per-address info
sources: Dict[str, Dict] = {}
for fpath_str, state in self._msg_files.items():
addr = state.address or Path(fpath_str).stem
if addr not in sources:
sources[addr] = {
"address": addr,
"path": str(Path(fpath_str).parent),
"last_updated": "",
"message_count": 0,
"rxlog_count": 0,
}
sources[addr]["message_count"] = state.last_entry_count
sources[addr]["path"] = fpath_str
for fpath_str, state in self._rxlog_files.items():
addr = state.address or Path(fpath_str).stem
if addr not in sources:
sources[addr] = {
"address": addr,
"path": fpath_str,
"last_updated": "",
"message_count": 0,
"rxlog_count": 0,
}
sources[addr]["rxlog_count"] = state.last_entry_count
return list(sources.values())
def get_stats(self) -> Dict:
"""Return aggregate statistics.
Returns:
Dict with total_messages_seen, total_rxlog_seen, active_sources.
"""
with self._lock:
addresses = set()
for state in self._msg_files.values():
if state.address:
addresses.add(state.address)
for state in self._rxlog_files.values():
if state.address:
addresses.add(state.address)
return {
"total_messages_seen": self._total_messages_seen,
"total_rxlog_seen": self._total_rxlog_seen,
"active_sources": len(addresses),
}
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _check_file(
self,
fpath_str: str,
tracking: Dict[str, _FileState],
entries_key: str,
) -> List[Tuple[str, dict]]:
"""Check a single file for new entries.
Args:
fpath_str: Absolute path string.
tracking: Dict of _FileState for this file category.
entries_key: JSON key containing the entry list ("messages" or "entries").
Returns:
List of (source_address, entry_dict) for new entries, or empty list.
"""
fpath = Path(fpath_str)
try:
current_mtime = fpath.stat().st_mtime
except OSError:
# File vanished between iterdir and stat
tracking.pop(fpath_str, None)
return []
# New file — start tracking
if fpath_str not in tracking:
tracking[fpath_str] = _FileState(path=fpath)
state = tracking[fpath_str]
# Unchanged file — no I/O needed
if current_mtime == state.last_mtime:
return []
# File changed — read and parse
state.last_mtime = current_mtime
try:
raw_text = fpath.read_text(encoding="utf-8")
data = json.loads(raw_text)
except (json.JSONDecodeError, OSError) as exc:
logger.error("Error reading %s: %s", fpath, exc)
return []
# Version check
if data.get("version") != ARCHIVE_VERSION:
if self._debug:
logger.debug(
"Skipping %s: version %s (expected %d)",
fpath.name, data.get("version"), ARCHIVE_VERSION,
)
return []
# Extract source address
address = data.get("address", fpath.stem)
state.address = address
entries = data.get(entries_key, [])
total_count = len(entries)
prev_count = state.last_entry_count
# Detect new entries (append-only assumption)
new_entries: List[Tuple[str, dict]] = []
if total_count > prev_count:
for entry in entries[prev_count:]:
# Tag each entry with source address
entry["_source"] = address
new_entries.append((address, entry))
state.last_entry_count = total_count
if new_entries and self._debug:
logger.debug(
"%s: %d new %s (total: %d)",
fpath.name, len(new_entries), entries_key, total_count,
)
return new_entries

View File

@@ -1,348 +0,0 @@
"""
Ed25519 JWT authentication token for LetsMesh MQTT broker.
Generates tokens compatible with the ``@michaelhart/meshcore-decoder``
``createAuthToken()`` reference implementation.
Private key formats supported:
- **128 hex chars** (64 bytes): Full orlp/ed25519 expanded key as stored
in ``device_identity.json`` by ``device_identity.py``.
Format: ``[clamped_scalar(32)][nonce_prefix(32)]``.
- **64 hex chars** (32 bytes): Legacy Ed25519 seed. Works with PyNaCl
fallback and with Node.js (seed + pubkey concatenation).
Strategy:
1. **Node.js** — calls meshcore-decoder directly (reference impl)
2. **PyNaCl** — pure Python fallback (seed-only, 64-char keys)
Author: PE1HVH
Version: 2.1.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import base64
import json
import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────
DEFAULT_TOKEN_LIFETIME_S = 3600 # 1 hour
TOKEN_REFRESH_MARGIN_S = 300 # Refresh 5 minutes before expiry
VALID_PRIVATE_KEY_LENGTHS = (64, 128) # 32-byte seed or 64-byte expanded
def _resolve_node_path() -> str:
"""Resolve NODE_PATH — check common global install locations."""
env_val = os.environ.get("NODE_PATH", "")
if env_val:
return env_val
# Common locations: Debian/Ubuntu, npm global on Pi/macOS, nvm
for candidate in (
"/usr/lib/node_modules",
"/usr/local/lib/node_modules",
Path.home() / ".npm-global" / "lib" / "node_modules",
):
if Path(candidate).is_dir():
return str(candidate)
return "/usr/lib/node_modules" # fallback
_NODE_ENV = {**os.environ, "NODE_PATH": _resolve_node_path()}
_node_available: Optional[bool] = None
# ── Key helpers ──────────────────────────────────────────────────────
def _is_valid_hex(value: str, allowed_lengths: tuple) -> bool:
"""Check if *value* is a hex string with one of *allowed_lengths*."""
try:
bytes.fromhex(value)
except ValueError:
return False
return len(value) in allowed_lengths
def _build_nodejs_private_key(
private_key_hex: str,
public_key_hex: str,
) -> str:
"""Return the 128-char hex private key meshcore-decoder expects.
- 128-char input → already complete orlp expanded key; pass through.
- 64-char input → legacy seed; concatenate ``seed + pubkey``.
"""
if len(private_key_hex) == 128:
return private_key_hex
return private_key_hex + public_key_hex.lower()
# ── Node.js strategy ────────────────────────────────────────────────
def _check_node_available() -> bool:
"""Check if Node.js and meshcore-decoder are available."""
global _node_available
if _node_available is not None:
return _node_available
if not shutil.which("node"):
logger.debug("Node.js not found in PATH")
_node_available = False
return False
try:
result = subprocess.run(
["node", "-e",
"require('@michaelhart/meshcore-decoder').createAuthToken"],
env=_NODE_ENV,
capture_output=True,
timeout=5,
)
_node_available = result.returncode == 0
if _node_available:
logger.info("Using Node.js meshcore-decoder for MQTT auth tokens")
else:
logger.debug(
"meshcore-decoder not available: %s",
result.stderr.decode().strip(),
)
except Exception as exc:
logger.debug("Node.js check failed: %s", exc)
_node_available = False
return _node_available
def _create_token_nodejs(
public_key_hex: str,
private_key_hex: str,
audience: str,
lifetime_s: int,
) -> str:
"""Create auth token via Node.js meshcore-decoder.
Handles both 64-char seeds (concatenated with pubkey) and
128-char orlp expanded keys (passed directly).
"""
full_priv = _build_nodejs_private_key(private_key_hex, public_key_hex)
pub_upper = public_key_hex.upper()
js_code = f"""
const {{ createAuthToken }} = require('@michaelhart/meshcore-decoder');
(async () => {{
const payload = {{
publicKey: '{pub_upper}',
aud: '{audience}',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + {lifetime_s}
}};
const token = await createAuthToken(payload, '{full_priv}', '{pub_upper}');
process.stdout.write(token);
}})();
"""
result = subprocess.run(
["node", "-e", js_code],
env=_NODE_ENV,
capture_output=True,
timeout=10,
)
if result.returncode != 0:
stderr = result.stderr.decode().strip()
raise RuntimeError(f"Node.js token generation failed: {stderr}")
token = result.stdout.decode().strip()
if not token or token.count(".") != 2:
raise RuntimeError(
f"Node.js returned invalid token: {token[:50]}..."
)
return token
# ── PyNaCl strategy ──────────────────────────────────────────────────
def _base64url_encode(data: bytes) -> str:
"""Base64url encode without padding (JWT standard)."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _create_token_pynacl(
public_key_hex: str,
private_key_hex: str,
audience: str,
lifetime_s: int,
) -> str:
"""Create auth token via PyNaCl (fallback, 64-char seed only).
The orlp/ed25519 expanded format (128-char) cannot be used with
PyNaCl because the expanded key is not a seed — it is the result
of ``SHA-512(seed)`` with clamping applied. PyNaCl's ``SigningKey``
expects the original 32-byte seed.
"""
if len(private_key_hex) == 128:
raise ValueError(
"PyNaCl fallback requires a 64-char Ed25519 seed. "
"The 128-char orlp/ed25519 expanded key is only supported "
"via Node.js meshcore-decoder. "
"Install: npm install -g @michaelhart/meshcore-decoder"
)
try:
from nacl.signing import SigningKey
except ImportError:
raise ImportError(
"Neither Node.js meshcore-decoder nor PyNaCl are available. "
"Install one: npm install -g @michaelhart/meshcore-decoder "
"OR pip install PyNaCl"
)
signing_key = SigningKey(bytes.fromhex(private_key_hex))
header = {"alg": "Ed25519", "typ": "JWT"}
now = int(time.time())
payload = {
"publicKey": public_key_hex.upper(),
"aud": audience,
"iat": now,
"exp": now + lifetime_s,
}
header_b64 = _base64url_encode(
json.dumps(header, separators=(",", ":")).encode("utf-8")
)
payload_b64 = _base64url_encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
message = f"{header_b64}.{payload_b64}".encode("utf-8")
signed = signing_key.sign(message)
signature_b64 = _base64url_encode(signed.signature)
return f"{header_b64}.{payload_b64}.{signature_b64}"
# ── Public API ───────────────────────────────────────────────────────
def create_auth_token(
public_key_hex: str,
private_key_hex: str,
audience: str,
lifetime_s: int = DEFAULT_TOKEN_LIFETIME_S,
) -> str:
"""Create a LetsMesh-compatible Ed25519 JWT authentication token.
Tries Node.js meshcore-decoder first (reference implementation),
falls back to PyNaCl if unavailable (seed-only).
Args:
public_key_hex: 64-char hex device public key (from appstart).
private_key_hex: Ed25519 private key — either 128-char hex
(orlp expanded, preferred) or 64-char hex (seed).
audience: Broker hostname (e.g. ``mqtt-eu-v1.letsmesh.net``).
lifetime_s: Token validity in seconds (default 3600).
Returns:
JWT-style token string: ``header.payload.signature``
Raises:
ValueError: If key format is invalid.
"""
if not _is_valid_hex(public_key_hex, (64,)):
raise ValueError(
f"Public key must be 64 hex chars, got {len(public_key_hex)}"
)
if not _is_valid_hex(private_key_hex, VALID_PRIVATE_KEY_LENGTHS):
raise ValueError(
f"Private key must be 64 or 128 hex chars, "
f"got {len(private_key_hex)}"
)
# Strategy 1: Node.js meshcore-decoder (reference implementation)
if _check_node_available():
try:
token = _create_token_nodejs(
public_key_hex, private_key_hex, audience, lifetime_s,
)
logger.debug("Token generated via Node.js meshcore-decoder")
return token
except Exception as exc:
logger.warning(
"Node.js token generation failed, falling back to PyNaCl: %s",
exc,
)
# Strategy 2: PyNaCl fallback (seed-only)
token = _create_token_pynacl(
public_key_hex, private_key_hex, audience, lifetime_s,
)
logger.debug("Token generated via PyNaCl (fallback)")
return token
class TokenManager:
"""Manages JWT token lifecycle with automatic refresh.
Args:
public_key_hex: 64-char hex device public key.
private_key_hex: 64- or 128-char hex Ed25519 private key.
lifetime_s: Token validity in seconds.
"""
def __init__(
self,
public_key_hex: str,
private_key_hex: str,
lifetime_s: int = DEFAULT_TOKEN_LIFETIME_S,
) -> None:
self._public_key = public_key_hex
self._private_key = private_key_hex
self._lifetime_s = lifetime_s
self._current_token: Optional[str] = None
self._token_expiry: float = 0.0
@property
def username(self) -> str:
"""MQTT username: ``v1_{PUBLIC_KEY}``."""
return f"v1_{self._public_key.upper()}"
def get_token(self, audience: str) -> str:
"""Get a valid token, refreshing if necessary."""
now = time.time()
if (
self._current_token is None
or now >= self._token_expiry - TOKEN_REFRESH_MARGIN_S
):
self._current_token = create_auth_token(
self._public_key,
self._private_key,
audience,
self._lifetime_s,
)
self._token_expiry = now + self._lifetime_s
logger.debug(
"Generated new auth token for %s (expires in %ds)",
audience,
self._lifetime_s,
)
return self._current_token
def invalidate(self) -> None:
"""Force token regeneration on next ``get_token()`` call."""
self._current_token = None
self._token_expiry = 0.0

View File

@@ -1,372 +0,0 @@
"""
Observer-specific configuration.
Loads settings from a YAML configuration file and provides typed
access to all observer parameters. Falls back to sensible defaults
when keys are missing.
Dependencies:
pyyaml (6.x)
"""
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
import yaml
logger = logging.getLogger(__name__)
# Default config file location (next to meshcore_observer.py)
DEFAULT_CONFIG_PATH: Path = Path(__file__).parent.parent / "observer_config.yaml"
# Device identity file written by meshcore_gui (auto-detected)
DEFAULT_DEVICE_IDENTITY_PATH: Path = Path.home() / ".meshcore-gui" / "device_identity.json"
# Valid key lengths (hex chars)
VALID_PUBLIC_KEY_LENGTH = 64 # 32-byte Ed25519 public key
VALID_PRIVATE_KEY_LENGTHS = (64, 128) # 32-byte seed or 64-byte expanded
# ── Key validation helper ────────────────────────────────────────────
def _is_valid_key(hex_str: str, allowed_lengths: tuple) -> bool:
"""Check if *hex_str* is valid hex with one of the allowed lengths."""
if not hex_str or len(hex_str) not in allowed_lengths:
return False
try:
bytes.fromhex(hex_str)
return True
except ValueError:
return False
# ── MQTT Broker Configuration ────────────────────────────────────────
@dataclass
class MqttBrokerConfig:
"""Configuration for a single MQTT broker endpoint."""
name: str = "letsmesh-eu"
server: str = "mqtt-eu-v1.letsmesh.net"
port: int = 443
transport: str = "websockets"
tls: bool = True
enabled: bool = True
@classmethod
def from_dict(cls, data: dict) -> "MqttBrokerConfig":
return cls(
name=str(data.get("name", "letsmesh-eu")),
server=str(data.get("server", "mqtt-eu-v1.letsmesh.net")),
port=int(data.get("port", 443)),
transport=str(data.get("transport", "websockets")),
tls=bool(data.get("tls", True)),
enabled=bool(data.get("enabled", True)),
)
@dataclass
class MqttConfig:
"""MQTT uplink configuration.
Attributes:
enabled: Master MQTT enable switch (default OFF).
iata: 3-letter IATA airport code for topic path.
brokers: List of broker endpoints.
device_identity_file: Path to meshcore_gui device identity JSON.
private_key: Ed25519 private key (hex) — from config.
private_key_file: Path to file containing private key.
public_key: Device public key (hex) — for topics/auth.
device_name: Device display name for ``origin`` field.
upload_packet_types: Packet type filter (empty = all).
status_interval_s: Seconds between status republish.
reconnect_delay_s: Seconds between reconnect attempts.
max_reconnect_retries: Max reconnect retries (0 = infinite).
token_lifetime_s: JWT token validity in seconds.
dry_run: Log payloads but do not publish.
"""
enabled: bool = False
iata: str = "AMS"
brokers: List[MqttBrokerConfig] = field(default_factory=list)
device_identity_file: str = ""
private_key: str = ""
private_key_file: str = ""
public_key: str = ""
device_name: str = ""
upload_packet_types: List[int] = field(default_factory=list)
status_interval_s: int = 300
reconnect_delay_s: int = 10
max_reconnect_retries: int = 0
token_lifetime_s: int = 3600
dry_run: bool = False
# Cached identity data (not serialised)
_identity_cache: Optional[dict] = field(
default=None, repr=False, compare=False,
)
@classmethod
def from_dict(cls, data: dict) -> "MqttConfig":
brokers_raw = data.get("brokers", [])
brokers = [MqttBrokerConfig.from_dict(b) for b in brokers_raw]
return cls(
enabled=bool(data.get("enabled", False)),
iata=str(data.get("iata", "AMS")),
brokers=brokers,
device_identity_file=str(data.get("device_identity_file", "")),
private_key=str(data.get("private_key", "")),
private_key_file=str(data.get("private_key_file", "")),
public_key=str(data.get("public_key", "")),
device_name=str(data.get("device_name", "")),
upload_packet_types=list(data.get("upload_packet_types", [])),
status_interval_s=int(data.get("status_interval_s", 300)),
reconnect_delay_s=int(data.get("reconnect_delay_s", 10)),
max_reconnect_retries=int(data.get("max_reconnect_retries", 0)),
token_lifetime_s=int(data.get("token_lifetime_s", 3600)),
dry_run=bool(data.get("dry_run", False)),
)
# ── Device identity loading ──────────────────────────────────────
def _load_device_identity(self) -> Optional[dict]:
"""Load device identity JSON written by meshcore_gui.
Checks (in order):
1. ``device_identity_file`` from config (explicit path)
2. Default path ``~/.meshcore-gui/device_identity.json``
Accepts private keys in both 64-char (legacy seed) and 128-char
(orlp expanded) formats.
Returns:
Dict with ``public_key`` and ``private_key``, or None.
"""
if self._identity_cache is not None:
return self._identity_cache
paths_to_try = []
if self.device_identity_file:
paths_to_try.append(Path(self.device_identity_file).expanduser())
paths_to_try.append(DEFAULT_DEVICE_IDENTITY_PATH)
for id_path in paths_to_try:
if not id_path.exists():
continue
try:
data = json.loads(id_path.read_text(encoding="utf-8"))
pub = data.get("public_key", "")
priv = data.get("private_key", "")
if (_is_valid_key(pub, (VALID_PUBLIC_KEY_LENGTH,))
and _is_valid_key(priv, VALID_PRIVATE_KEY_LENGTHS)):
logger.info(
"Loaded device identity from %s "
"(key=%s..., priv=%d chars)",
id_path, pub[:12], len(priv),
)
self._identity_cache = data
return data
logger.warning(
"Device identity file %s has invalid key lengths "
"(pub=%d, priv=%d)",
id_path, len(pub), len(priv),
)
except (json.JSONDecodeError, OSError) as exc:
logger.warning(
"Cannot read device identity file %s: %s",
id_path, exc,
)
self._identity_cache = {} # Mark as tried (empty = not found)
return None
# ── Key resolution (Single Responsibility) ───────────────────────
def resolve_private_key(self) -> str:
"""Resolve the private key.
Priority:
1. ``MESHCORE_PRIVATE_KEY`` environment variable
2. Device identity file (auto from meshcore_gui)
3. ``private_key_file`` config path
4. ``private_key`` config value
Returns:
Hex private key string (64 or 128 chars), or empty string.
"""
# Priority 1: environment variable
env_key = os.environ.get("MESHCORE_PRIVATE_KEY", "").strip()
if env_key:
logger.debug("Using private key from MESHCORE_PRIVATE_KEY env var")
return env_key
# Priority 2: device identity file (written by meshcore_gui)
identity = self._load_device_identity()
if identity and identity.get("private_key"):
logger.debug("Using private key from device identity file")
return identity["private_key"]
# Priority 3: key file
if self.private_key_file:
key_path = Path(self.private_key_file).expanduser()
if key_path.exists():
try:
key_data = key_path.read_text(encoding="utf-8").strip()
if key_data:
logger.debug("Using private key from file: %s", key_path)
return key_data
except OSError as exc:
logger.error("Cannot read private key file %s: %s", key_path, exc)
else:
logger.warning("Private key file not found: %s", key_path)
# Priority 4: inline config value
if self.private_key:
logger.warning(
"Using private key from plain-text config — "
"consider using private_key_file or MESHCORE_PRIVATE_KEY env var instead"
)
return self.private_key
return ""
def resolve_public_key(self) -> str:
"""Resolve the public key.
Priority:
1. ``MESHCORE_PUBLIC_KEY`` environment variable
2. Device identity file (auto from meshcore_gui)
3. ``public_key`` config value
Returns:
64-char hex public key string, or empty string.
"""
env_key = os.environ.get("MESHCORE_PUBLIC_KEY", "").strip()
if env_key:
return env_key
identity = self._load_device_identity()
if identity and identity.get("public_key"):
logger.debug("Using public key from device identity file")
return identity["public_key"]
if self.public_key:
return self.public_key
return ""
def resolve_device_name(self) -> str:
"""Resolve the device display name.
Returns:
Device name string, or ``"MeshCore Observer"`` as default.
"""
if self.device_name:
return self.device_name
identity = self._load_device_identity()
if identity and identity.get("device_name"):
return identity["device_name"]
return "MeshCore Observer"
# ── Validation ───────────────────────────────────────────────────
def validate(self) -> List[str]:
"""Validate MQTT configuration and return list of errors."""
errors: List[str] = []
if not self.enabled:
return errors
# IATA code
if not self.iata or len(self.iata) != 3 or not self.iata.isalpha():
errors.append(
f"IATA code must be exactly 3 letters, got: '{self.iata}'"
)
# Public key
pub = self.resolve_public_key()
if not pub:
errors.append("Public key is required for MQTT authentication")
elif not _is_valid_key(pub, (VALID_PUBLIC_KEY_LENGTH,)):
errors.append(
f"Public key must be 64 hex chars, got {len(pub)}"
)
# Private key — accepts both 64 (seed) and 128 (expanded)
priv = self.resolve_private_key()
if not priv:
errors.append(
"Private key is required for MQTT authentication "
"(set via config, file, or MESHCORE_PRIVATE_KEY env var)"
)
elif not _is_valid_key(priv, VALID_PRIVATE_KEY_LENGTHS):
errors.append(
f"Private key must be 64 or 128 hex chars, got {len(priv)}"
)
# At least one enabled broker
enabled_brokers = [b for b in self.brokers if b.enabled]
if not enabled_brokers:
errors.append("At least one broker must be enabled")
return errors
# ── Main Observer Configuration ──────────────────────────────────────
@dataclass
class ObserverConfig:
"""Complete observer daemon configuration."""
archive_dir: str = str(Path.home() / ".meshcore-gui" / "archive")
poll_interval_s: float = 2.0
max_messages_display: int = 100
max_rxlog_display: int = 50
gui_port: int = 9093
gui_title: str = "MeshCore Observer"
debug: bool = False
config_path: str = ""
mqtt: MqttConfig = field(default_factory=MqttConfig)
@classmethod
def from_yaml(cls, path: Path) -> "ObserverConfig":
"""Load configuration from a YAML file.
Missing keys fall back to dataclass defaults.
"""
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
observer_section = raw.get("observer", {})
gui_section = raw.get("gui", {})
mqtt_section = raw.get("mqtt", {})
archive_raw = observer_section.get(
"archive_dir",
str(Path.home() / ".meshcore-gui" / "archive"),
)
archive_dir = str(Path(archive_raw).expanduser().resolve())
mqtt_cfg = MqttConfig.from_dict(mqtt_section) if mqtt_section else MqttConfig()
return cls(
archive_dir=archive_dir,
poll_interval_s=float(observer_section.get("poll_interval_s", 2.0)),
max_messages_display=int(observer_section.get("max_messages_display", 100)),
max_rxlog_display=int(observer_section.get("max_rxlog_display", 50)),
gui_port=int(gui_section.get("port", 9093)),
gui_title=str(gui_section.get("title", "MeshCore Observer")),
mqtt=mqtt_cfg,
)

View File

@@ -1 +0,0 @@
"""Observer GUI package."""

View File

@@ -1,233 +0,0 @@
"""
Observer dashboard — NiceGUI page with DOMCA theme.
Thin orchestrator that owns the layout, injects the DOMCA theme,
and runs a periodic update timer that polls the ArchiveWatcher
and refreshes all panels. Visually consistent with the
meshcore_gui and meshcore_bridge dashboards.
"""
from nicegui import ui
from meshcore_observer import __version__
from meshcore_observer.archive_watcher import ArchiveWatcher
from meshcore_observer.config import ObserverConfig
from meshcore_observer.gui.panels.sources_panel import SourcesPanel
from meshcore_observer.gui.panels.messages_panel import MessagesPanel
from meshcore_observer.gui.panels.rxlog_panel import RxLogPanel
from meshcore_observer.gui.panels.stats_panel import StatsPanel
from meshcore_observer.gui.panels.mqtt_panel import MqttPanel
# ── DOMCA Theme (identical to meshcore_bridge dashboard) ─────────────
_DOMCA_HEAD = '''
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ── DOMCA theme variables (dark) ── */
body.body--dark {
--bg: #0A1628;
--title: #48CAE4; --subtitle: #48CAE4;
}
/* ── DOMCA theme variables (light) ── */
body.body--light {
--bg: #FFFFFF;
--title: #0077B6; --subtitle: #0077B6;
}
/* ── DOMCA page background ── */
body.body--dark { background: #0A1628 !important; }
body.body--light { background: #f4f8fb !important; }
body.body--dark .q-page { background: #0A1628 !important; }
body.body--light .q-page { background: #f4f8fb !important; }
/* ── DOMCA header ── */
body.body--dark .q-header { background: #0d1f35 !important; }
body.body--light .q-header { background: #0077B6 !important; }
/* ── DOMCA cards — dark mode readable ── */
body.body--dark .q-card {
background: #112240 !important;
color: #e0f0f8 !important;
border: 1px solid rgba(0,119,182,0.15) !important;
}
body.body--dark .q-card .text-gray-600 { color: #48CAE4 !important; }
body.body--dark .q-card .text-xs { color: #c0dce8 !important; }
body.body--dark .q-card .text-sm { color: #d0e8f2 !important; }
/* ── Dark mode: fields ── */
body.body--dark .q-field__control { background: #0c1a2e !important; color: #e0f0f8 !important; }
body.body--dark .q-field__native { color: #e0f0f8 !important; }
/* ── Observer-specific ── */
.observer-header-text {
font-family: 'JetBrains Mono', monospace;
color: white;
}
</style>
'''
class ObserverDashboard:
"""Observer dashboard page.
Provides a NiceGUI-based monitoring view showing aggregated
messages, RX log entries, archive sources, statistics, and
MQTT uplink status from all detected archive files.
Args:
watcher: ArchiveWatcher for polling archive files.
config: ObserverConfig for display settings.
mqtt_uplink: MqttUplink instance (or None if MQTT disabled).
"""
def __init__(
self,
watcher: ArchiveWatcher,
config: ObserverConfig,
mqtt_uplink=None,
) -> None:
self._watcher = watcher
self._cfg = config
self._mqtt_uplink = mqtt_uplink
# Panels (created in render)
self._sources: SourcesPanel | None = None
self._messages: MessagesPanel | None = None
self._rxlog: RxLogPanel | None = None
self._stats: StatsPanel | None = None
self._mqtt: MqttPanel | None = None
# Header status label
self._header_status = None
def render(self) -> None:
"""Build the complete observer dashboard layout and start the timer."""
# Create panel instances
self._sources = SourcesPanel(self._watcher)
self._messages = MessagesPanel(self._cfg.max_messages_display)
self._rxlog = RxLogPanel(self._cfg.max_rxlog_display)
self._stats = StatsPanel(self._watcher)
self._mqtt = MqttPanel(self._mqtt_uplink)
# Inject DOMCA theme
ui.add_head_html(_DOMCA_HEAD)
# Default to dark mode
dark = ui.dark_mode(True)
# ── Header ────────────────────────────────────────────────
with ui.header().classes("items-center px-4 py-2 shadow-md"):
ui.icon("visibility").classes("text-white text-2xl")
ui.label(
f"MeshCore Observer v{__version__}"
).classes("text-lg font-bold ml-2 observer-header-text")
ui.space()
self._header_status = ui.label("Scanning...").classes(
"text-sm opacity-70 observer-header-text"
)
ui.button(
icon="brightness_6",
on_click=lambda: dark.toggle(),
).props("flat round dense color=white").tooltip("Toggle dark / light")
# ── Main Content ──────────────────────────────────────────
with ui.column().classes("w-full max-w-6xl mx-auto p-4 gap-4"):
# Config summary
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("settings", color="primary").classes("text-lg")
ui.label("Configuration").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
with ui.row().classes("gap-4 flex-wrap"):
config_items = [
("Archive dir", self._cfg.archive_dir),
("Poll interval", f"{self._cfg.poll_interval_s}s"),
("Max messages", str(self._cfg.max_messages_display)),
("Max RX log", str(self._cfg.max_rxlog_display)),
]
# MQTT status in config summary
if self._cfg.mqtt.enabled:
mode = "DRY RUN" if self._cfg.mqtt.dry_run else "LIVE"
config_items.append(("MQTT", f"{mode} ({self._cfg.mqtt.iata})"))
else:
config_items.append(("MQTT", "Disabled"))
for lbl, val in config_items:
with ui.column().classes("gap-0"):
ui.label(lbl).classes("text-xs opacity-50")
ui.label(val).classes("text-xs font-bold").style(
"font-family: 'JetBrains Mono', monospace"
)
# Top row: Sources + Stats + MQTT
with ui.row().classes("w-full gap-4 flex-wrap"):
with ui.column().classes("flex-1 min-w-[300px]"):
self._sources.render()
with ui.column().classes("flex-1 min-w-[280px]"):
self._stats.render()
# MQTT panel (full width, only if enabled or for status)
self._mqtt.render()
# Messages panel (full width)
self._messages.render()
# RX log panel (full width)
self._rxlog.render()
# ── Update timer ──────────────────────────────────────────
ui.timer(self._cfg.poll_interval_s, self._on_timer)
def _on_timer(self) -> None:
"""Periodic UI update callback — poll watcher, publish MQTT, refresh panels."""
result = self._watcher.poll()
# Feed new data to panels
if result.new_messages and self._messages:
self._messages.add_messages(result.new_messages)
if result.new_rxlog and self._rxlog:
self._rxlog.add_entries(result.new_rxlog)
# Publish new RX log entries to MQTT (if enabled)
if result.new_rxlog and self._mqtt_uplink:
try:
self._mqtt_uplink.publish_entries(result.new_rxlog)
except Exception as exc:
import logging
logging.getLogger(__name__).error(
"MQTT publish error: %s", exc,
)
# Update header status
stats = self._watcher.get_stats()
n_sources = stats["active_sources"]
n_msg = stats["total_messages_seen"]
n_rx = stats["total_rxlog_seen"]
if n_sources > 0:
status = f"{n_sources} source{'s' if n_sources != 1 else ''}{n_msg} msg / {n_rx} rx"
else:
status = "⏳ Waiting for archive files..."
if self._header_status:
self._header_status.set_text(status)
# Refresh all panels
if self._sources:
self._sources.update()
if self._messages:
self._messages.update()
if self._rxlog:
self._rxlog.update()
if self._stats:
self._stats.update()
if self._mqtt:
self._mqtt.update()

View File

@@ -1 +0,0 @@
"""Observer dashboard panels."""

View File

@@ -1,187 +0,0 @@
"""
Messages panel — aggregated message feed from all archive sources.
Displays messages sorted by ``timestamp_utc`` (newest first),
with optional source and channel filtering.
"""
from typing import Dict, List, Optional, Tuple
from nicegui import ui
class MessagesPanel:
"""Aggregated message feed panel.
Maintains an in-memory buffer of messages from all sources,
sorted by UTC timestamp.
Args:
max_display: Maximum number of messages to display.
"""
def __init__(self, max_display: int = 100) -> None:
self._max_display = max_display
self._messages: List[dict] = []
self._table: Optional[ui.table] = None
# Filters
self._source_filter: str = ""
self._channel_filter: str = ""
self._source_select: Optional[ui.select] = None
self._channel_select: Optional[ui.select] = None
def render(self) -> None:
"""Build the messages panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("chat", color="primary").classes("text-lg")
ui.label("Messages").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
ui.space()
# Source filter (REQ-10)
self._source_select = ui.select(
options={"": "All sources"},
value="",
on_change=lambda e: self._on_source_filter(e.value),
).props("dense outlined").classes("text-xs min-w-[140px]")
# Channel filter (REQ-11)
self._channel_select = ui.select(
options={"": "All channels"},
value="",
on_change=lambda e: self._on_channel_filter(e.value),
).props("dense outlined").classes("text-xs min-w-[140px]")
self._table = ui.table(
columns=[
{"name": "time", "label": "Time", "field": "time",
"align": "left", "sortable": True},
{"name": "source", "label": "Source", "field": "source",
"align": "left"},
{"name": "channel", "label": "Channel", "field": "channel",
"align": "left"},
{"name": "sender", "label": "Sender", "field": "sender",
"align": "left"},
{"name": "text", "label": "Text", "field": "text",
"align": "left",
"classes": "msg-text-cell",
"headerClasses": "msg-text-header"},
{"name": "snr", "label": "SNR", "field": "snr",
"align": "right"},
{"name": "hops", "label": "Hops", "field": "hops",
"align": "right"},
],
rows=[],
).props("dense flat").classes("w-full text-xs")
ui.add_css("""
.msg-text-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.msg-text-header { max-width: 300px; }
""")
# ------------------------------------------------------------------
# Data ingestion
# ------------------------------------------------------------------
def add_messages(self, new_messages: List[Tuple[str, dict]]) -> None:
"""Add new messages from a poll result.
Args:
new_messages: List of (source_address, message_dict) tuples.
"""
for _source, msg in new_messages:
self._messages.append(msg)
# Sort by timestamp (newest first) and trim
self._messages.sort(
key=lambda m: m.get("timestamp_utc", ""),
reverse=True,
)
self._messages = self._messages[:self._max_display * 2]
# ------------------------------------------------------------------
# Update UI
# ------------------------------------------------------------------
def update(self) -> None:
"""Refresh the messages table with current filter state."""
if not self._table:
return
# Update filter dropdowns with discovered values
self._update_filter_options()
# Apply filters
filtered = self._messages
if self._source_filter:
filtered = [m for m in filtered if m.get("_source") == self._source_filter]
if self._channel_filter:
filtered = [m for m in filtered if m.get("channel_name") == self._channel_filter]
display = filtered[:self._max_display]
rows = [
{
"time": m.get("time", m.get("timestamp_utc", "")[:19]),
"source": self._short_source(m.get("_source", "")),
"channel": m.get("channel_name", m.get("channel", "-")),
"sender": m.get("sender", ""),
"text": m.get("text", ""),
"snr": f"{m['snr']:.1f}" if m.get("snr") is not None else "-",
"hops": str(m.get("path_len", 0)),
}
for m in display
]
self._table.rows = rows
self._table.update()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _on_source_filter(self, value: str) -> None:
"""Handle source filter change."""
self._source_filter = value
self.update()
def _on_channel_filter(self, value: str) -> None:
"""Handle channel filter change."""
self._channel_filter = value
self.update()
def _update_filter_options(self) -> None:
"""Update dropdown options from current message data."""
if self._source_select:
sources = sorted({m.get("_source", "") for m in self._messages if m.get("_source")})
options = {"": "All sources"}
options.update({s: self._short_source(s) for s in sources})
self._source_select.options = options
self._source_select.update()
if self._channel_select:
channels = sorted({m.get("channel_name", "") for m in self._messages if m.get("channel_name")})
options = {"": "All channels"}
options.update({c: c for c in channels})
self._channel_select.options = options
self._channel_select.update()
@staticmethod
def _short_source(address: str) -> str:
"""Shorten a source address for display."""
if not address:
return "-"
# Remove common prefixes for readability
short = address
for prefix in ("bridge_a_", "bridge_b_", "_dev_"):
if short.startswith(prefix):
short = short[len(prefix):]
return short[:20] if len(short) > 20 else short

View File

@@ -1,165 +0,0 @@
"""
MQTT status panel — broker connection status and publish statistics.
Displays MQTT uplink health: per-broker connection state, packet
counters, filter configuration, and any errors. Updates in real-time
via the dashboard timer.
"""
from typing import Dict, Optional
from nicegui import ui
class MqttPanel:
"""MQTT uplink status panel.
Args:
mqtt_uplink: MqttUplink instance (or None if MQTT disabled).
"""
def __init__(self, mqtt_uplink=None) -> None:
self._uplink = mqtt_uplink
# UI element references
self._status_icon: Optional[ui.icon] = None
self._status_label: Optional[ui.label] = None
self._topic_label: Optional[ui.label] = None
self._filter_label: Optional[ui.label] = None
self._published_label: Optional[ui.label] = None
self._filtered_label: Optional[ui.label] = None
self._skipped_label: Optional[ui.label] = None
self._brokers_container: Optional[ui.column] = None
def render(self) -> None:
"""Build the MQTT status panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("cell_tower", color="primary").classes("text-lg")
ui.label("MQTT Uplink").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
ui.space()
self._status_icon = ui.icon("circle").classes("text-sm")
self._status_label = ui.label("").classes("text-xs")
if self._uplink is None:
ui.label("MQTT is disabled in configuration.").classes(
"text-xs opacity-40 py-1"
)
return
with ui.column().classes("gap-1 w-full"):
# Topic info
with ui.row().classes("items-center gap-2"):
ui.label("Topic:").classes("text-xs opacity-60 w-24")
self._topic_label = ui.label("-").classes(
"text-xs font-bold"
).style("font-family: 'JetBrains Mono', monospace")
# Filter info
with ui.row().classes("items-center gap-2"):
ui.label("Filter:").classes("text-xs opacity-60 w-24")
self._filter_label = ui.label("-").classes("text-xs")
# Counters
with ui.row().classes("items-center gap-2"):
ui.label("Published:").classes("text-xs opacity-60 w-24")
self._published_label = ui.label("0").classes(
"text-xs font-bold"
)
with ui.row().classes("items-center gap-2"):
ui.label("Filtered:").classes("text-xs opacity-60 w-24")
self._filtered_label = ui.label("0").classes("text-xs")
with ui.row().classes("items-center gap-2"):
ui.label("Skipped:").classes("text-xs opacity-60 w-24")
self._skipped_label = ui.label("0").classes("text-xs")
ui.separator().classes("my-2")
with ui.row().classes("items-center gap-2 mb-1"):
ui.icon("dns", color="primary").classes("text-sm")
ui.label("Brokers").classes("text-xs font-bold")
self._brokers_container = ui.column().classes("gap-1 w-full")
def update(self) -> None:
"""Refresh MQTT status from uplink instance."""
if self._uplink is None:
return
status = self._uplink.get_status()
# Global status indicator
if self._status_icon and self._status_label:
if status.get("dry_run"):
self._status_icon.props("color=yellow")
self._status_label.set_text("DRY RUN")
elif any(b["connected"] for b in status.get("brokers", [])):
self._status_icon.props("color=green")
self._status_label.set_text("Connected")
elif status.get("started"):
self._status_icon.props("color=orange")
self._status_label.set_text("Connecting...")
else:
self._status_icon.props("color=red")
self._status_label.set_text("Disconnected")
# Topic
if self._topic_label:
self._topic_label.set_text(status.get("topic_base", "-"))
# Filter
if self._filter_label:
filt = status.get("upload_filter", "ALL")
if isinstance(filt, list):
self._filter_label.set_text(", ".join(filt))
else:
self._filter_label.set_text(str(filt))
# Counters
if self._published_label:
self._published_label.set_text(str(status.get("total_published", 0)))
if self._filtered_label:
self._filtered_label.set_text(str(status.get("total_filtered", 0)))
if self._skipped_label:
self._skipped_label.set_text(
f"{status.get('total_skipped_no_raw', 0)} (no raw_payload)"
)
# Per-broker status
if self._brokers_container:
self._brokers_container.clear()
with self._brokers_container:
brokers = status.get("brokers", [])
if not brokers:
ui.label("No brokers configured.").classes(
"text-xs opacity-40 py-1"
)
else:
for b in brokers:
with ui.row().classes("items-center gap-2 py-0.5"):
# Connection dot
color = "green" if b["connected"] else "red"
ui.icon("circle", color=color).classes("text-xs")
ui.label(b["name"]).classes(
"text-xs opacity-70 w-28"
)
ui.label(f"{b['packets_published']} pkts").classes(
"text-xs w-20"
)
ui.label(
b.get("last_publish_time", "-")[-8:]
).classes("text-xs opacity-50 w-20")
# Show error if present
if b.get("last_error"):
with ui.row().classes("pl-6"):
ui.label(
f"{b['last_error']}"
).classes("text-xs text-red-400")

View File

@@ -1,155 +0,0 @@
"""
RX log panel — aggregated RX log entries from all archive sources.
Displays entries sorted by ``timestamp_utc`` (newest first),
with source tagging. Filters follow the source filter from the
messages panel.
"""
from typing import Dict, List, Optional, Tuple
from nicegui import ui
class RxLogPanel:
"""Aggregated RX log table panel.
Args:
max_display: Maximum number of entries to display.
"""
def __init__(self, max_display: int = 50) -> None:
self._max_display = max_display
self._entries: List[dict] = []
self._table: Optional[ui.table] = None
self._source_filter: str = ""
def render(self) -> None:
"""Build the RX log panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("radio", color="primary").classes("text-lg")
ui.label("RX Log").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
self._table = ui.table(
columns=[
{"name": "time", "label": "Time", "field": "time",
"align": "left"},
{"name": "source", "label": "Source", "field": "source",
"align": "left"},
{"name": "snr", "label": "SNR", "field": "snr",
"align": "right"},
{"name": "rssi", "label": "RSSI", "field": "rssi",
"align": "right"},
{"name": "type", "label": "Type", "field": "type",
"align": "left"},
{"name": "hops", "label": "Hops", "field": "hops",
"align": "right"},
{"name": "path", "label": "Path", "field": "path",
"align": "left",
"classes": "rxlog-path-cell",
"headerClasses": "rxlog-path-header"},
{"name": "hash", "label": "Hash", "field": "hash",
"align": "left"},
],
rows=[],
).props("dense flat").classes("w-full text-xs")
ui.add_css("""
.rxlog-path-cell {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rxlog-path-header { max-width: 180px; }
""")
# ------------------------------------------------------------------
# Data ingestion
# ------------------------------------------------------------------
def add_entries(self, new_entries: List[Tuple[str, dict]]) -> None:
"""Add new RX log entries from a poll result.
Args:
new_entries: List of (source_address, entry_dict) tuples.
"""
for _source, entry in new_entries:
self._entries.append(entry)
# Sort by timestamp (newest first) and trim
self._entries.sort(
key=lambda e: e.get("timestamp_utc", ""),
reverse=True,
)
self._entries = self._entries[:self._max_display * 2]
# ------------------------------------------------------------------
# Filter (shared with messages panel)
# ------------------------------------------------------------------
def set_source_filter(self, source: str) -> None:
"""Apply a source filter (called by dashboard orchestrator)."""
self._source_filter = source
# ------------------------------------------------------------------
# Update UI
# ------------------------------------------------------------------
def update(self) -> None:
"""Refresh the RX log table."""
if not self._table:
return
filtered = self._entries
if self._source_filter:
filtered = [e for e in filtered if e.get("_source") == self._source_filter]
display = filtered[:self._max_display]
rows = [
{
"time": e.get("time", e.get("timestamp_utc", "")[:19]),
"source": self._short_source(e.get("_source", "")),
"snr": f"{e['snr']:.1f}" if isinstance(e.get("snr"), (int, float)) else "-",
"rssi": f"{e['rssi']:.0f}" if isinstance(e.get("rssi"), (int, float)) else "-",
"type": e.get("payload_type", "?"),
"hops": str(e.get("hops", 0)),
"path": self._build_path(e),
"hash": (e.get("message_hash") or "")[:12],
}
for e in display
]
self._table.rows = rows
self._table.update()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _build_path(entry: dict) -> str:
"""Build a display path: Sender → [repeaters →] Receiver."""
parts: list = []
if entry.get("sender"):
parts.append(entry["sender"])
path_names = entry.get("path_names", [])
if path_names:
parts.extend(path_names)
if entry.get("receiver"):
parts.append(entry["receiver"])
return "".join(parts) if parts else "-"
@staticmethod
def _short_source(address: str) -> str:
"""Shorten a source address for display."""
if not address:
return "-"
short = address
for prefix in ("bridge_a_", "bridge_b_", "_dev_"):
if short.startswith(prefix):
short = short[len(prefix):]
return short[:20] if len(short) > 20 else short

View File

@@ -1,65 +0,0 @@
"""
Sources panel — table of discovered archive file sources.
Shows all ``*_messages.json`` files found in the archive directory
with per-source metadata: address, file path, entry counts.
"""
from typing import Dict, List, Optional
from nicegui import ui
from meshcore_observer.archive_watcher import ArchiveWatcher
class SourcesPanel:
"""Archive sources overview panel.
Args:
watcher: ArchiveWatcher instance for source metadata.
"""
def __init__(self, watcher: ArchiveWatcher) -> None:
self._watcher = watcher
self._table: Optional[ui.table] = None
def render(self) -> None:
"""Build the sources panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("storage", color="primary").classes("text-lg")
ui.label("Archive Sources").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
self._table = ui.table(
columns=[
{"name": "address", "label": "Source", "field": "address",
"align": "left"},
{"name": "messages", "label": "Messages", "field": "messages",
"align": "right"},
{"name": "rxlog", "label": "RX Log", "field": "rxlog",
"align": "right"},
{"name": "path", "label": "File", "field": "path",
"align": "left"},
],
rows=[],
).props("dense flat").classes("w-full text-xs")
def update(self) -> None:
"""Refresh sources table from watcher state."""
if not self._table:
return
sources = self._watcher.get_sources()
rows = [
{
"address": s["address"],
"messages": str(s["message_count"]),
"rxlog": str(s["rxlog_count"]),
"path": s["path"].split("/")[-1] if "/" in s["path"] else s["path"],
}
for s in sources
]
self._table.rows = rows
self._table.update()

View File

@@ -1,105 +0,0 @@
"""
Statistics panel — observer uptime and aggregate counters.
Displays observer process uptime, total messages and RX log entries
seen, number of active sources, and per-source breakdown.
"""
import time
from typing import Dict, Optional
from nicegui import ui
from meshcore_observer.archive_watcher import ArchiveWatcher
class StatsPanel:
"""Observer statistics panel.
Args:
watcher: ArchiveWatcher instance for aggregate stats.
"""
def __init__(self, watcher: ArchiveWatcher) -> None:
self._watcher = watcher
self._start_time = time.monotonic()
# UI element references
self._uptime_label: Optional[ui.label] = None
self._total_msg_label: Optional[ui.label] = None
self._total_rxlog_label: Optional[ui.label] = None
self._sources_label: Optional[ui.label] = None
self._breakdown_container: Optional[ui.column] = None
def render(self) -> None:
"""Build the statistics panel UI."""
with ui.card().classes("w-full"):
with ui.row().classes("items-center gap-2 mb-2"):
ui.icon("analytics", color="primary").classes("text-lg")
ui.label("Observer Statistics").classes(
"text-sm font-bold"
).style("font-family: 'JetBrains Mono', monospace")
with ui.column().classes("gap-1"):
with ui.row().classes("items-center gap-2"):
ui.label("Uptime:").classes("text-xs opacity-60 w-32")
self._uptime_label = ui.label("0s").classes("text-xs font-bold")
with ui.row().classes("items-center gap-2"):
ui.label("Total messages:").classes("text-xs opacity-60 w-32")
self._total_msg_label = ui.label("0").classes("text-xs font-bold")
with ui.row().classes("items-center gap-2"):
ui.label("Total RX log:").classes("text-xs opacity-60 w-32")
self._total_rxlog_label = ui.label("0").classes("text-xs font-bold")
with ui.row().classes("items-center gap-2"):
ui.label("Active sources:").classes("text-xs opacity-60 w-32")
self._sources_label = ui.label("0").classes("text-xs font-bold")
ui.separator().classes("my-2")
with ui.row().classes("items-center gap-2 mb-1"):
ui.icon("list", color="primary").classes("text-sm")
ui.label("Per Source").classes("text-xs font-bold")
self._breakdown_container = ui.column().classes("gap-0 w-full")
def update(self) -> None:
"""Refresh all statistics labels."""
stats = self._watcher.get_stats()
# Uptime
if self._uptime_label:
elapsed = int(time.monotonic() - self._start_time)
h, rem = divmod(elapsed, 3600)
m, s = divmod(rem, 60)
self._uptime_label.set_text(
f"{h}h {m}m {s}s" if h else f"{m}m {s}s"
)
if self._total_msg_label:
self._total_msg_label.set_text(str(stats["total_messages_seen"]))
if self._total_rxlog_label:
self._total_rxlog_label.set_text(str(stats["total_rxlog_seen"]))
if self._sources_label:
self._sources_label.set_text(str(stats["active_sources"]))
# Per-source breakdown
if self._breakdown_container:
sources = self._watcher.get_sources()
self._breakdown_container.clear()
with self._breakdown_container:
if not sources:
ui.label("No sources detected yet.").classes(
"text-xs opacity-40 py-1"
)
else:
for src in sources:
addr = src["address"]
msg_c = src["message_count"]
rxlog_c = src["rxlog_count"]
with ui.row().classes("items-center gap-2 py-0.5"):
ui.label(addr).classes("text-xs opacity-70 w-48 truncate")
ui.label(f"{msg_c} msg").classes("text-xs w-16")
ui.label(f"{rxlog_c} rx").classes("text-xs w-16")

View File

@@ -1,563 +0,0 @@
"""
MQTT uplink — publishes RX log packets to LetsMesh analyzer.
Manages paho-mqtt client(s) with WebSocket+TLS transport, Ed25519 JWT
authentication, status topics with LWT, and privacy-configurable
packet type filtering.
Lifecycle::
uplink = MqttUplink(mqtt_config, debug=True)
uplink.start() # Connect to all enabled brokers
uplink.publish_entries(new_rxlog) # Called from timer loop
uplink.shutdown() # Graceful disconnect
Thread safety: paho-mqtt ``loop_start()`` runs its own network thread.
``publish_entries()`` is safe to call from the NiceGUI timer thread.
Author: PE1HVH
Version: 1.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import json
import logging
import ssl
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple
from meshcore_observer.config import MqttBrokerConfig, MqttConfig
from meshcore_observer.auth_token import TokenManager
logger = logging.getLogger(__name__)
# Packet type name lookup (for debug logging)
PACKET_TYPE_NAMES = {
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
5: "GRP_TXT", 6: "GRP_DATA", 7: "ANON_REQ", 8: "PATH", 9: "TRACE",
}
@dataclass
class BrokerState:
"""Runtime state for a single broker connection.
Attributes:
config: Broker configuration.
client: paho-mqtt client instance.
connected: Whether currently connected.
packets_published: Total packets published to this broker.
last_publish_time: Timestamp of last successful publish.
last_error: Last error message, if any.
reconnect_count: Number of reconnection attempts.
"""
config: MqttBrokerConfig
client: object = None # paho.mqtt.client.Client
connected: bool = False
packets_published: int = 0
last_publish_time: Optional[str] = None
last_error: str = ""
reconnect_count: int = 0
class MqttUplink:
"""MQTT uplink to LetsMesh analyzer brokers.
Manages one or more paho-mqtt clients, each connecting to a
configured broker via WebSocket+TLS. Publishes RX log entries
as LetsMesh-compatible JSON payloads on ``meshcore/{IATA}/{KEY}/packets``.
Args:
mqtt_config: Validated MqttConfig instance.
debug: Enable verbose debug logging.
"""
def __init__(self, mqtt_config: MqttConfig, debug: bool = False) -> None:
self._cfg = mqtt_config
self._debug = debug
self._lock = threading.Lock()
# Resolved identity
self._public_key = mqtt_config.resolve_public_key().upper()
self._private_key = mqtt_config.resolve_private_key()
self._device_name = mqtt_config.resolve_device_name()
self._iata = mqtt_config.iata.upper()
# Token manager
self._token_mgr = TokenManager(
self._public_key,
self._private_key,
mqtt_config.token_lifetime_s,
)
# Topic paths
self._topic_base = f"meshcore/{self._iata}/{self._public_key}"
self._topic_packets = f"{self._topic_base}/packets"
self._topic_status = f"{self._topic_base}/status"
# Privacy filter
self._allowed_types: Optional[set] = None
if mqtt_config.upload_packet_types:
self._allowed_types = set(mqtt_config.upload_packet_types)
# Broker states
self._brokers: Dict[str, BrokerState] = {}
# Aggregate stats
self._total_published: int = 0
self._total_filtered: int = 0
self._total_skipped_no_raw: int = 0
self._started: bool = False
# Status republish tracking
self._last_status_time: float = 0.0
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self) -> None:
"""Connect to all enabled brokers and publish online status.
Creates paho-mqtt clients, configures auth, TLS, LWT, and
starts the network loop. Non-blocking — network threads run
in the background.
"""
try:
import paho.mqtt.client as paho_mqtt
except ImportError:
logger.error(
"paho-mqtt is required for MQTT uplink. "
"Install with: pip install paho-mqtt"
)
return
enabled_brokers = [b for b in self._cfg.brokers if b.enabled]
if not enabled_brokers:
logger.warning("MQTT enabled but no brokers configured")
return
for broker_cfg in enabled_brokers:
state = BrokerState(config=broker_cfg)
# Client ID
client_id = f"meshcore_observer_{self._public_key[:8]}"
if len(enabled_brokers) > 1:
client_id += f"_{broker_cfg.name}"
try:
# paho-mqtt v2.x API
client = paho_mqtt.Client(
callback_api_version=paho_mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
transport=broker_cfg.transport,
protocol=paho_mqtt.MQTTv311,
)
except (TypeError, AttributeError):
# Fallback for paho-mqtt v1.x
client = paho_mqtt.Client(
client_id=client_id,
transport=broker_cfg.transport,
protocol=paho_mqtt.MQTTv311,
)
state.client = client
# Auth: username + JWT password
username = self._token_mgr.username
password = self._token_mgr.get_token(broker_cfg.server)
client.username_pw_set(username, password)
# TLS
if broker_cfg.tls:
client.tls_set(
cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLS_CLIENT,
)
# LWT (Last Will and Testament)
lwt_payload = json.dumps({
"status": "offline",
"origin": self._device_name,
"origin_id": self._public_key,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
client.will_set(
self._topic_status,
payload=lwt_payload,
qos=1,
retain=True,
)
# Callbacks
client.on_connect = self._make_on_connect(broker_cfg.name)
client.on_disconnect = self._make_on_disconnect(broker_cfg.name)
self._brokers[broker_cfg.name] = state
# Connect
if self._cfg.dry_run:
logger.info(
"[DRY RUN] Would connect to %s:%d (%s)",
broker_cfg.server, broker_cfg.port, broker_cfg.name,
)
continue
try:
logger.info(
"Connecting to MQTT broker %s (%s:%d)...",
broker_cfg.name, broker_cfg.server, broker_cfg.port,
)
client.connect_async(broker_cfg.server, broker_cfg.port)
client.loop_start()
except Exception as exc:
state.last_error = str(exc)
logger.error(
"Failed to connect to %s: %s", broker_cfg.name, exc,
)
self._started = True
def publish_entries(self, new_rxlog: List[Tuple[str, dict]]) -> int:
"""Publish new RX log entries to all connected brokers.
Filters by packet type, skips entries without ``raw_payload``,
transforms to LetsMesh format, and publishes to each broker.
Args:
new_rxlog: List of (source_address, entry_dict) tuples from
ArchiveWatcher.poll().
Returns:
Number of entries published (per broker).
"""
if not self._started or not new_rxlog:
return 0
published_count = 0
for source_addr, entry in new_rxlog:
# Skip entries without raw_payload (pre-Fase1 archives)
raw = entry.get("raw_payload", "")
if not raw:
self._total_skipped_no_raw += 1
if self._debug:
logger.debug(
"Skipping entry without raw_payload: %s",
entry.get("message_hash", "?")[:12],
)
continue
# Privacy filter: check packet type
pkt_type = entry.get("packet_type_num")
if self._allowed_types is not None and pkt_type is not None:
if int(pkt_type) not in self._allowed_types:
self._total_filtered += 1
if self._debug:
type_name = PACKET_TYPE_NAMES.get(int(pkt_type), str(pkt_type))
logger.debug(
"Filtered packet type %s (%s)", pkt_type, type_name,
)
continue
# Transform to LetsMesh payload format
payload = self._transform_entry(entry, source_addr)
payload_json = json.dumps(payload)
if self._cfg.dry_run:
logger.info("[DRY RUN] Would publish: %s", payload_json[:200])
published_count += 1
continue
# Publish to each connected broker
for name, state in self._brokers.items():
if not state.connected or state.client is None:
continue
try:
result = state.client.publish(
self._topic_packets,
payload=payload_json,
qos=0,
)
if result.rc == 0:
with self._lock:
state.packets_published += 1
state.last_publish_time = datetime.now(
timezone.utc
).isoformat()
published_count += 1
else:
logger.warning(
"Publish to %s returned rc=%d", name, result.rc,
)
except Exception as exc:
state.last_error = str(exc)
logger.error("Publish error on %s: %s", name, exc)
with self._lock:
self._total_published += published_count
# Periodic status republish
self._maybe_republish_status()
return published_count
def shutdown(self) -> None:
"""Graceful shutdown — publish offline status and disconnect."""
if not self._started:
return
logger.info("Shutting down MQTT uplink...")
for name, state in self._brokers.items():
if state.client is None:
continue
if state.connected and not self._cfg.dry_run:
# Publish offline status before disconnect
try:
offline_payload = json.dumps({
"status": "offline",
"origin": self._device_name,
"origin_id": self._public_key,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
state.client.publish(
self._topic_status,
payload=offline_payload,
qos=1,
retain=True,
)
except Exception as exc:
logger.warning(
"Could not publish offline status to %s: %s",
name, exc,
)
try:
state.client.loop_stop()
state.client.disconnect()
except Exception as exc:
logger.warning("Error disconnecting from %s: %s", name, exc)
self._started = False
logger.info("MQTT uplink stopped")
def get_status(self) -> Dict:
"""Return current MQTT status for dashboard display.
Returns:
Dict with enabled, dry_run, brokers status, totals.
"""
with self._lock:
broker_statuses = []
for name, state in self._brokers.items():
broker_statuses.append({
"name": name,
"server": state.config.server,
"connected": state.connected,
"packets_published": state.packets_published,
"last_publish_time": state.last_publish_time or "-",
"last_error": state.last_error,
})
return {
"enabled": self._cfg.enabled,
"dry_run": self._cfg.dry_run,
"started": self._started,
"iata": self._iata,
"public_key_short": self._public_key[:12] + "...",
"topic_base": self._topic_base,
"brokers": broker_statuses,
"total_published": self._total_published,
"total_filtered": self._total_filtered,
"total_skipped_no_raw": self._total_skipped_no_raw,
"upload_filter": (
[PACKET_TYPE_NAMES.get(t, str(t)) for t in sorted(self._allowed_types)]
if self._allowed_types
else "ALL"
),
}
# ------------------------------------------------------------------
# Internal: transform + publish helpers
# ------------------------------------------------------------------
def _transform_entry(self, entry: dict, source_addr: str) -> dict:
"""Transform an archive RX log entry to LetsMesh payload format.
Args:
entry: RX log entry dict from archive JSON.
source_addr: Source address from archive file.
Returns:
LetsMesh-compatible payload dict.
"""
# Parse timestamp for date/time fields
ts_utc = entry.get("timestamp_utc", "")
time_str = entry.get("time", "")
date_str = ""
if ts_utc:
try:
dt = datetime.fromisoformat(ts_utc)
if not time_str:
time_str = dt.strftime("%H:%M:%S")
# LetsMesh format: DD/M/YYYY
date_str = f"{dt.day}/{dt.month}/{dt.year}"
except (ValueError, AttributeError):
pass
return {
"origin": self._device_name,
"origin_id": self._public_key,
"timestamp": ts_utc or datetime.now(timezone.utc).isoformat(),
"type": "PACKET",
"direction": "rx",
"time": time_str,
"date": date_str,
"len": str(entry.get("packet_len", "")),
"packet_type": str(entry.get("packet_type_num", "")),
"route": str(entry.get("route_type", "")),
"payload_len": str(entry.get("payload_len", "")),
"raw": entry.get("raw_payload", ""),
"SNR": str(entry.get("snr", "")),
"RSSI": str(entry.get("rssi", "")),
"score": "1000",
"hash": entry.get("message_hash", ""),
}
def _publish_online_status(self, broker_name: str) -> None:
"""Publish online status to a specific broker.
Args:
broker_name: Name of the broker to publish to.
"""
state = self._brokers.get(broker_name)
if not state or not state.client or not state.connected:
return
status_payload = json.dumps({
"status": "online",
"origin": self._device_name,
"origin_id": self._public_key,
"timestamp": datetime.now(timezone.utc).isoformat(),
"version": "1.0.0",
"stats": {
"uptime_s": 0,
"packets_published": state.packets_published,
"sources_active": 0,
},
})
try:
state.client.publish(
self._topic_status,
payload=status_payload,
qos=1,
retain=True,
)
logger.info("Published online status to %s", broker_name)
except Exception as exc:
logger.error(
"Failed to publish status to %s: %s", broker_name, exc,
)
def _maybe_republish_status(self) -> None:
"""Republish status if interval has elapsed."""
if self._cfg.status_interval_s <= 0:
return
now = time.monotonic()
if now - self._last_status_time < self._cfg.status_interval_s:
return
self._last_status_time = now
for name in self._brokers:
self._publish_online_status(name)
# ------------------------------------------------------------------
# Internal: paho-mqtt callbacks
# ------------------------------------------------------------------
def _make_on_connect(self, broker_name: str):
"""Create an on_connect callback for a specific broker.
Args:
broker_name: Broker label for logging.
Returns:
Callback function for paho-mqtt.
"""
def on_connect(client, userdata, flags, rc, *args):
if rc == 0:
logger.info("Connected to MQTT broker: %s", broker_name)
with self._lock:
state = self._brokers.get(broker_name)
if state:
state.connected = True
state.last_error = ""
state.reconnect_count = 0
self._publish_online_status(broker_name)
else:
error_msg = f"Connection refused (rc={rc})"
logger.error(
"MQTT connection to %s failed: %s", broker_name, error_msg,
)
with self._lock:
state = self._brokers.get(broker_name)
if state:
state.connected = False
state.last_error = error_msg
return on_connect
def _make_on_disconnect(self, broker_name: str):
"""Create an on_disconnect callback for a specific broker.
Args:
broker_name: Broker label for logging.
Returns:
Callback function for paho-mqtt.
"""
def on_disconnect(client, userdata, flags_or_rc, rc=None, *args):
# Handle both v1.x and v2.x callback signatures
actual_rc = rc if rc is not None else flags_or_rc
logger.warning(
"Disconnected from MQTT broker %s (rc=%s)",
broker_name, actual_rc,
)
with self._lock:
state = self._brokers.get(broker_name)
if state:
state.connected = False
state.reconnect_count += 1
# Refresh token on reconnect
self._token_mgr.invalidate()
try:
password = self._token_mgr.get_token(
state.config.server
)
client.username_pw_set(
self._token_mgr.username, password,
)
except Exception as exc:
state.last_error = f"Token refresh failed: {exc}"
logger.error(
"Token refresh failed for %s: %s",
broker_name, exc,
)
# paho-mqtt auto-reconnects when loop_start() is active
return on_disconnect

View File

@@ -1,62 +0,0 @@
# ─────────────────────────────────────────────────────────
# MeshCore Observer — Configuration Template
# ─────────────────────────────────────────────────────────
#
# Kopieer naar observer_config.yaml:
# cp observer_config.template.yaml observer_config.yaml
#
# Keys worden AUTOMATISCH opgehaald als meshcore_gui draait
# op dezelfde machine. Handmatige configuratie is alleen
# nodig als meshcore_gui op een andere machine draait.
#
# observer_config.yaml staat in .gitignore — nooit committen.
# ─────────────────────────────────────────────────────────
observer:
archive_dir: "~/.meshcore-gui/archive"
poll_interval_s: 2.0
max_messages_display: 100
max_rxlog_display: 50
gui:
port: 9093
title: "MeshCore Observer"
mqtt:
enabled: true
iata: "AMS"
# ── Device identity ─────────────────────────────────────
#
# AUTOMATISCH: meshcore_gui schrijft device_identity.json
# naar ~/.meshcore-gui/. Observer leest dit automatisch.
# Geen handmatige configuratie nodig!
#
# HANDMATIG (alleen als auto-detect niet beschikbaar is):
# public_key: ""
# private_key_file: "~/.meshcore-observer-key"
#
# Of verwijs naar een custom identity file:
# device_identity_file: "~/.meshcore-gui/device_identity.json"
device_name: "" # Leeg = automatisch van device identity
# ── Broker endpoints ────────────────────────────────────
brokers:
- name: "letsmesh-eu"
server: "mqtt-eu-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: true
# ── Privacy filter (leeg = alles uploaden) ──────────────
# Types: 0=REQ 2=TXT_MSG 4=ADVERT 5=GRP_TXT 8=PATH
upload_packet_types: []
# ── Tuning ──────────────────────────────────────────────
status_interval_s: 300
reconnect_delay_s: 10
max_reconnect_retries: 0
token_lifetime_s: 3600
dry_run: false

View File

@@ -1,118 +0,0 @@
# ============================================================================
# MeshCore Observer — Configuration
# ============================================================================
#
# Copy this file to observer_config.yaml and adjust settings as needed.
# All settings have sensible defaults — the observer will work without
# a config file.
#
# Author: PE1HVH
# SPDX-License-Identifier: MIT
# Copyright: (c) 2026 PE1HVH
# ============================================================================
# ── Observer settings ────────────────────────────────────────────────
observer:
# Path to archive directory (where meshcore_gui/bridge write JSON files)
archive_dir: "~/.meshcore-gui/archive"
# Seconds between archive directory polls
poll_interval_s: 2.0
# Maximum number of messages displayed in dashboard
max_messages_display: 100
# Maximum number of RX log entries displayed in dashboard
max_rxlog_display: 50
# ── GUI settings ─────────────────────────────────────────────────────
gui:
# Dashboard TCP port (GUI=8081, Bridge=9092, Observer=9093)
port: 9093
# Browser tab title
title: "MeshCore Observer"
# ── MQTT Uplink to LetsMesh ──────────────────────────────────────────
#
# Publishes RX log packet data to the LetsMesh analyzer
# (analyzer.letsmesh.net) via MQTT over WebSocket+TLS.
#
# DISABLED by default — set enabled: true and provide keys to activate.
#
# Required for MQTT:
# - Device public key (64-char hex)
# - Device private key (64-char hex) for Ed25519 JWT signing
# - IATA airport code (3 letters) for topic namespace
#
# Private key can be provided via:
# 1. MESHCORE_PRIVATE_KEY environment variable (recommended)
# 2. private_key_file path (permissions should be 600)
# 3. private_key inline (not recommended for production)
#
mqtt:
# Master enable switch — MUST be explicitly set to true
enabled: false
# 3-letter IATA airport code for your location (used in MQTT topic path)
# Examples: AMS (Amsterdam), JFK (New York), LHR (London)
iata: "AMS"
# Device identity (required when MQTT is enabled)
# Can also be set via MESHCORE_PUBLIC_KEY env var
public_key: ""
# Device name shown as 'origin' in published packets
device_name: "MeshCore Observer"
# Private key for Ed25519 JWT authentication
# SECURITY: prefer private_key_file or MESHCORE_PRIVATE_KEY env var
private_key: ""
# Path to file containing private key (more secure than inline)
# File should contain only the 64-char hex key, permissions 600
private_key_file: ""
# ── Broker endpoints ──
# Multiple brokers can be configured (e.g. EU + US)
brokers:
- name: "letsmesh-eu"
server: "mqtt-eu-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: true
- name: "letsmesh-us"
server: "mqtt-us-v1.letsmesh.net"
port: 443
transport: "websockets"
tls: true
enabled: false
# ── Privacy filter ──
# Which packet types to upload. Empty list = upload ALL types.
# Packet types:
# 0=REQ, 1=RESPONSE, 2=TXT_MSG, 3=ACK, 4=ADVERT,
# 5=GRP_TXT, 6=GRP_DATA, 7=ANON_REQ, 8=PATH, 9=TRACE
#
# Examples:
# upload_packet_types: [] # Upload everything
# upload_packet_types: [4] # Only adverts
# upload_packet_types: [4, 5] # Adverts + group text
upload_packet_types: []
# Seconds between status topic republish (0 = only on connect)
status_interval_s: 300
# Seconds between reconnect attempts
reconnect_delay_s: 10
# Maximum reconnect retries (0 = infinite)
max_reconnect_retries: 0
# JWT token lifetime in seconds (auto-refreshed before expiry)
token_lifetime_s: 3600

View File

@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""
Manual MQTT key setup — FALLBACK ONLY.
Normally, meshcore_gui automatically writes the device identity file
to ``~/.meshcore-gui/device_identity.json`` and the observer reads
it. This script is only needed when meshcore_gui runs on a different
machine than the observer.
Two modes:
1. **Copy identity file** — copy ``device_identity.json`` from the
GUI machine and this script reads it directly.
2. **Manual entry** — paste the public key (64 hex chars, from GUI)
and the private key (128 hex chars, from ``export_private_key``).
Usage::
python setup_mqtt_keys.py
python setup_mqtt_keys.py --identity /path/to/device_identity.json
Author: PE1HVH
Version: 2.0.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import json
import re
import shutil
import stat
import sys
from pathlib import Path
PRIVATE_KEY_FILE = Path.home() / ".meshcore-observer-key"
# Config lives in project root (parent of the meshcore_observer package)
_PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_FILE = _PROJECT_DIR / "observer_config.yaml"
TEMPLATE_FILE = _PROJECT_DIR / "observer_config.template.yaml"
VALID_PRIVATE_KEY_LENGTHS = (64, 128) # seed or expanded
def _validate_hex(s: str, expected_lengths: tuple) -> bool:
return (
bool(re.fullmatch(r"[0-9a-fA-F]+", s))
and len(s) in expected_lengths
)
def _save_private_key(private_key_hex: str) -> None:
"""Save private key to file with restricted permissions."""
PRIVATE_KEY_FILE.write_text(private_key_hex + "\n", encoding="utf-8")
PRIVATE_KEY_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
print(f" ✅ Private key → {PRIVATE_KEY_FILE}")
print(f" ({len(private_key_hex)} hex chars)")
def _update_config_public_key(public_key_hex: str) -> None:
"""Write/update the public key in observer_config.yaml."""
if not CONFIG_FILE.exists():
if TEMPLATE_FILE.exists():
shutil.copy2(TEMPLATE_FILE, CONFIG_FILE)
print(f" 📄 {CONFIG_FILE.name} aangemaakt vanuit template")
else:
CONFIG_FILE.write_text(
"mqtt:\n"
" enabled: true\n"
f' public_key: "{public_key_hex}"\n'
' private_key_file: "~/.meshcore-observer-key"\n'
' iata: "AMS"\n'
" brokers:\n"
' - name: "letsmesh-eu"\n'
' server: "mqtt-eu-v1.letsmesh.net"\n'
" port: 443\n"
' transport: "websockets"\n'
" tls: true\n"
" enabled: true\n",
encoding="utf-8",
)
print(f" ✅ Config aangemaakt → {CONFIG_FILE}")
return
content = CONFIG_FILE.read_text(encoding="utf-8")
pattern = r'(public_key:\s*)["\']?[0-9a-fA-F]*["\']?'
new_content, count = re.subn(
pattern, f'\\1"{public_key_hex}"', content, count=1,
)
if count == 0:
if "mqtt:" in content:
new_content = content.replace(
"mqtt:", f'mqtt:\n public_key: "{public_key_hex}"', 1,
)
else:
new_content = (
content + f'\nmqtt:\n public_key: "{public_key_hex}"\n'
)
CONFIG_FILE.write_text(new_content, encoding="utf-8")
print(f" ✅ Public key → {CONFIG_FILE}")
def _from_identity_file(identity_path: Path) -> None:
"""Read keys from a device_identity.json file."""
print(f" 📄 Lezen: {identity_path}")
data = json.loads(identity_path.read_text(encoding="utf-8"))
pub = data.get("public_key", "")
priv = data.get("private_key", "")
if not _validate_hex(pub, (64,)):
print(f"\n❌ Ongeldige public key in identity file (lengte: {len(pub)})")
sys.exit(1)
if not _validate_hex(priv, VALID_PRIVATE_KEY_LENGTHS):
print(f"\n❌ Ongeldige private key in identity file (lengte: {len(priv)})")
sys.exit(1)
device_name = data.get("device_name", "onbekend")
print(f" Device: {device_name}")
print(f" Public key: {pub[:12]}...{pub[-8:]}")
print(f" Private key: {len(priv)} hex chars")
print()
_save_private_key(priv.lower())
_update_config_public_key(pub.upper())
def _from_manual_input() -> None:
"""Interactively enter public and private keys."""
print(" Voer de keys in die je van de GUI-machine hebt gekopieerd.")
print()
print(" De PUBLIC key (64 hex chars) staat in meshcore_gui onder")
print(" device info, of in device_identity.json.")
print()
pub = input(" Public key (64 hex chars): ").strip().replace(" ", "")
if not _validate_hex(pub, (64,)):
print(f"\n❌ Verwacht 64 hex karakters, gekregen: {len(pub)}")
sys.exit(1)
print()
print(" De PRIVATE key (128 hex chars) komt uit export_private_key()")
print(" of uit device_identity.json (het 'private_key' veld).")
print(" Legacy 64-char seeds worden ook geaccepteerd.")
print()
priv = input(" Private key (128 of 64 hex chars): ").strip().replace(" ", "")
if not _validate_hex(priv, VALID_PRIVATE_KEY_LENGTHS):
print(f"\n❌ Verwacht 64 of 128 hex karakters, gekregen: {len(priv)}")
sys.exit(1)
print()
print(f" Public key: {pub[:12]}...{pub[-8:]}")
print(f" Private key: {len(priv)} hex chars")
print()
_save_private_key(priv.lower())
_update_config_public_key(pub.upper())
def main():
print()
print("=" * 58)
print(" MeshCore Observer — Handmatige MQTT Key Setup")
print("=" * 58)
print()
print(" Dit script is alleen nodig als meshcore_gui op")
print(" een andere machine draait dan de observer.")
print(" Normaal worden keys automatisch gedeeld via")
print(" ~/.meshcore-gui/device_identity.json")
print()
# Check for --identity flag
identity_path = None
for i, arg in enumerate(sys.argv[1:], 1):
if arg.startswith("--identity="):
identity_path = Path(arg.split("=", 1)[1].strip()).expanduser()
elif arg == "--identity" and i < len(sys.argv) - 1:
identity_path = Path(sys.argv[i + 1].strip()).expanduser()
if identity_path:
if not identity_path.exists():
print(f"❌ Bestand niet gevonden: {identity_path}")
sys.exit(1)
_from_identity_file(identity_path)
else:
print(" Kies een methode:")
print(" 1. Kopieer device_identity.json van de GUI machine")
print(" 2. Voer public en private key handmatig in")
print()
choice = input(" Keuze [1/2]: ").strip()
print()
if choice == "1":
path_str = input(" Pad naar device_identity.json: ").strip()
p = Path(path_str).expanduser()
if not p.exists():
print(f"\n❌ Bestand niet gevonden: {p}")
sys.exit(1)
_from_identity_file(p)
else:
_from_manual_input()
print()
print(" ✅ Klaar!")
print()
print(" Test: python meshcore_observer.py --mqtt-dry-run --debug-on")
print(" Live: python meshcore_observer.py")
print()
if __name__ == "__main__":
main()