diff --git a/BRIDGE.md b/BRIDGE.md deleted file mode 100644 index b2214fd..0000000 --- a/BRIDGE.md +++ /dev/null @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index f94ff4f..16d7086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/OBSERVER.md b/OBSERVER.md deleted file mode 100644 index 9329b90..0000000 --- a/OBSERVER.md +++ /dev/null @@ -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) diff --git a/README.md b/README.md index d917dc7..f11aaa9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.py b/config.py deleted file mode 100644 index aba5c7b..0000000 --- a/config.py +++ /dev/null @@ -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__", "") - 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. diff --git a/install_scripts/install_bridge.sh b/install_scripts/install_bridge.sh deleted file mode 100755 index 37ec31b..0000000 --- a/install_scripts/install_bridge.sh +++ /dev/null @@ -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" diff --git a/install_scripts/install_observer.sh b/install_scripts/install_observer.sh deleted file mode 100755 index 5523243..0000000 --- a/install_scripts/install_observer.sh +++ /dev/null @@ -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 diff --git a/install_scripts/install_serial.sh b/install_scripts/install_serial.sh index 93817a7..0b4c765 100644 --- a/install_scripts/install_serial.sh +++ b/install_scripts/install_serial.sh @@ -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 diff --git a/install_scripts/install_venv.sh b/install_scripts/install_venv.sh deleted file mode 100755 index b23e087..0000000 --- a/install_scripts/install_venv.sh +++ /dev/null @@ -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" diff --git a/meshcore_bridge.py b/meshcore_bridge.py deleted file mode 100755 index d3905f1..0000000 --- a/meshcore_bridge.py +++ /dev/null @@ -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() diff --git a/meshcore_bridge/__init__.py b/meshcore_bridge/__init__.py deleted file mode 100644 index 0cd61d3..0000000 --- a/meshcore_bridge/__init__.py +++ /dev/null @@ -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" diff --git a/meshcore_bridge/__main__.py b/meshcore_bridge/__main__.py deleted file mode 100644 index edb358e..0000000 --- a/meshcore_bridge/__main__.py +++ /dev/null @@ -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() diff --git a/meshcore_bridge/bridge_config.yaml b/meshcore_bridge/bridge_config.yaml deleted file mode 100644 index fb797ea..0000000 --- a/meshcore_bridge/bridge_config.yaml +++ /dev/null @@ -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 diff --git a/meshcore_bridge/bridge_engine.py b/meshcore_bridge/bridge_engine.py deleted file mode 100644 index d0bc900..0000000 --- a/meshcore_bridge/bridge_engine.py +++ /dev/null @@ -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]}" diff --git a/meshcore_bridge/config.py b/meshcore_bridge/config.py deleted file mode 100644 index c427c04..0000000 --- a/meshcore_bridge/config.py +++ /dev/null @@ -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) diff --git a/meshcore_bridge/gui/__init__.py b/meshcore_bridge/gui/__init__.py deleted file mode 100644 index 056cf7b..0000000 --- a/meshcore_bridge/gui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Bridge GUI package.""" diff --git a/meshcore_bridge/gui/dashboard.py b/meshcore_bridge/gui/dashboard.py deleted file mode 100644 index 1c7cd3e..0000000 --- a/meshcore_bridge/gui/dashboard.py +++ /dev/null @@ -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 = ''' - - -''' - - -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() diff --git a/meshcore_bridge/gui/panels/__init__.py b/meshcore_bridge/gui/panels/__init__.py deleted file mode 100644 index 37577ac..0000000 --- a/meshcore_bridge/gui/panels/__init__.py +++ /dev/null @@ -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"] diff --git a/meshcore_bridge/gui/panels/log_panel.py b/meshcore_bridge/gui/panels/log_panel.py deleted file mode 100644 index d8e9cd9..0000000 --- a/meshcore_bridge/gui/panels/log_panel.py +++ /dev/null @@ -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" - ) diff --git a/meshcore_bridge/gui/panels/status_panel.py b/meshcore_bridge/gui/panels/status_panel.py deleted file mode 100644 index 58625ae..0000000 --- a/meshcore_bridge/gui/panels/status_panel.py +++ /dev/null @@ -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") diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index d81a553..e56e58b 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -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") diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index a33f37a..122ec09 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.19.0" +VERSION: str = "1.20.1" # ============================================================================== diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 377729f..95381dc 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -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 diff --git a/meshcore_gui/services/cache.py b/meshcore_gui/services/cache.py index dbde3a7..eceb5b1 100644 --- a/meshcore_gui/services/cache.py +++ b/meshcore_gui/services/cache.py @@ -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) # ------------------------------------------------------------------ diff --git a/meshcore_gui/services/device_identity.py b/meshcore_gui/services/device_identity.py index f650e41..e2ea6e1 100644 --- a/meshcore_gui/services/device_identity.py +++ b/meshcore_gui/services/device_identity.py @@ -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 diff --git a/meshcore_observer.py b/meshcore_observer.py deleted file mode 100644 index 4c296b3..0000000 --- a/meshcore_observer.py +++ /dev/null @@ -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() diff --git a/meshcore_observer/__init__.py b/meshcore_observer/__init__.py deleted file mode 100644 index 3a530c6..0000000 --- a/meshcore_observer/__init__.py +++ /dev/null @@ -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" diff --git a/meshcore_observer/__main__.py b/meshcore_observer/__main__.py deleted file mode 100644 index 9bcdf12..0000000 --- a/meshcore_observer/__main__.py +++ /dev/null @@ -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() diff --git a/meshcore_observer/archive_watcher.py b/meshcore_observer/archive_watcher.py deleted file mode 100644 index c8e6850..0000000 --- a/meshcore_observer/archive_watcher.py +++ /dev/null @@ -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 diff --git a/meshcore_observer/auth_token.py b/meshcore_observer/auth_token.py deleted file mode 100644 index 7b413a5..0000000 --- a/meshcore_observer/auth_token.py +++ /dev/null @@ -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 diff --git a/meshcore_observer/config.py b/meshcore_observer/config.py deleted file mode 100644 index 06edb6b..0000000 --- a/meshcore_observer/config.py +++ /dev/null @@ -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, - ) diff --git a/meshcore_observer/gui/__init__.py b/meshcore_observer/gui/__init__.py deleted file mode 100644 index 369ef4a..0000000 --- a/meshcore_observer/gui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Observer GUI package.""" diff --git a/meshcore_observer/gui/dashboard.py b/meshcore_observer/gui/dashboard.py deleted file mode 100644 index 034f7ad..0000000 --- a/meshcore_observer/gui/dashboard.py +++ /dev/null @@ -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 = ''' - - -''' - - -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() diff --git a/meshcore_observer/gui/panels/__init__.py b/meshcore_observer/gui/panels/__init__.py deleted file mode 100644 index 0985760..0000000 --- a/meshcore_observer/gui/panels/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Observer dashboard panels.""" diff --git a/meshcore_observer/gui/panels/messages_panel.py b/meshcore_observer/gui/panels/messages_panel.py deleted file mode 100644 index 5ce2140..0000000 --- a/meshcore_observer/gui/panels/messages_panel.py +++ /dev/null @@ -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 diff --git a/meshcore_observer/gui/panels/mqtt_panel.py b/meshcore_observer/gui/panels/mqtt_panel.py deleted file mode 100644 index 2450346..0000000 --- a/meshcore_observer/gui/panels/mqtt_panel.py +++ /dev/null @@ -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") diff --git a/meshcore_observer/gui/panels/rxlog_panel.py b/meshcore_observer/gui/panels/rxlog_panel.py deleted file mode 100644 index 818c1a7..0000000 --- a/meshcore_observer/gui/panels/rxlog_panel.py +++ /dev/null @@ -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 diff --git a/meshcore_observer/gui/panels/sources_panel.py b/meshcore_observer/gui/panels/sources_panel.py deleted file mode 100644 index 281fa4d..0000000 --- a/meshcore_observer/gui/panels/sources_panel.py +++ /dev/null @@ -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() diff --git a/meshcore_observer/gui/panels/stats_panel.py b/meshcore_observer/gui/panels/stats_panel.py deleted file mode 100644 index 288fd20..0000000 --- a/meshcore_observer/gui/panels/stats_panel.py +++ /dev/null @@ -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") diff --git a/meshcore_observer/mqtt_uplink.py b/meshcore_observer/mqtt_uplink.py deleted file mode 100644 index db80917..0000000 --- a/meshcore_observer/mqtt_uplink.py +++ /dev/null @@ -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 diff --git a/meshcore_observer/observer_config.template.yaml b/meshcore_observer/observer_config.template.yaml deleted file mode 100644 index 6e320d5..0000000 --- a/meshcore_observer/observer_config.template.yaml +++ /dev/null @@ -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 diff --git a/meshcore_observer/observer_config.yaml b/meshcore_observer/observer_config.yaml deleted file mode 100644 index 950eb42..0000000 --- a/meshcore_observer/observer_config.yaml +++ /dev/null @@ -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 diff --git a/meshcore_observer/setup_mqtt_keys.py b/meshcore_observer/setup_mqtt_keys.py deleted file mode 100644 index da70e99..0000000 --- a/meshcore_observer/setup_mqtt_keys.py +++ /dev/null @@ -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()