Merge pull request #1 from pe1hvh/feature/map-route-visualization

Refactor to modular package architecture + new features

Summary

Restructures the single-file meshcore_gui.py (v2.0) into a proper Python package with clear separation of concerns, and adds three major features: message route visualization, a keyword auto-reply bot, and raw LoRa packet decoding with deduplication.
New features
Message route visualization
Click any message to open a route page (/route/{msg_index}) in a new tab showing:

Hop count summary with SNR
Interactive Leaflet map with sender → repeaters → receiver connected by polyline
Detailed route table (name, ID, node type, GPS coordinates per hop)
Pre-filled reply panel with route acknowledgement

Route data is resolved from two sources in priority order:

Path hashes decoded from the raw LoRa packet (via meshcoredecoder)
Stored out_path from the sender's contact record (fallback)

Keyword bot
Built-in auto-reply bot, toggled via 🤖 BOT checkbox in the filter bar.

Configurable keyword → reply template mapping (supports {bot}, {sender}, {snr}, {path} variables)
Listens on configurable channels only
Guards against reply loops (ignores own messages and senders ending in "Bot")
Cooldown between replies (default 5s)

Packet decoding & deduplication

Raw LoRa packets from RX_LOG_DATA are decoded and decrypted using channel keys via meshcoredecoder, extracting message hashes, path hashes and hop data
Dual-strategy deduplication (hash-based from decoded packets + content-based fallback) prevents duplicate messages

Architecture changes
Before (v2.0): Single 700+ line file with all logic intermixed.
After (v5.0): Modular package following SOLID principles:
meshcore_gui/
├── ble/                    # BLE communication layer
│   ├── worker.py           # Thread lifecycle and connection
│   ├── commands.py         # Command execution (SRP)
│   ├── events.py           # Event callbacks (SRP)
│   └── packet_decoder.py   # LoRa packet decoding
├── core/                   # Domain layer
│   ├── models.py           # Typed dataclasses (Message, Contact, RouteNode, ...)
│   ├── shared_data.py      # Thread-safe shared state
│   └── protocols.py        # Protocol interfaces (ISP/DIP)
├── gui/                    # Presentation layer
│   ├── dashboard.py        # Main page orchestrator
│   ├── route_page.py       # Route visualization page
│   └── panels/             # 8 modular UI panels
└── services/               # Business logic
    ├── bot.py              # Keyword auto-reply
    ├── dedup.py            # Message deduplication
    └── route_builder.py    # Route data construction
Key design decisions:

Protocol interfaces (typing.Protocol) decouple components — consumers depend on narrow interfaces, not the concrete SharedData class
Typed dataclasses replace untyped dicts for Message, Contact, RxLogEntry, RouteNode and DeviceInfo
Services layer keeps business logic (bot, dedup, route building) independent of both BLE and GUI

Dependencies
New dependency: meshcoredecoder (LoRa packet decoder and channel crypto)
bashpip install nicegui meshcore bleak meshcoredecoder
Breaking changes

Entry point is still python meshcore_gui.py <ADDRESS> but now delegates to the package
Channel configuration moved from meshcore_gui.py to meshcore_gui/config.py
--debug-on CLI flag added (replaces editing DEBUG = True in source)

Testing
Tested on Linux (Ubuntu 24.04) with SenseCAP T1000-E over BLE. macOS and Windows remain untested.
This commit is contained in:
pe1hvh
2026-02-05 18:31:38 +01:00
committed by GitHub
39 changed files with 3526 additions and 1065 deletions

View File

@@ -1,11 +1,13 @@
# MeshCore GUI
A graphical user interface for MeshCore mesh network devices via Bluetooth Low Energy (BLE) for on your desktop.
![Status](https://img.shields.io/badge/Status-Testing%20%2F%20Not%20Production%20Ready-orange.svg)
> ⚠️ **This branch is in active development and testing. It is not production-ready. Use at your own risk. **
![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)
A graphical user interface for MeshCore mesh network devices via Bluetooth Low Energy (BLE) for on your desktop.
## Why This Project Exists
MeshCore devices like the SenseCAP T1000-E can be managed through two interfaces: USB serial and BLE (Bluetooth Low Energy). The official companion apps communicate with devices over BLE, but they are mobile-only. If you want to manage your MeshCore device from a desktop or laptop, the usual approach is to **flash USB-serial firmware** via the web flasher. However, this replaces the BLE Companion firmware, which means you can no longer use the device with mobile companion apps (Android/iOS).
@@ -139,7 +141,7 @@ On macOS the address will be a UUID (e.g., `12345678-ABCD-...`) rather than a MA
### 3. Configure channels
Open `meshcore_gui.py` and adjust `CHANNELS_CONFIG` to your own channels:
Open `meshcore_gui/config.py` and adjust `CHANNELS_CONFIG` to your own channels:
```python
CHANNELS_CONFIG = [
@@ -313,13 +315,30 @@ DEBUG = True
```
meshcore-gui/
├── meshcore_gui.py # Main application
├── README.md # This file
└── docs/
├── TROUBLESHOOTING.md # BLE troubleshooting guide (Linux)
── MeshCore_GUI_Design.docx # Design document
├── meshcore_gui.py # Entry point
├── meshcore_gui/ # Application package
│ ├── __init__.py
├── ble_worker.py
── config.py
│ ├── main_page.py
│ ├── protocols.py
│ ├── route_builder.py
│ ├── route_page.py
│ └── shared_data.py
├── docs/
│ ├── SOLID_ANALYSIS.md
│ ├── TROUBLESHOOTING.md
│ ├── MeshCore_GUI_Design.docx
│ ├── ble_capture_workflow_t_1000_e_explanation.md
│ └── ble_capture_workflow_t_1000_e_uitleg.md
├── .gitattributes
├── .gitignore
├── LICENSE
└── README.md
```
For a SOLID principles analysis of the project structure, see [SOLID_ANALYSIS.md](docs/SOLID_ANALYSIS.md).
## Disclaimer
This is an **independent community project** and is not affiliated with or endorsed by the official [MeshCore](https://github.com/meshcore-dev) development team. It is built on top of the open-source `meshcore` Python library and `bleak` BLE library.

142
RELEASE.md Normal file
View File

@@ -0,0 +1,142 @@
# Release Notes — MeshCore GUI
**Date:** 4 February 2026
---
## Summary
This release replaces the single-file monolith (`meshcore_gui.py`, 1,395 lines, 3 classes, 51 methods) with a modular package of 16 files (1,955 lines, 10 classes, 90 methods). The refactoring introduces a `meshcore_gui/` package with Protocol-based dependency inversion, a `widgets/` subpackage with six independent UI components, a message route visualisation page, and full type coverage.
---
## Starting point
The repository contained one file with everything in it:
**`meshcore_gui.py`** — 1,395 lines, 3 classes, 51 methods
| Section | Lines | Methods | Responsibility |
|---------|-------|---------|----------------|
| Config + `debug_print` | 80 | 1 | Constants, debug helper |
| `SharedData` | 225 | 12 | Thread-safe data store |
| `BLEWorker` | 268 | 11 | BLE communication thread |
| `MeshCoreGUI` | 740 | 24 | All GUI: rendering, data updates, user actions |
| Main entry | 74 | 3 | Page handler, `main()` |
All three classes lived in one file. BLEWorker and MeshCoreGUI both depended directly on the concrete SharedData class. MeshCoreGUI handled everything: 8 render methods, 7 data-update methods, 5 user-action methods, the 500ms update timer, and the DM dialog.
---
## Current state
16 files across a package with a `widgets/` subpackage:
| File | Lines | Class | Depends on |
|------|-------|-------|------------|
| `meshcore_gui.py` | 101 | *(entry point)* | concrete SharedData (composition root) |
| `meshcore_gui/__init__.py` | 8 | — | — |
| `meshcore_gui/config.py` | 54 | — | — |
| `meshcore_gui/protocols.py` | 83 | 4 Protocol classes | — |
| `meshcore_gui/shared_data.py` | 263 | SharedData | config |
| `meshcore_gui/ble_worker.py` | 252 | BLEWorker | SharedDataWriter protocol |
| `meshcore_gui/main_page.py` | 148 | DashboardPage | SharedDataReader protocol |
| `meshcore_gui/route_builder.py` | 174 | RouteBuilder | ContactLookup protocol |
| `meshcore_gui/route_page.py` | 258 | RoutePage | SharedDataReadAndLookup protocol |
| `meshcore_gui/widgets/__init__.py` | 22 | — | — |
| `meshcore_gui/widgets/device_panel.py` | 100 | DevicePanel | config |
| `meshcore_gui/widgets/map_panel.py` | 80 | MapPanel | — |
| `meshcore_gui/widgets/contacts_panel.py` | 114 | ContactsPanel | config |
| `meshcore_gui/widgets/message_input.py` | 83 | MessageInput | — |
| `meshcore_gui/widgets/message_list.py` | 156 | MessageList | — |
| `meshcore_gui/widgets/rx_log_panel.py` | 59 | RxLogPanel | — |
| **Total** | **1,955** | **10 classes** | |
---
## What changed
### 1. Monolith → package
The single file was split into a `meshcore_gui/` package. Each class got its own module. Constants and `debug_print` moved to `config.py`. The original `meshcore_gui.py` became a thin entry point (101 lines) that wires components and starts the server.
### 2. Protocol-based dependency inversion
Four `typing.Protocol` interfaces were introduced in `protocols.py`:
| Protocol | Consumer | Methods |
|----------|----------|---------|
| SharedDataWriter | BLEWorker | 10 |
| SharedDataReader | DashboardPage | 4 |
| ContactLookup | RouteBuilder | 1 |
| SharedDataReadAndLookup | RoutePage | 5 |
No consumer imports `shared_data.py` directly. Only the entry point knows the concrete class.
### 3. MeshCoreGUI decomposed into DashboardPage + 6 widgets
The 740-line MeshCoreGUI class was split:
| Old (MeshCoreGUI) | New | Lines |
|--------------------|-----|-------|
| 8 `_render_*` methods | 6 widget classes in `widgets/` | 592 total |
| 7 `_update_*` methods | Widget `update()` methods | *(inside widgets)* |
| 5 user-action methods | Widget `on_command` callbacks | *(inside widgets)* |
| `render()` + `_update_ui()` | DashboardPage (orchestrator) | 148 |
DashboardPage now has 4 methods. It composes widgets and drives the timer. Widgets have zero knowledge of SharedData — they receive plain `Dict` snapshots and callbacks.
### 4. Route visualisation (new feature)
Two new modules that did not exist in the monolith:
| Module | Lines | Purpose |
|--------|-------|---------|
| `route_builder.py` | 174 | Constructs route data from message metadata (pure logic) |
| `route_page.py` | 258 | Renders route on a Leaflet map in a separate browser tab |
Clicking a message in the message list opens `/route/{msg_index}` showing sender → repeater hops → receiver on a map.
### 5. SharedData extended
SharedData gained 4 new methods to support the protocol interfaces and route feature:
| New method | Purpose |
|------------|---------|
| `set_connected()` | Explicit setter (was direct attribute access) |
| `put_command()` | Queue command from GUI (was `cmd_queue.put()` directly) |
| `get_next_command()` | Dequeue command for BLE worker (was `cmd_queue.get_nowait()` directly) |
| `get_contact_by_prefix()` | Contact lookup for route building |
| `get_contact_name_by_prefix()` | Contact name lookup for DM display |
The direct `self.shared.lock` and `self.shared.cmd_queue` access from BLEWorker and MeshCoreGUI was replaced with proper method calls through protocol interfaces.
### 6. Full type coverage
All 90 methods now have complete type annotations (parameters and return types). The old monolith had 51 methods with partial coverage.
---
## Metrics
| Metric | Old | Current |
|--------|-----|---------|
| Files | 1 | 16 |
| Lines | 1,395 | 1,955 |
| Classes | 3 | 10 |
| Methods | 51 | 90 |
| Largest class (lines) | MeshCoreGUI (740) | SharedData (263) |
| Protocol interfaces | 0 | 4 |
| Type-annotated methods | partial | 90/90 |
| Widget classes | 0 | 6 |
---
## Documentation
| Document | Status |
|----------|--------|
| `README.md` | Updated: architecture diagram, project structure, features |
| `docs/MeshCore_GUI_Design.docx` | Updated: widget tables, component descriptions, version history |
| `docs/SOLID_ANALYSIS.md` | Updated: widget SRP, dependency tree, metrics |
| `docs/RELEASE.md` | New (this document) |

1
data/nodes.json Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

219
docs/SOLID_ANALYSIS.md Normal file
View File

@@ -0,0 +1,219 @@
# SOLID Analysis — MeshCore GUI
## 1. Reference: standard Python OOP project conventions
| Convention | Norm | This project |
|-----------|------|-------------|
| Package with subpackage when widgets emerge | ✅ | ✅ `widgets/` subpackage (6 classes) |
| One class per module | ✅ | ✅ every module ≤1 class |
| Entry point outside package | ✅ | ✅ `meshcore_gui.py` beside package |
| `__init__.py` with version | ✅ | ✅ only `__version__` |
| Constants in own module | ✅ | ✅ `config.py` |
| No circular imports | ✅ | ✅ acyclic dependency tree |
| Type hints on public API | ✅ | ✅ 84/84 methods typed |
| Private methods with `_` prefix | ✅ | ✅ consistent |
| Docstrings on modules and classes | ✅ | ✅ present everywhere |
| PEP 8 import order | ✅ | ✅ stdlib → third-party → local |
### Dependency tree (acyclic)
```
config protocols
↑ ↑
shared_data ble_worker
↑ main_page → widgets/*
↑ route_builder ← route_page
meshcore_gui.py (only place that knows the concrete SharedData)
```
No circular dependencies. `config` and `protocols` are leaf nodes; everything points in one direction. Widgets depend only on `config` (for constants) and NiceGUI — they have zero knowledge of SharedData or protocols.
---
## 2. SOLID assessment per principle
### S — Single Responsibility Principle
> "A class should have only one reason to change."
| Module | Class | Responsibility | Verdict |
|--------|-------|---------------|---------|
| `config.py` | *(no class)* | Constants and debug helper | ✅ Single purpose |
| `protocols.py` | *(Protocol classes)* | Interface contracts | ✅ Single purpose |
| `shared_data.py` | SharedData | Thread-safe data store | ✅ See note |
| `ble_worker.py` | BLEWorker | BLE communication thread | ✅ Single purpose |
| `main_page.py` | DashboardPage | Dashboard layout orchestrator | ✅ See note |
| `route_builder.py` | RouteBuilder | Route data construction (pure logic) | ✅ Single purpose |
| `route_page.py` | RoutePage | Route page rendering | ✅ Single purpose |
| `widgets/device_panel.py` | DevicePanel | Header, device info, actions | ✅ Single purpose |
| `widgets/map_panel.py` | MapPanel | Leaflet map with markers | ✅ Single purpose |
| `widgets/contacts_panel.py` | ContactsPanel | Contacts list + DM dialog | ✅ Single purpose |
| `widgets/message_input.py` | MessageInput | Message input + channel select | ✅ Single purpose |
| `widgets/message_list.py` | MessageList | Message feed + channel filter | ✅ Single purpose |
| `widgets/rx_log_panel.py` | RxLogPanel | RX log table | ✅ Single purpose |
**SharedData:** 15 public methods in 5 categories (device updates, status, collections, snapshots, lookups). This is deliberate design: SharedData is the single source of truth between two threads. Splitting it would spread lock logic across multiple objects, making thread-safety harder. The responsibility is *"thread-safe data access"* — that is one reason to change.
**DashboardPage:** After the widget decomposition, DashboardPage is now 148 lines with only 4 methods. It is a thin orchestrator that composes six widgets into a layout and drives the update timer. All rendering and data-update logic has been extracted into the widget classes. The previous ⚠️ for DashboardPage is resolved.
**Conclusion SRP:** No violations. All classes have a single, well-defined responsibility.
---
### O — Open/Closed Principle
> "Open for extension, closed for modification."
| Scenario | How to extend | Existing code modified? |
|----------|--------------|------------------------|
| Add new page | New module + `@ui.page` in entry point | Only entry point (1 line) |
| Add new BLE command | `_handle_command()` case | Only `ble_worker.py` |
| Add new contact type | `TYPE_ICONS/NAMES/LABELS` in config | Only `config.py` |
| Add new dashboard widget | New widget class + compose in DashboardPage | Only `main_page.py` |
| Add new route info | Extend RouteBuilder.build() | Only `route_builder.py` |
**Where not ideal:** `_handle_command()` in BLEWorker is an if/elif chain. In a larger project, a Command pattern or dict-dispatch would be more appropriate. For 4 commands this is pragmatically correct.
**Conclusion OCP:** Good. Extensions touch only one module.
---
### L — Liskov Substitution Principle
> "Subtypes must be substitutable for their base types."
There is **no inheritance** in this project. All classes are concrete and standalone. This is correct for the project scale — there is no reason for a class hierarchy.
**Where LSP does apply:** The Protocol interfaces (`SharedDataWriter`, `SharedDataReader`, `ContactLookup`, `SharedDataReadAndLookup`) define contracts that SharedData implements. Any object that satisfies these protocols can be substituted — for example a test stub. This is LSP via structural subtyping.
**Conclusion LSP:** Satisfied via Protocol interfaces. No violations.
---
### I — Interface Segregation Principle
> "Clients should not be forced to depend on interfaces they do not use."
| Client | Protocol | Methods visible | SharedData methods not visible |
|--------|----------|----------------|-------------------------------|
| BLEWorker | SharedDataWriter | 10 | 5 (snapshot, flags, GUI commands) |
| DashboardPage | SharedDataReader | 4 | 11 (all write methods) |
| RouteBuilder | ContactLookup | 1 | 14 (everything else) |
| RoutePage | SharedDataReadAndLookup | 5 | 10 (all write methods) |
| Widget classes | *(none — receive Dict/callback)* | 0 | 15 (all methods) |
Each consumer sees **only the methods it needs**. The protocols enforce this at the type level. Widget classes go even further: they have zero knowledge of SharedData and receive only plain dictionaries and callbacks.
**Conclusion ISP:** Satisfied. Each consumer depends on a narrow, purpose-built interface.
---
### D — Dependency Inversion Principle
> "Depend on abstractions, not on concretions."
| Dependency | Before (protocols) | After (protocols) |
|-----------|---------------|---------------|
| BLEWorker → SharedData | Concrete ⚠️ | Protocol (SharedDataWriter) ✅ |
| DashboardPage → SharedData | Concrete ⚠️ | Protocol (SharedDataReader) ✅ |
| RouteBuilder → SharedData | Concrete ⚠️ | Protocol (ContactLookup) ✅ |
| RoutePage → SharedData | Concrete ⚠️ | Protocol (SharedDataReadAndLookup) ✅ |
| Widget classes → SharedData | N/A | No dependency at all ✅ |
| meshcore_gui.py → SharedData | Concrete | Concrete ✅ (composition root) |
The **composition root** (`meshcore_gui.py`) is the only place that knows the concrete `SharedData` class. All other modules depend on protocols or receive plain data. This is standard DIP practice: the wiring layer knows the concretions, the business logic knows only abstractions.
**Conclusion DIP:** Satisfied. Constructor injection was already present; now the abstractions are explicit.
---
## 3. Protocol interface design
### Why `typing.Protocol` and not `abc.ABC`?
Python offers two approaches for defining interfaces:
| Aspect | `abc.ABC` (nominal) | `typing.Protocol` (structural) |
|--------|---------------------|-------------------------------|
| Subclassing required | Yes (`class Foo(MyABC)`) | No |
| Duck typing compatible | No | Yes |
| Runtime checkable | Yes | Optional (`@runtime_checkable`) |
| Python version | 3.0+ | 3.8+ |
Protocol was chosen because SharedData does not need to inherit from an abstract base class. Any object that has the right methods automatically satisfies the protocol — this is idiomatic Python (duck typing with type safety).
### Interface map
```
SharedDataWriter (BLEWorker)
├── update_from_appstart()
├── update_from_device_query()
├── set_status()
├── set_connected()
├── set_contacts()
├── set_channels()
├── add_message()
├── add_rx_log()
├── get_next_command()
└── get_contact_name_by_prefix()
SharedDataReader (DashboardPage)
├── get_snapshot()
├── clear_update_flags()
├── mark_gui_initialized()
└── put_command()
ContactLookup (RouteBuilder)
└── get_contact_by_prefix()
SharedDataReadAndLookup (RoutePage)
├── get_snapshot()
├── clear_update_flags()
├── mark_gui_initialized()
├── put_command()
└── get_contact_by_prefix()
```
---
## 4. Summary
| Principle | Before protocols | With protocols | With widgets | Change |
|----------|-----------------|----------------|--------------|--------|
| **SRP** | ✅ Good | ✅ Good | ✅ Good | Widget extraction resolved DashboardPage size |
| **OCP** | ✅ Good | ✅ Good | ✅ Good | Widgets are easy to add |
| **LSP** | ✅ N/A | ✅ Satisfied via Protocol | ✅ Satisfied via Protocol | — |
| **ISP** | ⚠️ Acceptable | ✅ Good | ✅ Good | Widgets have zero SharedData dependency |
| **DIP** | ⚠️ Acceptable | ✅ Good | ✅ Good | — |
### Changes: Protocol interfaces
| # | Change | Files affected |
|---|--------|---------------|
| 1 | Added `protocols.py` with 4 Protocol interfaces | New file |
| 2 | BLEWorker depends on `SharedDataWriter` | `ble_worker.py` |
| 3 | DashboardPage depends on `SharedDataReader` | `main_page.py` |
| 4 | RouteBuilder depends on `ContactLookup` | `route_builder.py` |
| 5 | RoutePage depends on `SharedDataReadAndLookup` | `route_page.py` |
| 6 | No consumer imports `shared_data.py` directly | All consumer modules |
### Changes: Widget decomposition
| # | Change | Files affected |
|---|--------|---------------|
| 1 | Added `widgets/` subpackage with 6 widget classes | New directory (7 files) |
| 2 | MeshCoreGUI (740 lines) replaced by DashboardPage (148 lines) + 6 widgets | `main_page.py`, `widgets/*.py` |
| 3 | DashboardPage is now a thin orchestrator | `main_page.py` |
| 4 | Widget classes depend only on `config` and NiceGUI | `widgets/*.py` |
| 5 | Maximum decoupling: widgets have zero SharedData knowledge | All widget modules |
### Metrics
| Metric | Monolith | With protocols | With widgets |
|--------|----------|----------------|--------------|
| Files | 1 | 8 | 16 |
| Total lines | 1,395 | ~1,500 | ~1,955 |
| Largest class (lines) | MeshCoreGUI (740) | MeshCoreGUI (740) | SharedData (263) |
| Typed methods | 51 (partial) | 51 (partial) | 90/90 |
| Protocol interfaces | 0 | 4 | 4 |

File diff suppressed because it is too large Load Diff

BIN
meshcore_gui.zip Normal file

Binary file not shown.

8
meshcore_gui/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""
MeshCore GUI — Threaded BLE Edition.
A graphical user interface for MeshCore mesh network devices,
communicating via Bluetooth Low Energy (BLE).
"""
__version__ = "5.0"

114
meshcore_gui/__main__.py Normal file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
MeshCore GUI — Threaded BLE Edition
====================================
Entry point. Parses arguments, wires up the components, registers
NiceGUI pages and starts the server.
Usage:
python meshcore_gui.py <BLE_ADDRESS>
python meshcore_gui.py <BLE_ADDRESS> --debug-on
python -m meshcore_gui <BLE_ADDRESS>
Author: PE1HVH
Version: 5.0
SPDX-License-Identifier: MIT
Copyright: (c) 2026 PE1HVH
"""
import sys
from nicegui import ui
# Allow overriding DEBUG before anything imports it
import meshcore_gui.config as config
try:
from meshcore import MeshCore, EventType # noqa: F401 — availability check
except ImportError:
print("ERROR: meshcore library not found")
print("Install with: pip install meshcore")
sys.exit(1)
from meshcore_gui.ble.worker import BLEWorker
from meshcore_gui.core.shared_data import SharedData
from meshcore_gui.gui.dashboard import DashboardPage
from meshcore_gui.gui.route_page import RoutePage
# Global instances (needed by NiceGUI page decorators)
_shared = None
_dashboard = None
_route_page = None
@ui.page('/')
def _page_dashboard():
"""NiceGUI page handler — main dashboard."""
if _dashboard:
_dashboard.render()
@ui.page('/route/{msg_index}')
def _page_route(msg_index: int):
"""NiceGUI page handler — route visualization (new tab)."""
if _route_page:
_route_page.render(msg_index)
def main():
"""
Main entry point.
Parses CLI arguments, initialises all components and starts the
NiceGUI server.
"""
global _shared, _dashboard, _route_page
# Parse arguments
args = [a for a in sys.argv[1:] if not a.startswith('--')]
flags = [a for a in sys.argv[1:] if a.startswith('--')]
if not args:
print("MeshCore GUI - Threaded BLE Edition")
print("=" * 40)
print("Usage: python meshcore_gui.py <BLE_ADDRESS> [--debug-on]")
print("Example: python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF")
print(" python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF --debug-on")
print()
print("Options:")
print(" --debug-on Enable verbose debug logging")
print()
print("Tip: Use 'bluetoothctl scan on' to find devices")
sys.exit(1)
ble_address = args[0]
# Apply --debug-on flag
if '--debug-on' in flags:
config.DEBUG = True
# Startup banner
print("=" * 50)
print("MeshCore GUI - Threaded BLE Edition")
print("=" * 50)
print(f"Device: {ble_address}")
print(f"Debug mode: {'ON' if config.DEBUG else 'OFF'}")
print("=" * 50)
# Assemble components
_shared = SharedData()
_dashboard = DashboardPage(_shared)
_route_page = RoutePage(_shared)
# Start BLE worker in background thread
worker = BLEWorker(ble_address, _shared)
worker.start()
# Start NiceGUI server (blocks)
ui.run(title='MeshCore', port=8080, reload=False)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
"""
BLE infrastructure layer — device connection, commands and events.
"""

View File

@@ -0,0 +1,113 @@
"""
BLE command handlers for MeshCore GUI.
Extracted from ``BLEWorker`` so that each command is an isolated unit
of work. New commands can be registered without modifying existing
code (Open/Closed Principle).
"""
from datetime import datetime
from typing import Dict, Optional
from meshcore import MeshCore
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message
from meshcore_gui.core.protocols import SharedDataWriter
class CommandHandler:
"""Dispatches and executes commands sent from the GUI.
Args:
mc: Connected MeshCore instance.
shared: SharedDataWriter for storing results.
"""
def __init__(self, mc: MeshCore, shared: SharedDataWriter) -> None:
self._mc = mc
self._shared = shared
# Handler registry — add new commands here (OCP)
self._handlers: Dict[str, object] = {
'send_message': self._cmd_send_message,
'send_dm': self._cmd_send_dm,
'send_advert': self._cmd_send_advert,
'refresh': self._cmd_refresh,
}
async def process_all(self) -> None:
"""Drain the command queue and dispatch each command."""
while True:
cmd = self._shared.get_next_command()
if cmd is None:
break
await self._dispatch(cmd)
async def _dispatch(self, cmd: Dict) -> None:
action = cmd.get('action')
handler = self._handlers.get(action)
if handler:
await handler(cmd)
else:
debug_print(f"Unknown command action: {action}")
# ------------------------------------------------------------------
# Individual command handlers
# ------------------------------------------------------------------
async def _cmd_send_message(self, cmd: Dict) -> None:
channel = cmd.get('channel', 0)
text = cmd.get('text', '')
is_bot = cmd.get('_bot', False)
if text:
await self._mc.commands.send_chan_msg(channel, text)
if not is_bot:
self._shared.add_message(Message(
time=datetime.now().strftime('%H:%M:%S'),
sender='Me',
text=text,
channel=channel,
direction='out',
))
debug_print(
f"{'BOT' if is_bot else 'Sent'} message to "
f"channel {channel}: {text[:30]}"
)
async def _cmd_send_dm(self, cmd: Dict) -> None:
pubkey = cmd.get('pubkey', '')
text = cmd.get('text', '')
contact_name = cmd.get('contact_name', pubkey[:8])
if text and pubkey:
await self._mc.commands.send_msg(pubkey, text)
self._shared.add_message(Message(
time=datetime.now().strftime('%H:%M:%S'),
sender='Me',
text=text,
channel=None,
direction='out',
sender_pubkey=pubkey,
))
debug_print(f"Sent DM to {contact_name}: {text[:30]}")
async def _cmd_send_advert(self, cmd: Dict) -> None:
await self._mc.commands.send_advert(flood=True)
self._shared.set_status("📢 Advert sent")
debug_print("Advert sent")
async def _cmd_refresh(self, cmd: Dict) -> None:
debug_print("Refresh requested")
# Delegate to the worker's _load_data via a callback
if self._load_data_callback:
await self._load_data_callback()
# ------------------------------------------------------------------
# Callback for refresh (set by BLEWorker after construction)
# ------------------------------------------------------------------
_load_data_callback = None
def set_load_data_callback(self, callback) -> None:
"""Register the worker's ``_load_data`` coroutine for refresh."""
self._load_data_callback = callback

216
meshcore_gui/ble/events.py Normal file
View File

@@ -0,0 +1,216 @@
"""
BLE event callbacks for MeshCore GUI.
Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA``
events from the MeshCore library. Extracted from ``BLEWorker`` so the
worker only deals with connection lifecycle.
"""
from datetime import datetime
from typing import Dict, Optional
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RxLogEntry
from meshcore_gui.core.protocols import SharedDataWriter
from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType
from meshcore_gui.services.bot import MeshBot
from meshcore_gui.services.dedup import DualDeduplicator
class EventHandler:
"""Processes BLE events and writes results to shared data.
Args:
shared: SharedDataWriter for storing messages and RX log.
decoder: PacketDecoder for raw LoRa packet decryption.
dedup: DualDeduplicator for message deduplication.
bot: MeshBot for auto-reply logic.
"""
def __init__(
self,
shared: SharedDataWriter,
decoder: PacketDecoder,
dedup: DualDeduplicator,
bot: MeshBot,
) -> None:
self._shared = shared
self._decoder = decoder
self._dedup = dedup
self._bot = bot
# ------------------------------------------------------------------
# RX_LOG_DATA — the single source of truth for path info
# ------------------------------------------------------------------
def on_rx_log(self, event) -> None:
"""Handle RX log data events."""
payload = event.payload
self._shared.add_rx_log(RxLogEntry(
time=datetime.now().strftime('%H:%M:%S'),
snr=payload.get('snr', 0),
rssi=payload.get('rssi', 0),
payload_type=payload.get('payload_type', '?'),
hops=payload.get('path_len', 0),
))
payload_hex = payload.get('payload', '')
if not payload_hex:
return
decoded = self._decoder.decode(payload_hex)
if decoded is None:
return
if decoded.payload_type == PayloadType.GroupText and decoded.is_decrypted:
self._dedup.mark_hash(decoded.message_hash)
self._dedup.mark_content(
decoded.sender, decoded.channel_idx, decoded.text,
)
sender_pubkey = ''
if decoded.sender:
match = self._shared.get_contact_by_name(decoded.sender)
if match:
sender_pubkey, _contact = match
snr = self._extract_snr(payload)
self._shared.add_message(Message(
time=datetime.now().strftime('%H:%M:%S'),
sender=decoded.sender,
text=decoded.text,
channel=decoded.channel_idx,
direction='in',
snr=snr,
path_len=decoded.path_length,
sender_pubkey=sender_pubkey,
path_hashes=decoded.path_hashes,
message_hash=decoded.message_hash,
))
debug_print(
f"RX_LOG → message: hash={decoded.message_hash}, "
f"sender={decoded.sender!r}, ch={decoded.channel_idx}, "
f"path={decoded.path_hashes}"
)
self._bot.check_and_reply(
sender=decoded.sender,
text=decoded.text,
channel_idx=decoded.channel_idx,
snr=snr,
path_len=decoded.path_length,
path_hashes=decoded.path_hashes,
)
# ------------------------------------------------------------------
# CHANNEL_MSG_RECV — fallback when RX_LOG decode missed it
# ------------------------------------------------------------------
def on_channel_msg(self, event) -> None:
"""Handle channel message events."""
payload = event.payload
debug_print(f"Channel msg payload keys: {list(payload.keys())}")
# Dedup via hash
msg_hash = payload.get('message_hash', '')
if msg_hash and self._dedup.is_hash_seen(msg_hash):
debug_print(f"Channel msg suppressed (hash): {msg_hash}")
return
# Parse sender from "SenderName: message body" format
raw_text = payload.get('text', '')
sender, msg_text = '', raw_text
if ': ' in raw_text:
name_part, body_part = raw_text.split(': ', 1)
sender = name_part.strip()
msg_text = body_part
elif raw_text:
msg_text = raw_text
# Dedup via content
ch_idx = payload.get('channel_idx')
if self._dedup.is_content_seen(sender, ch_idx, msg_text):
debug_print(f"Channel msg suppressed (content): {sender!r}")
return
debug_print(
f"Channel msg (fallback): sender={sender!r}, "
f"text={msg_text[:40]!r}"
)
sender_pubkey = ''
if sender:
match = self._shared.get_contact_by_name(sender)
if match:
sender_pubkey, _contact = match
snr = self._extract_snr(payload)
self._shared.add_message(Message(
time=datetime.now().strftime('%H:%M:%S'),
sender=sender,
text=msg_text,
channel=ch_idx,
direction='in',
snr=snr,
path_len=payload.get('path_len', 0),
sender_pubkey=sender_pubkey,
path_hashes=[],
message_hash=msg_hash,
))
self._bot.check_and_reply(
sender=sender,
text=msg_text,
channel_idx=ch_idx,
snr=snr,
path_len=payload.get('path_len', 0),
)
# ------------------------------------------------------------------
# CONTACT_MSG_RECV — DMs
# ------------------------------------------------------------------
def on_contact_msg(self, event) -> None:
"""Handle direct message events."""
payload = event.payload
pubkey = payload.get('pubkey_prefix', '')
debug_print(f"DM payload keys: {list(payload.keys())}")
sender = ''
if pubkey:
sender = self._shared.get_contact_name_by_prefix(pubkey)
if not sender:
sender = pubkey[:8] if pubkey else ''
self._shared.add_message(Message(
time=datetime.now().strftime('%H:%M:%S'),
sender=sender,
text=payload.get('text', ''),
channel=None,
direction='in',
snr=self._extract_snr(payload),
path_len=payload.get('path_len', 0),
sender_pubkey=pubkey,
))
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_snr(payload: Dict) -> Optional[float]:
"""Extract SNR from a payload dict (handles 'SNR' and 'snr' keys)."""
raw = payload.get('SNR') or payload.get('snr')
if raw is not None:
try:
return float(raw)
except (ValueError, TypeError):
pass
return None

View File

@@ -0,0 +1,206 @@
"""
Packet decoder for MeshCore GUI — single-source approach.
Wraps ``meshcoredecoder`` to decode raw LoRa packets from RX_LOG_DATA
events. A single raw packet contains **everything**: message_hash,
path hashes, hop count, and (with channel keys) the decrypted text
and sender name.
No correlation with CHANNEL_MSG_RECV events is needed.
Channel decryption keys are loaded at startup (fetched from the device
via ``get_channel()`` or derived from the channel name as fallback).
"""
from dataclasses import dataclass, field
from hashlib import sha256
from typing import Dict, List, Optional
from meshcoredecoder import MeshCoreDecoder
from meshcoredecoder.crypto.channel_crypto import ChannelCrypto
from meshcoredecoder.crypto.key_manager import MeshCoreKeyStore
from meshcoredecoder.types.crypto import DecryptionOptions
from meshcoredecoder.types.enums import PayloadType
from meshcore_gui.config import debug_print
# Re-export so other modules don't need to import meshcoredecoder
__all__ = ["PacketDecoder", "DecodedPacket", "PayloadType"]
# ---------------------------------------------------------------------------
# Decoded result
# ---------------------------------------------------------------------------
@dataclass
class DecodedPacket:
"""All data extracted from a single raw LoRa packet.
Attributes:
message_hash: Deterministic packet identifier (hex string).
payload_type: Enum (GroupText, Advert, Ack, …).
path_length: Number of repeater hashes in the path.
path_hashes: 2-char hex strings, one per repeater.
sender: Sender name (GroupText only, after decryption).
text: Message body (GroupText only, after decryption).
channel_idx: Channel index (GroupText only, via hash→idx map).
timestamp: Message timestamp (GroupText only).
is_decrypted: True if payload was successfully decrypted.
"""
message_hash: str
payload_type: PayloadType
path_length: int
path_hashes: List[str] = field(default_factory=list)
# GroupText-specific (populated after successful decryption)
sender: str = ""
text: str = ""
channel_idx: Optional[int] = None
timestamp: int = 0
is_decrypted: bool = False
# ---------------------------------------------------------------------------
# Decoder
# ---------------------------------------------------------------------------
class PacketDecoder:
"""Decode raw LoRa packets with channel-key decryption.
Usage::
decoder = PacketDecoder()
decoder.add_channel_key(0, secret_bytes) # from device
decoder.add_channel_key_from_name(1, "#test") # fallback
result = decoder.decode(payload_hex)
if result and result.is_decrypted:
print(result.sender, result.text, result.path_hashes)
"""
def __init__(self) -> None:
self._key_store = MeshCoreKeyStore()
self._options: Optional[DecryptionOptions] = None
# channel_hash (2-char lower hex) → channel_idx
self._hash_to_idx: Dict[str, int] = {}
# ------------------------------------------------------------------
# Key management
# ------------------------------------------------------------------
def add_channel_key(self, channel_idx: int, secret_bytes: bytes) -> None:
"""Register a channel decryption key (16 raw bytes from device).
Args:
channel_idx: Channel index (0-based).
secret_bytes: 16-byte channel secret from ``get_channel()``.
"""
secret_hex = secret_bytes.hex()
self._key_store.add_channel_secrets([secret_hex])
self._rebuild_options()
ch_hash = ChannelCrypto.calculate_channel_hash(secret_hex).lower()
self._hash_to_idx[ch_hash] = channel_idx
debug_print(
f"PacketDecoder: key for ch{channel_idx} "
f"(hash={ch_hash}, from device)"
)
def add_channel_key_from_name(
self, channel_idx: int, channel_name: str,
) -> None:
"""Derive a channel key from the channel name (fallback).
MeshCore derives channel secrets as
``SHA-256(name.encode('utf-8'))[:16]``.
Args:
channel_idx: Channel index (0-based).
channel_name: Channel name string (e.g. ``"#test"``).
"""
secret_bytes = sha256(channel_name.encode("utf-8")).digest()[:16]
self.add_channel_key(channel_idx, secret_bytes)
debug_print(
f"PacketDecoder: key for ch{channel_idx} "
f"(derived from '{channel_name}')"
)
@property
def has_keys(self) -> bool:
"""True if at least one channel key has been registered."""
return self._options is not None
# ------------------------------------------------------------------
# Decode
# ------------------------------------------------------------------
def decode(self, payload_hex: str) -> Optional[DecodedPacket]:
"""Decode a raw LoRa packet hex string.
Args:
payload_hex: Hex string from the RX_LOG_DATA event's
``payload`` field.
Returns:
:class:`DecodedPacket` on success, ``None`` if the data
is invalid or too short.
"""
if not payload_hex:
return None
try:
packet = MeshCoreDecoder.decode(payload_hex, self._options)
except Exception as exc:
debug_print(f"PacketDecoder: decode error: {exc}")
return None
if not packet.is_valid:
debug_print(f"PacketDecoder: invalid: {packet.errors}")
return None
result = DecodedPacket(
message_hash=packet.message_hash,
payload_type=packet.payload_type,
path_length=packet.path_length,
path_hashes=list(packet.path) if packet.path else [],
)
# --- GroupText decryption ---
if packet.payload_type == PayloadType.GroupText:
decoded_payload = packet.payload.get("decoded")
if decoded_payload and decoded_payload.decrypted:
d = decoded_payload.decrypted
result.sender = d.get("sender", "") or ""
result.text = d.get("message", "") or ""
result.timestamp = d.get("timestamp", 0)
result.is_decrypted = True
# Resolve channel_hash → channel_idx
ch_hash = decoded_payload.channel_hash.lower()
result.channel_idx = self._hash_to_idx.get(ch_hash)
debug_print(
f"PacketDecoder: GroupText OK — "
f"hash={result.message_hash}, "
f"sender={result.sender!r}, "
f"ch={result.channel_idx}, "
f"path={result.path_hashes}, "
f"text={result.text[:40]!r}"
)
else:
debug_print(
f"PacketDecoder: GroupText NOT decrypted "
f"(hash={result.message_hash})"
)
return result
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _rebuild_options(self) -> None:
"""Recreate DecryptionOptions after a key change."""
self._options = DecryptionOptions(key_store=self._key_store)

199
meshcore_gui/ble/worker.py Normal file
View File

@@ -0,0 +1,199 @@
"""
BLE communication worker for MeshCore GUI.
Runs in a separate thread with its own asyncio event loop. Connects
to the MeshCore device, wires up collaborators, and runs the command
processing loop.
Responsibilities deliberately kept narrow (SRP):
- Thread lifecycle and asyncio loop
- BLE connection and initial data loading
- Wiring CommandHandler and EventHandler
Command execution → :mod:`meshcore_gui.ble.commands`
Event handling → :mod:`meshcore_gui.ble.events`
Packet decoding → :mod:`meshcore_gui.ble.packet_decoder`
Bot logic → :mod:`meshcore_gui.services.bot`
Deduplication → :mod:`meshcore_gui.services.dedup`
"""
import asyncio
import threading
from typing import Optional
from meshcore import MeshCore, EventType
from meshcore_gui.config import CHANNELS_CONFIG, debug_print
from meshcore_gui.core.protocols import SharedDataWriter
from meshcore_gui.ble.commands import CommandHandler
from meshcore_gui.ble.events import EventHandler
from meshcore_gui.ble.packet_decoder import PacketDecoder
from meshcore_gui.services.bot import BotConfig, MeshBot
from meshcore_gui.services.dedup import DualDeduplicator
class BLEWorker:
"""BLE communication worker that runs in a separate thread.
Args:
address: BLE MAC address (e.g. ``"literal:AA:BB:CC:DD:EE:FF"``).
shared: SharedDataWriter for thread-safe communication.
"""
def __init__(self, address: str, shared: SharedDataWriter) -> None:
self.address = address
self.shared = shared
self.mc: Optional[MeshCore] = None
self.running = True
# Collaborators (created eagerly, wired after connection)
self._decoder = PacketDecoder()
self._dedup = DualDeduplicator(max_size=200)
self._bot = MeshBot(
config=BotConfig(),
command_sink=shared.put_command,
enabled_check=shared.is_bot_enabled,
)
# ------------------------------------------------------------------
# Thread lifecycle
# ------------------------------------------------------------------
def start(self) -> None:
"""Start the worker in a new daemon thread."""
thread = threading.Thread(target=self._run, daemon=True)
thread.start()
debug_print("BLE worker thread started")
def _run(self) -> None:
asyncio.run(self._async_main())
async def _async_main(self) -> None:
await self._connect()
if self.mc:
while self.running:
await self._cmd_handler.process_all()
await asyncio.sleep(0.1)
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
async def _connect(self) -> None:
self.shared.set_status(f"🔄 Connecting to {self.address}...")
try:
print(f"BLE: Connecting to {self.address}...")
self.mc = await MeshCore.create_ble(self.address)
print("BLE: Connected!")
await asyncio.sleep(1)
# Wire collaborators now that mc is available
self._evt_handler = EventHandler(
shared=self.shared,
decoder=self._decoder,
dedup=self._dedup,
bot=self._bot,
)
self._cmd_handler = CommandHandler(mc=self.mc, shared=self.shared)
self._cmd_handler.set_load_data_callback(self._load_data)
# Subscribe to events
self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._evt_handler.on_channel_msg)
self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._evt_handler.on_contact_msg)
self.mc.subscribe(EventType.RX_LOG_DATA, self._evt_handler.on_rx_log)
await self._load_data()
await self._load_channel_keys()
await self.mc.start_auto_message_fetching()
self.shared.set_connected(True)
self.shared.set_status("✅ Connected")
print("BLE: Ready!")
except Exception as e:
print(f"BLE: Connection error: {e}")
self.shared.set_status(f"{e}")
# ------------------------------------------------------------------
# Initial data loading
# ------------------------------------------------------------------
async def _load_data(self) -> None:
"""Load device info, channels and contacts."""
# send_appstart (retries)
self.shared.set_status("🔄 Device info...")
for i in range(5):
debug_print(f"send_appstart attempt {i + 1}")
r = await self.mc.commands.send_appstart()
if r.type != EventType.ERROR:
print(f"BLE: send_appstart OK: {r.payload.get('name')}")
self.shared.update_from_appstart(r.payload)
break
await asyncio.sleep(0.3)
# send_device_query (retries)
for i in range(5):
debug_print(f"send_device_query attempt {i + 1}")
r = await self.mc.commands.send_device_query()
if r.type != EventType.ERROR:
print(f"BLE: send_device_query OK: {r.payload.get('ver')}")
self.shared.update_from_device_query(r.payload)
break
await asyncio.sleep(0.3)
# Channels (hardcoded — BLE get_channel is unreliable)
self.shared.set_status("🔄 Channels...")
self.shared.set_channels(CHANNELS_CONFIG)
print(f"BLE: Channels loaded: {[c['name'] for c in CHANNELS_CONFIG]}")
# Contacts
self.shared.set_status("🔄 Contacts...")
r = await self.mc.commands.get_contacts()
if r.type != EventType.ERROR:
self.shared.set_contacts(r.payload)
print(f"BLE: Contacts loaded: {len(r.payload)} contacts")
async def _load_channel_keys(self) -> None:
"""Load channel decryption keys from device or derive from name.
Channels that cannot be confirmed on the device are logged with
a warning. Sending and receiving on unconfirmed channels will
likely fail because the device does not know about them.
"""
self.shared.set_status("🔄 Channel keys...")
confirmed: list[str] = []
missing: list[str] = []
for ch in CHANNELS_CONFIG:
idx, name = ch['idx'], ch['name']
loaded = False
for attempt in range(3):
try:
r = await self.mc.commands.get_channel(idx)
if r.type != EventType.ERROR:
secret = r.payload.get('channel_secret')
if secret and isinstance(secret, bytes) and len(secret) >= 16:
self._decoder.add_channel_key(idx, secret[:16])
print(f"BLE: ✅ Channel [{idx}] '{name}' — key loaded from device")
confirmed.append(f"[{idx}] {name}")
loaded = True
break
except Exception as exc:
debug_print(f"get_channel({idx}) attempt {attempt + 1} error: {exc}")
await asyncio.sleep(0.3)
if not loaded:
self._decoder.add_channel_key_from_name(idx, name)
missing.append(f"[{idx}] {name}")
print(f"BLE: ⚠️ Channel [{idx}] '{name}' — NOT found on device (key derived from name)")
if missing:
print(f"BLE: ⚠️ Channels not confirmed on device: {', '.join(missing)}")
print(f"BLE: ⚠️ Sending/receiving on these channels may not work.")
print(f"BLE: ⚠️ Check your device config with: meshcli -d <BLE_ADDRESS> → get_channels")
print(f"BLE: PacketDecoder ready — has_keys={self._decoder.has_keys}")
print(f"BLE: Confirmed: {', '.join(confirmed) if confirmed else 'none'}")
print(f"BLE: Unconfirmed: {', '.join(missing) if missing else 'none'}")

43
meshcore_gui/config.py Normal file
View File

@@ -0,0 +1,43 @@
"""
Application configuration for MeshCore GUI.
Contains only global runtime settings and the channel table.
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.
"""
from typing import Dict, List
# ==============================================================================
# DEBUG
# ==============================================================================
DEBUG: bool = False
def debug_print(msg: str) -> None:
"""Print a debug message when ``DEBUG`` is enabled."""
if DEBUG:
print(f"DEBUG: {msg}")
# ==============================================================================
# CHANNELS
# ==============================================================================
# Hardcoded channels configuration.
# Determine your channels with meshcli:
# meshcli -d <BLE_ADDRESS>
# > get_channels
# Output: 0: Public [...], 1: #test [...], etc.
CHANNELS_CONFIG: List[Dict] = [
{'idx': 0, 'name': 'Public'},
{'idx': 1, 'name': '#test'},
{'idx': 2, 'name': '#zwolle'},
{'idx': 3, 'name': 'RahanSom'},
{'idx': 4, 'name': '#bot'},
]

View File

@@ -0,0 +1,16 @@
"""
Core domain layer — models, protocols and shared data store.
Re-exports the most commonly used names so consumers can write::
from meshcore_gui.core import SharedData, Message, RxLogEntry
"""
from meshcore_gui.core.models import ( # noqa: F401
Contact,
DeviceInfo,
Message,
RouteNode,
RxLogEntry,
)
from meshcore_gui.core.shared_data import SharedData # noqa: F401

174
meshcore_gui/core/models.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Domain model for MeshCore GUI.
Typed dataclasses that replace untyped Dict objects throughout the
codebase. Each class represents a core domain concept. All classes
are immutable-friendly (frozen is not used because SharedData mutates
collections, but fields are not reassigned after construction).
Migration note
~~~~~~~~~~~~~~
``SharedData.get_snapshot()`` still returns a plain dict for backward
compatibility with the NiceGUI timer loop. Inside that dict, however,
``messages`` and ``rx_log`` are now lists of dataclass instances.
UI code can access attributes directly (``msg.sender``) or fall back
to ``dataclasses.asdict(msg)`` if a plain dict is needed.
"""
from dataclasses import dataclass, field
from typing import List, Optional
# ---------------------------------------------------------------------------
# Message
# ---------------------------------------------------------------------------
@dataclass
class Message:
"""A channel message or direct message (DM).
Attributes:
time: Formatted timestamp (HH:MM:SS).
sender: Display name of the sender.
text: Message body.
channel: Channel index, or ``None`` for a DM.
direction: ``'in'`` for received, ``'out'`` for sent.
snr: Signal-to-noise ratio (dB), if available.
path_len: Hop count from the LoRa frame header.
sender_pubkey: Full public key of the sender (hex string).
path_hashes: List of 2-char hex strings, one per repeater.
message_hash: Deterministic packet identifier (hex string).
"""
time: str
sender: str
text: str
channel: Optional[int]
direction: str
snr: Optional[float] = None
path_len: int = 0
sender_pubkey: str = ""
path_hashes: List[str] = field(default_factory=list)
message_hash: str = ""
# ---------------------------------------------------------------------------
# Contact
# ---------------------------------------------------------------------------
@dataclass
class Contact:
"""A known mesh network node.
Attributes:
pubkey: Full public key (hex string).
adv_name: Advertised display name.
type: Node type (0=unknown, 1=CLI, 2=REP, 3=ROOM).
adv_lat: Advertised latitude (0.0 if unknown).
adv_lon: Advertised longitude (0.0 if unknown).
out_path: Hex string of stored route (2 hex chars per hop).
out_path_len: Number of hops in ``out_path``.
"""
pubkey: str
adv_name: str = ""
type: int = 0
adv_lat: float = 0.0
adv_lon: float = 0.0
out_path: str = ""
out_path_len: int = 0
@staticmethod
def from_dict(pubkey: str, d: dict) -> "Contact":
"""Create a Contact from a meshcore contacts dict entry."""
return Contact(
pubkey=pubkey,
adv_name=d.get("adv_name", ""),
type=d.get("type", 0),
adv_lat=d.get("adv_lat", 0.0),
adv_lon=d.get("adv_lon", 0.0),
out_path=d.get("out_path", ""),
out_path_len=d.get("out_path_len", 0),
)
# ---------------------------------------------------------------------------
# DeviceInfo
# ---------------------------------------------------------------------------
@dataclass
class DeviceInfo:
"""Radio device identification and configuration.
Attributes:
name: Device display name.
public_key: Device public key (hex string).
radio_freq: Radio frequency in MHz.
radio_sf: LoRa spreading factor.
radio_bw: Bandwidth in kHz.
tx_power: Transmit power in dBm.
adv_lat: Advertised latitude.
adv_lon: Advertised longitude.
firmware_version: Firmware version string.
"""
name: str = ""
public_key: str = ""
radio_freq: float = 0.0
radio_sf: int = 0
radio_bw: float = 0.0
tx_power: int = 0
adv_lat: float = 0.0
adv_lon: float = 0.0
firmware_version: str = ""
# ---------------------------------------------------------------------------
# RxLogEntry
# ---------------------------------------------------------------------------
@dataclass
class RxLogEntry:
"""A single RX log entry from the radio.
Attributes:
time: Formatted timestamp (HH:MM:SS).
snr: Signal-to-noise ratio (dB).
rssi: Received signal strength (dBm).
payload_type: Packet type identifier.
hops: Number of hops (path_len from frame header).
"""
time: str
snr: float = 0.0
rssi: float = 0.0
payload_type: str = "?"
hops: int = 0
# ---------------------------------------------------------------------------
# RouteNode
# ---------------------------------------------------------------------------
@dataclass
class RouteNode:
"""A node in a message route (sender, repeater or receiver).
Attributes:
name: Display name (or ``'-'`` if unknown).
lat: Latitude (0.0 if unknown).
lon: Longitude (0.0 if unknown).
type: Node type (0=unknown, 1=CLI, 2=REP, 3=ROOM).
pubkey: Public key or 2-char hash (hex string).
"""
name: str
lat: float = 0.0
lon: float = 0.0
type: int = 0
pubkey: str = ""
@property
def has_location(self) -> bool:
"""True if the node has GPS coordinates."""
return self.lat != 0 or self.lon != 0

View File

@@ -0,0 +1,107 @@
"""
Protocol interfaces for MeshCore GUI.
Defines the contracts between components using ``typing.Protocol``.
Each protocol captures the subset of SharedData that a specific
consumer needs, following the Interface Segregation Principle (ISP)
and the Dependency Inversion Principle (DIP).
Consumers depend on these protocols rather than on the concrete
SharedData class, which makes the contracts explicit and enables
testing with lightweight stubs.
v4.1 changes
~~~~~~~~~~~~~
- Added ``CommandSink`` protocol for bot and command dispatch.
- ``SharedDataWriter.add_message`` now accepts a ``Message`` dataclass.
- ``SharedDataWriter.add_rx_log`` now accepts an ``RxLogEntry`` dataclass.
"""
from typing import Dict, List, Optional, Protocol, runtime_checkable
from meshcore_gui.core.models import Message, RxLogEntry
# ----------------------------------------------------------------------
# CommandSink — used by MeshBot and GUI pages
# ----------------------------------------------------------------------
@runtime_checkable
class CommandSink(Protocol):
"""Enqueue commands for the BLE worker."""
def put_command(self, cmd: Dict) -> None: ...
# ----------------------------------------------------------------------
# Writer — used by BLEWorker
# ----------------------------------------------------------------------
@runtime_checkable
class SharedDataWriter(Protocol):
"""Write-side interface used by BLEWorker.
BLEWorker pushes data into the shared store: device info,
contacts, channels, messages, RX log entries and status updates.
It also reads commands enqueued by the GUI.
"""
def update_from_appstart(self, payload: Dict) -> None: ...
def update_from_device_query(self, payload: Dict) -> None: ...
def set_status(self, status: str) -> None: ...
def set_connected(self, connected: bool) -> None: ...
def set_contacts(self, contacts_dict: Dict) -> None: ...
def set_channels(self, channels: List[Dict]) -> None: ...
def add_message(self, msg: Message) -> None: ...
def add_rx_log(self, entry: RxLogEntry) -> None: ...
def get_next_command(self) -> Optional[Dict]: ...
def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: ...
def get_contact_by_name(self, name: str) -> Optional[tuple]: ...
def is_bot_enabled(self) -> bool: ...
def put_command(self, cmd: Dict) -> None: ...
# ----------------------------------------------------------------------
# Reader — used by DashboardPage
# ----------------------------------------------------------------------
@runtime_checkable
class SharedDataReader(Protocol):
"""Read-side interface used by GUI pages.
GUI pages read snapshots of the shared data and manage
update flags. They also enqueue commands for the BLE worker.
"""
def get_snapshot(self) -> Dict: ...
def clear_update_flags(self) -> None: ...
def mark_gui_initialized(self) -> None: ...
def put_command(self, cmd: Dict) -> None: ...
def set_bot_enabled(self, enabled: bool) -> None: ...
# ----------------------------------------------------------------------
# ContactLookup — used by RouteBuilder
# ----------------------------------------------------------------------
@runtime_checkable
class ContactLookup(Protocol):
"""Contact lookup interface used by RouteBuilder.
RouteBuilder needs to resolve public key prefixes and names
to contact records.
"""
def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: ...
def get_contact_by_name(self, name: str) -> Optional[tuple]: ...
# ----------------------------------------------------------------------
# ReadAndLookup — used by RoutePage (needs both Reader + Lookup)
# ----------------------------------------------------------------------
@runtime_checkable
class SharedDataReadAndLookup(SharedDataReader, ContactLookup, Protocol):
"""Combined interface for RoutePage which reads snapshots and
delegates contact lookups to RouteBuilder."""
...

View File

@@ -0,0 +1,261 @@
"""
Thread-safe shared data container for MeshCore GUI.
SharedData is the central data store shared between the BLE worker thread
and the GUI main thread. All access goes through methods that acquire a
threading.Lock so both threads can safely read and write.
v4.1 changes
~~~~~~~~~~~~~
- ``messages`` is now ``List[Message]`` (was ``List[Dict]``).
- ``rx_log`` is now ``List[RxLogEntry]`` (was ``List[Dict]``).
- ``DeviceInfo`` dataclass replaces loose scalar fields.
- ``get_snapshot()`` returns typed objects; UI code accesses attributes
directly (``msg.sender``) instead of dict keys (``msg['sender']``).
"""
import queue
import threading
from dataclasses import asdict
from typing import Dict, List, Optional, Tuple
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import DeviceInfo, Message, RxLogEntry
class SharedData:
"""
Thread-safe container for shared data between BLE worker and GUI.
Implements all four Protocol interfaces defined in ``protocols.py``.
"""
def __init__(self) -> None:
self.lock = threading.Lock()
# Device info (typed)
self.device = DeviceInfo()
# Connection status
self.connected: bool = False
self.status: str = "Starting..."
# Data collections (typed)
self.contacts: Dict = {}
self.channels: List[Dict] = []
self.messages: List[Message] = []
self.rx_log: List[RxLogEntry] = []
# Command queue (GUI → BLE)
self.cmd_queue: queue.Queue = queue.Queue()
# Update flags — initially True so first GUI render shows data
self.device_updated: bool = True
self.contacts_updated: bool = True
self.channels_updated: bool = True
self.rxlog_updated: bool = True
# Flag to track if GUI has done first render
self.gui_initialized: bool = False
# BOT enabled flag (toggled from GUI)
self.bot_enabled: bool = False
# ------------------------------------------------------------------
# Device info updates
# ------------------------------------------------------------------
def update_from_appstart(self, payload: Dict) -> None:
"""Update device info from send_appstart response."""
with self.lock:
d = self.device
d.name = payload.get('name', d.name)
d.public_key = payload.get('public_key', d.public_key)
d.radio_freq = payload.get('radio_freq', d.radio_freq)
d.radio_sf = payload.get('radio_sf', d.radio_sf)
d.radio_bw = payload.get('radio_bw', d.radio_bw)
d.tx_power = payload.get('tx_power', d.tx_power)
d.adv_lat = payload.get('adv_lat', d.adv_lat)
d.adv_lon = payload.get('adv_lon', d.adv_lon)
self.device_updated = True
debug_print(f"Device info updated: {d.name}")
def update_from_device_query(self, payload: Dict) -> None:
"""Update firmware version from send_device_query response."""
with self.lock:
self.device.firmware_version = payload.get(
'ver', self.device.firmware_version,
)
self.device_updated = True
debug_print(f"Firmware version: {self.device.firmware_version}")
# ------------------------------------------------------------------
# Status
# ------------------------------------------------------------------
def set_status(self, status: str) -> None:
with self.lock:
self.status = status
def set_connected(self, connected: bool) -> None:
with self.lock:
self.connected = connected
# ------------------------------------------------------------------
# BOT
# ------------------------------------------------------------------
def set_bot_enabled(self, enabled: bool) -> None:
with self.lock:
self.bot_enabled = enabled
debug_print(f"BOT {'enabled' if enabled else 'disabled'}")
def is_bot_enabled(self) -> bool:
with self.lock:
return self.bot_enabled
# ------------------------------------------------------------------
# Command queue
# ------------------------------------------------------------------
def put_command(self, cmd: Dict) -> None:
self.cmd_queue.put(cmd)
def get_next_command(self) -> Optional[Dict]:
try:
return self.cmd_queue.get_nowait()
except queue.Empty:
return None
# ------------------------------------------------------------------
# Collections
# ------------------------------------------------------------------
def set_contacts(self, contacts_dict: Dict) -> None:
with self.lock:
self.contacts = contacts_dict.copy()
self.contacts_updated = True
debug_print(f"Contacts updated: {len(self.contacts)} contacts")
def set_channels(self, channels: List[Dict]) -> None:
with self.lock:
self.channels = channels.copy()
self.channels_updated = True
debug_print(f"Channels updated: {[c['name'] for c in channels]}")
def add_message(self, msg: Message) -> None:
"""Add a Message to the store (max 100)."""
with self.lock:
self.messages.append(msg)
if len(self.messages) > 100:
self.messages.pop(0)
debug_print(
f"Message added: {msg.sender}: {msg.text[:30]}"
)
def add_rx_log(self, entry: RxLogEntry) -> None:
"""Add an RxLogEntry (max 50, newest first)."""
with self.lock:
self.rx_log.insert(0, entry)
if len(self.rx_log) > 50:
self.rx_log.pop()
self.rxlog_updated = True
# ------------------------------------------------------------------
# Snapshot and flags
# ------------------------------------------------------------------
def get_snapshot(self) -> Dict:
"""Create a complete snapshot of all data for the GUI.
Returns a plain dict with typed objects inside. The
``messages`` and ``rx_log`` values are lists of dataclass
instances (not dicts).
"""
with self.lock:
d = self.device
return {
# DeviceInfo fields (flat for backward compat)
'name': d.name,
'public_key': d.public_key,
'radio_freq': d.radio_freq,
'radio_sf': d.radio_sf,
'radio_bw': d.radio_bw,
'tx_power': d.tx_power,
'adv_lat': d.adv_lat,
'adv_lon': d.adv_lon,
'firmware_version': d.firmware_version,
# Status
'connected': self.connected,
'status': self.status,
# Collections (typed copies)
'contacts': self.contacts.copy(),
'channels': self.channels.copy(),
'messages': self.messages.copy(),
'rx_log': self.rx_log.copy(),
# Flags
'device_updated': self.device_updated,
'contacts_updated': self.contacts_updated,
'channels_updated': self.channels_updated,
'rxlog_updated': self.rxlog_updated,
'gui_initialized': self.gui_initialized,
'bot_enabled': self.bot_enabled,
}
def clear_update_flags(self) -> None:
with self.lock:
self.device_updated = False
self.contacts_updated = False
self.channels_updated = False
self.rxlog_updated = False
def mark_gui_initialized(self) -> None:
with self.lock:
self.gui_initialized = True
debug_print("GUI marked as initialized")
# ------------------------------------------------------------------
# Contact lookups
# ------------------------------------------------------------------
def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]:
if not pubkey_prefix:
return None
with self.lock:
for key, contact in self.contacts.items():
if key.startswith(pubkey_prefix) or pubkey_prefix.startswith(key):
return contact.copy()
return None
def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str:
if not pubkey_prefix:
return ""
with self.lock:
for key, contact in self.contacts.items():
if key.startswith(pubkey_prefix):
name = contact.get('adv_name', '')
if name:
return name
return pubkey_prefix[:8]
def get_contact_by_name(self, name: str) -> Optional[Tuple[str, Dict]]:
if not name:
return None
with self.lock:
# Strategy 1: exact match
for key, contact in self.contacts.items():
if contact.get('adv_name', '') == name:
return (key, contact.copy())
# Strategy 2: case-insensitive
name_lower = name.lower()
for key, contact in self.contacts.items():
if contact.get('adv_name', '').lower() == name_lower:
return (key, contact.copy())
# Strategy 3: prefix match
for key, contact in self.contacts.items():
adv = contact.get('adv_name', '')
if not adv:
continue
if name.startswith(adv) or adv.startswith(name):
return (key, contact.copy())
return None

View File

@@ -0,0 +1,3 @@
"""
Presentation layer — NiceGUI pages and panels.
"""

View File

@@ -0,0 +1,11 @@
"""
Display constants for the GUI layer.
Contact type → icon/name/label mappings used by multiple panels.
"""
from typing import Dict
TYPE_ICONS: Dict[int, str] = {0: "", 1: "📱", 2: "📡", 3: "🏠"}
TYPE_NAMES: Dict[int, str] = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"}
TYPE_LABELS: Dict[int, str] = {0: "-", 1: "Companion", 2: "Repeater", 3: "Room Server"}

View File

@@ -0,0 +1,165 @@
"""
Main dashboard page for MeshCore GUI.
Thin orchestrator that owns the layout and the 500 ms update timer.
All visual content is delegated to individual panel classes in
:mod:`meshcore_gui.gui.panels`.
"""
import logging
from nicegui import ui
from meshcore_gui.core.protocols import SharedDataReader
from meshcore_gui.gui.panels import (
ActionsPanel,
ContactsPanel,
DevicePanel,
FilterPanel,
InputPanel,
MapPanel,
MessagesPanel,
RxLogPanel,
)
# Suppress the harmless "Client has been deleted" warning that NiceGUI
# emits when a browser tab is refreshed while a ui.timer is active.
class _DeletedClientFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return 'Client has been deleted' not in record.getMessage()
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
class DashboardPage:
"""Main dashboard rendered at ``/``.
Args:
shared: SharedDataReader for data access and command dispatch.
"""
def __init__(self, shared: SharedDataReader) -> None:
self._shared = shared
# Panels (created fresh on each render)
self._device: DevicePanel | None = None
self._contacts: ContactsPanel | None = None
self._map: MapPanel | None = None
self._input: InputPanel | None = None
self._filter: FilterPanel | None = None
self._messages: MessagesPanel | None = None
self._actions: ActionsPanel | None = None
self._rxlog: RxLogPanel | None = None
# Header status label
self._status_label = None
# Local first-render flag
self._initialized: bool = False
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the complete dashboard layout and start the timer."""
self._initialized = False
# Create panel instances
put_cmd = self._shared.put_command
self._device = DevicePanel()
self._contacts = ContactsPanel(put_cmd)
self._map = MapPanel()
self._input = InputPanel(put_cmd)
self._filter = FilterPanel(self._shared.set_bot_enabled)
self._messages = MessagesPanel()
self._actions = ActionsPanel(put_cmd)
self._rxlog = RxLogPanel()
ui.dark_mode(False)
# Header
with ui.header().classes('bg-blue-600 text-white'):
ui.label('🔗 MeshCore').classes('text-xl font-bold')
ui.space()
self._status_label = ui.label('Starting...').classes('text-sm')
# Three-column layout
with ui.row().classes('w-full h-full gap-2 p-2'):
# Left column
with ui.column().classes('w-64 gap-2'):
self._device.render()
self._contacts.render()
# Centre column
with ui.column().classes('flex-grow gap-2'):
self._map.render()
self._input.render()
self._filter.render()
self._messages.render()
# Right column
with ui.column().classes('w-64 gap-2'):
self._actions.render()
self._rxlog.render()
# Start update timer
ui.timer(0.5, self._update_ui)
# ------------------------------------------------------------------
# Timer-driven UI update
# ------------------------------------------------------------------
def _update_ui(self) -> None:
try:
if not self._status_label:
return
data = self._shared.get_snapshot()
is_first = not self._initialized
# Always update status
self._status_label.text = data['status']
# Device info
if data['device_updated'] or is_first:
self._device.update(data)
# Channels → filter checkboxes + input dropdown
if data['channels_updated'] or is_first:
self._filter.update(data)
self._input.update_channel_options(data['channels'])
# Contacts
if data['contacts_updated'] or is_first:
self._contacts.update(data)
# Map
if data['contacts'] and (
data['contacts_updated'] or not self._map.has_markers or is_first
):
self._map.update(data)
# Messages (always — for live filter changes)
self._messages.update(
data,
self._filter.channel_filters,
self._filter.last_channels,
)
# RX Log
if data['rxlog_updated']:
self._rxlog.update(data)
# Clear flags and mark initialised
self._shared.clear_update_flags()
if is_first and data['channels'] and data['contacts']:
self._initialized = True
self._shared.mark_gui_initialized()
except Exception as e:
err = str(e).lower()
if "deleted" not in err and "client" not in err:
print(f"GUI update error: {e}")

View File

@@ -0,0 +1,16 @@
"""
Individual dashboard panels — each panel is a single-responsibility class.
Re-exports all panels for convenient importing::
from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ...
"""
from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401
from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401
from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401
from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401
from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401
from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401
from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401
from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401

View File

@@ -0,0 +1,29 @@
"""Actions panel — refresh and advertise buttons."""
from typing import Callable, Dict
from nicegui import ui
class ActionsPanel:
"""Action buttons in the right column.
Args:
put_command: Callable to enqueue a command dict for the BLE worker.
"""
def __init__(self, put_command: Callable[[Dict], None]) -> None:
self._put_command = put_command
def render(self) -> None:
with ui.card().classes('w-full'):
ui.label('⚡ Actions').classes('font-bold text-gray-600')
with ui.row().classes('gap-2'):
ui.button('🔄 Refresh', on_click=self._refresh)
ui.button('📢 Advert', on_click=self._advert)
def _refresh(self) -> None:
self._put_command({'action': 'refresh'})
def _advert(self) -> None:
self._put_command({'action': 'send_advert'})

View File

@@ -0,0 +1,87 @@
"""Contacts panel — list of known mesh nodes with click-to-DM."""
from typing import Callable, Dict
from nicegui import ui
from meshcore_gui.gui.constants import TYPE_ICONS, TYPE_NAMES
class ContactsPanel:
"""Displays contacts in the left column. Click opens a DM dialog.
Args:
put_command: Callable to enqueue a command dict for the BLE worker.
"""
def __init__(self, put_command: Callable[[Dict], None]) -> None:
self._put_command = put_command
self._container = None
def render(self) -> None:
with ui.card().classes('w-full'):
ui.label('👥 Contacts').classes('font-bold text-gray-600')
self._container = ui.column().classes(
'w-full gap-1 max-h-96 overflow-y-auto'
)
def update(self, data: Dict) -> None:
if not self._container:
return
self._container.clear()
with self._container:
for key, contact in data['contacts'].items():
ctype = contact.get('type', 0)
icon = TYPE_ICONS.get(ctype, '')
name = contact.get('adv_name', key[:12])
type_name = TYPE_NAMES.get(ctype, '-')
lat = contact.get('adv_lat', 0)
lon = contact.get('adv_lon', 0)
has_loc = lat != 0 or lon != 0
tooltip = (
f"{name}\nType: {type_name}\n"
f"Key: {key[:16]}...\nClick to send DM"
)
if has_loc:
tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}"
with ui.row().classes(
'w-full items-center gap-2 p-1 '
'hover:bg-gray-100 rounded cursor-pointer'
).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)):
ui.label(icon).classes('text-sm')
ui.label(name[:15]).classes(
'text-sm flex-grow truncate'
).tooltip(tooltip)
ui.label(type_name).classes('text-xs text-gray-500')
if has_loc:
ui.label('📍').classes('text-xs')
# ------------------------------------------------------------------
# DM dialog
# ------------------------------------------------------------------
def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None:
with ui.dialog() as dialog, ui.card().classes('w-96'):
ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg')
msg_input = ui.input(placeholder='Type your message...').classes('w-full')
with ui.row().classes('w-full justify-end gap-2 mt-4'):
ui.button('Cancel', on_click=dialog.close).props('flat')
def send_dm():
text = msg_input.value
if text:
self._put_command({
'action': 'send_dm',
'pubkey': pubkey,
'text': text,
'contact_name': contact_name,
})
dialog.close()
ui.button('Send', on_click=send_dm).classes('bg-blue-500 text-white')
dialog.open()

View File

@@ -0,0 +1,40 @@
"""Device information panel — radio name, frequency, location, firmware."""
from typing import Dict
from nicegui import ui
class DevicePanel:
"""Displays device info in the left column."""
def __init__(self) -> None:
self._label = None
def render(self) -> None:
with ui.card().classes('w-full'):
ui.label('📡 Device').classes('font-bold text-gray-600')
self._label = ui.label('Connecting...').classes(
'text-sm whitespace-pre-line'
)
def update(self, data: Dict) -> None:
if not self._label:
return
lines = []
if data['name']:
lines.append(f"📡 {data['name']}")
if data['public_key']:
lines.append(f"🔑 {data['public_key'][:16]}...")
if data['radio_freq']:
lines.append(f"📻 {data['radio_freq']:.3f} MHz")
lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz")
if data['tx_power']:
lines.append(f"⚡ TX: {data['tx_power']} dBm")
if data['adv_lat'] and data['adv_lon']:
lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}")
if data['firmware_version']:
lines.append(f"🏷️ {data['firmware_version']}")
self._label.text = "\n".join(lines) if lines else "Loading..."

View File

@@ -0,0 +1,61 @@
"""Filter panel — channel filter checkboxes and bot toggle."""
from typing import Callable, Dict, List
from nicegui import ui
class FilterPanel:
"""Channel filter checkboxes and bot on/off toggle.
Args:
set_bot_enabled: Callable to toggle the bot in SharedData.
"""
def __init__(self, set_bot_enabled: Callable[[bool], None]) -> None:
self._set_bot_enabled = set_bot_enabled
self._container = None
self._bot_checkbox = None
self._channel_filters: Dict = {}
self._last_channels: List[Dict] = []
@property
def channel_filters(self) -> Dict:
"""Current filter checkboxes (key: channel idx or ``'DM'``)."""
return self._channel_filters
@property
def last_channels(self) -> List[Dict]:
"""Channel list from the most recent update."""
return self._last_channels
def render(self) -> None:
with ui.card().classes('w-full'):
with ui.row().classes('w-full items-center gap-4 justify-center'):
ui.label('📻 Filter:').classes('text-sm text-gray-600')
self._container = ui.row().classes('gap-4')
def update(self, data: Dict) -> None:
"""Rebuild checkboxes when channel data changes."""
if not self._container or not data['channels']:
return
self._container.clear()
self._channel_filters = {}
with self._container:
self._bot_checkbox = ui.checkbox(
'🤖 BOT',
value=data.get('bot_enabled', False),
on_change=lambda e: self._set_bot_enabled(e.value),
)
ui.label('').classes('text-gray-300')
cb_dm = ui.checkbox('DM', value=True)
self._channel_filters['DM'] = cb_dm
for ch in data['channels']:
cb = ui.checkbox(f"[{ch['idx']}] {ch['name']}", value=True)
self._channel_filters[ch['idx']] = cb
self._last_channels = data['channels']

View File

@@ -0,0 +1,59 @@
"""Input panel — message input field, channel selector and send button."""
from typing import Callable, Dict, List
from nicegui import ui
class InputPanel:
"""Message composition panel in the centre column.
Args:
put_command: Callable to enqueue a command dict for the BLE worker.
"""
def __init__(self, put_command: Callable[[Dict], None]) -> None:
self._put_command = put_command
self._msg_input = None
self._channel_select = None
@property
def channel_select(self):
"""Expose channel_select so FilterPanel can update its options."""
return self._channel_select
def render(self) -> None:
with ui.card().classes('w-full'):
with ui.row().classes('w-full items-center gap-2'):
self._msg_input = ui.input(
placeholder='Message...'
).classes('flex-grow')
self._channel_select = ui.select(
options={0: '[0] Public'}, value=0
).classes('w-32')
ui.button(
'Send', on_click=self._send_message
).classes('bg-blue-500 text-white')
def update_channel_options(self, channels: List[Dict]) -> None:
"""Update the channel dropdown options."""
if not self._channel_select or not channels:
return
opts = {ch['idx']: f"[{ch['idx']}] {ch['name']}" for ch in channels}
self._channel_select.options = opts
if self._channel_select.value not in opts:
self._channel_select.value = list(opts.keys())[0]
self._channel_select.update()
def _send_message(self) -> None:
text = self._msg_input.value
channel = self._channel_select.value
if text:
self._put_command({
'action': 'send_message',
'channel': channel,
'text': text,
})
self._msg_input.value = ''

View File

@@ -0,0 +1,49 @@
"""Map panel — Leaflet map with own position and contact markers."""
from typing import Dict, List
from nicegui import ui
class MapPanel:
"""Interactive Leaflet map in the centre column."""
def __init__(self) -> None:
self._map = None
self._markers: List = []
@property
def has_markers(self) -> bool:
return bool(self._markers)
def render(self) -> None:
with ui.card().classes('w-full'):
self._map = ui.leaflet(
center=(52.5, 6.0), zoom=9
).classes('w-full h-72')
def update(self, data: Dict) -> None:
if not self._map:
return
# Remove old markers
for marker in self._markers:
try:
self._map.remove_layer(marker)
except Exception:
pass
self._markers.clear()
# Own position
if data['adv_lat'] and data['adv_lon']:
m = self._map.marker(latlng=(data['adv_lat'], data['adv_lon']))
self._markers.append(m)
self._map.set_center((data['adv_lat'], data['adv_lon']))
# Contact markers
for key, contact in data['contacts'].items():
lat = contact.get('adv_lat', 0)
lon = contact.get('adv_lon', 0)
if lat != 0 or lon != 0:
m = self._map.marker(latlng=(lat, lon))
self._markers.append(m)

View File

@@ -0,0 +1,89 @@
"""Messages panel — filtered message display with route navigation."""
from typing import Dict, List
from nicegui import ui
from meshcore_gui.core.models import Message
class MessagesPanel:
"""Displays filtered messages in the centre column.
Messages are filtered based on channel checkboxes managed by
:class:`~meshcore_gui.gui.panels.filter_panel.FilterPanel`.
"""
def __init__(self) -> None:
self._container = None
def render(self) -> None:
with ui.card().classes('w-full'):
ui.label('💬 Messages').classes('font-bold text-gray-600')
self._container = ui.column().classes(
'w-full h-40 overflow-y-auto gap-0 text-sm font-mono '
'bg-gray-50 p-2 rounded'
)
def update(
self,
data: Dict,
channel_filters: Dict,
last_channels: List[Dict],
) -> None:
"""Refresh messages applying current filter state.
Args:
data: Snapshot dict from SharedData.
channel_filters: ``{channel_idx: checkbox, 'DM': checkbox}``
from FilterPanel.
last_channels: Channel list from FilterPanel.
"""
if not self._container:
return
channel_names = {ch['idx']: ch['name'] for ch in last_channels}
messages: List[Message] = data['messages']
# Apply filters
filtered = []
for orig_idx, msg in enumerate(messages):
if msg.channel is None:
if channel_filters.get('DM') and not channel_filters['DM'].value:
continue
else:
if msg.channel in channel_filters and not channel_filters[msg.channel].value:
continue
filtered.append((orig_idx, msg))
# Rebuild
self._container.clear()
with self._container:
for orig_idx, msg in reversed(filtered[-50:]):
direction = '' if msg.direction == 'out' else ''
ch_label = (
f"[{channel_names.get(msg.channel, f'ch{msg.channel}')}]"
if msg.channel is not None
else '[DM]'
)
path_len = msg.path_len
has_path = bool(msg.path_hashes)
if msg.direction == 'in' and path_len > 0:
hop_tag = f' [{path_len}h{"" if has_path else ""}]'
else:
hop_tag = ''
if msg.sender:
line = f"{msg.time} {direction} {ch_label}{hop_tag} {msg.sender}: {msg.text}"
else:
line = f"{msg.time} {direction} {ch_label}{hop_tag} {msg.text}"
ui.label(line).classes(
'text-xs leading-tight cursor-pointer '
'hover:bg-blue-50 rounded px-1'
).on('click', lambda e, i=orig_idx: ui.navigate.to(
f'/route/{i}', new_tab=True
))

View File

@@ -0,0 +1,41 @@
"""RX log panel — table of recently received packets."""
from typing import Dict, List
from nicegui import ui
from meshcore_gui.core.models import RxLogEntry
class RxLogPanel:
"""RX log table in the right column."""
def __init__(self) -> None:
self._table = None
def render(self) -> None:
with ui.card().classes('w-full'):
ui.label('📊 RX Log').classes('font-bold text-gray-600')
self._table = ui.table(
columns=[
{'name': 'time', 'label': 'Time', 'field': 'time'},
{'name': 'snr', 'label': 'SNR', 'field': 'snr'},
{'name': 'type', 'label': 'Type', 'field': 'type'},
],
rows=[],
).props('dense flat').classes('text-xs max-h-48 overflow-y-auto')
def update(self, data: Dict) -> None:
if not self._table:
return
entries: List[RxLogEntry] = data['rx_log'][:20]
rows = [
{
'time': e.time,
'snr': f"{e.snr:.1f}",
'type': e.payload_type,
}
for e in entries
]
self._table.rows = rows
self._table.update()

View File

@@ -0,0 +1,312 @@
"""
Route visualization page for MeshCore GUI.
Standalone NiceGUI page that opens in a new browser tab when a user
clicks on a message. Shows a Leaflet map with the message route,
a hop count summary, and a details table.
v4.1 changes
~~~~~~~~~~~~~
- Uses :class:`~meshcore_gui.models.Message` and
:class:`~meshcore_gui.models.RouteNode` instead of plain dicts.
"""
from typing import Dict, List
from nicegui import ui
from meshcore_gui.gui.constants import TYPE_LABELS
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RouteNode
from meshcore_gui.services.route_builder import RouteBuilder
from meshcore_gui.core.protocols import SharedDataReadAndLookup
class RoutePage:
"""
Route visualization page rendered at ``/route/{msg_index}``.
Args:
shared: SharedDataReadAndLookup for data access and contact lookups
"""
def __init__(self, shared: SharedDataReadAndLookup) -> None:
self._shared = shared
self._builder = RouteBuilder(shared)
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def render(self, msg_index: int) -> None:
data = self._shared.get_snapshot()
messages: List[Message] = data['messages']
if msg_index < 0 or msg_index >= len(messages):
ui.label('❌ Message not found').classes('text-xl p-8')
return
msg = messages[msg_index]
route = self._builder.build(msg, data)
ui.page_title(f'Route — {msg.sender or "Unknown"}')
ui.dark_mode(False)
with ui.header().classes('bg-blue-600 text-white'):
ui.label('🗺️ MeshCore Route').classes('text-xl font-bold')
with ui.column().classes('w-full max-w-4xl mx-auto p-4 gap-4'):
self._render_message_info(msg)
self._render_hop_summary(msg, route)
self._render_map(data, route)
self._render_send_panel(msg, route, data)
self._render_route_table(msg, data, route)
# ------------------------------------------------------------------
# Private — sub-sections
# ------------------------------------------------------------------
@staticmethod
def _render_message_info(msg: Message) -> None:
sender = msg.sender or 'Unknown'
direction = '→ Sent' if msg.direction == 'out' else '← Received'
ui.label(f'Message Route — {sender} ({direction})').classes('font-bold text-lg')
ui.label(
f"{msg.time} {sender}: {msg.text[:120]}"
).classes('text-sm text-gray-600')
@staticmethod
def _render_hop_summary(msg: Message, route: Dict) -> None:
msg_path_len = route['msg_path_len']
path_nodes: List[RouteNode] = route['path_nodes']
resolved_hops = len(path_nodes)
path_source = route.get('path_source', 'none')
expected_repeaters = max(msg_path_len - 1, 0)
with ui.card().classes('w-full'):
with ui.row().classes('items-center gap-4'):
if msg.direction == 'in':
if msg_path_len == 0:
ui.label('📡 Direct (0 hops)').classes(
'text-lg font-bold text-green-600'
)
else:
hop_text = '1 hop' if msg_path_len == 1 else f'{msg_path_len} hops'
ui.label(f'📡 {hop_text}').classes(
'text-lg font-bold text-blue-600'
)
else:
ui.label('📡 Outgoing message').classes(
'text-lg font-bold text-gray-600'
)
if route['snr'] is not None:
ui.label(
f'📶 SNR: {route["snr"]:.1f} dB'
).classes('text-sm text-gray-600')
if expected_repeaters > 0 and resolved_hops > 0:
source_label = (
'from received packet'
if path_source == 'rx_log'
else 'from stored contact route'
)
rpt = 'repeater' if expected_repeaters == 1 else 'repeaters'
ui.label(
f'{resolved_hops} of {expected_repeaters} '
f'{rpt} identified ({source_label})'
).classes('text-xs text-gray-500 mt-1')
elif msg_path_len > 0 and resolved_hops == 0:
ui.label(
f' {msg_path_len} '
f'hop{"s" if msg_path_len != 1 else ""}'
f'repeater identities not resolved'
).classes('text-xs text-gray-500 mt-1')
@staticmethod
def _render_map(data: Dict, route: Dict) -> None:
"""Leaflet map with route markers and polylines."""
with ui.card().classes('w-full'):
if not route['has_locations']:
ui.label(
'📍 No location data available for map display'
).classes('text-gray-500 italic p-4')
return
center_lat = data['adv_lat'] or 52.5
center_lon = data['adv_lon'] or 6.0
route_map = ui.leaflet(
center=(center_lat, center_lon), zoom=10
).classes('w-full h-96')
# Build ordered list of positions (or None)
ordered = []
sender: RouteNode = route['sender']
if sender:
ordered.append((sender.lat, sender.lon) if sender.has_location else None)
else:
ordered.append(None)
for node in route['path_nodes']:
ordered.append((node.lat, node.lon) if node.has_location else None)
self_node: RouteNode = route['self_node']
if self_node.has_location:
ordered.append((self_node.lat, self_node.lon))
else:
ordered.append(None)
all_points = [p for p in ordered if p is not None]
for lat, lon in all_points:
route_map.marker(latlng=(lat, lon))
if len(all_points) >= 2:
route_map.generic_layer(
name='polyline',
args=[all_points, {'color': '#2563eb', 'weight': 3}],
)
if all_points:
lats = [p[0] for p in all_points]
lons = [p[1] for p in all_points]
route_map.set_center(
(sum(lats) / len(lats), sum(lons) / len(lons))
)
@staticmethod
def _render_route_table(msg: Message, data: Dict, route: Dict) -> None:
msg_path_len = route['msg_path_len']
path_nodes: List[RouteNode] = route['path_nodes']
resolved_hops = len(path_nodes)
path_source = route.get('path_source', 'none')
with ui.card().classes('w-full'):
ui.label('📋 Route Details').classes('font-bold text-gray-600')
rows = []
# Sender
sender: RouteNode = route['sender']
if sender:
rows.append({
'hop': 'Start',
'name': sender.name,
'hash': sender.pubkey[:2].upper() if sender.pubkey else '-',
'type': TYPE_LABELS.get(sender.type, '-'),
'location': f"{sender.lat:.4f}, {sender.lon:.4f}" if sender.has_location else '-',
'role': '📱 Sender',
})
else:
rows.append({
'hop': 'Start',
'name': msg.sender or 'Unknown',
'hash': msg.sender_pubkey[:2].upper() if msg.sender_pubkey else '-',
'type': '-',
'location': '-',
'role': '📱 Sender',
})
# Repeaters
for i, node in enumerate(path_nodes):
rows.append({
'hop': str(i + 1),
'name': node.name,
'hash': node.pubkey[:2].upper() if node.pubkey else '-',
'type': TYPE_LABELS.get(node.type, '-'),
'location': f"{node.lat:.4f}, {node.lon:.4f}" if node.has_location else '-',
'role': '📡 Repeater',
})
# Placeholder rows
if not path_nodes and msg_path_len > 0:
for i in range(msg_path_len):
rows.append({
'hop': str(i + 1),
'name': '-', 'hash': '-', 'type': '-',
'location': '-', 'role': '📡 Repeater',
})
# Own position
self_node: RouteNode = route['self_node']
rows.append({
'hop': 'End',
'name': self_node.name,
'hash': '-',
'type': 'Companion',
'location': f"{self_node.lat:.4f}, {self_node.lon:.4f}" if self_node.has_location else '-',
'role': '📱 Receiver' if msg.direction == 'in' else '📱 Sender',
})
ui.table(
columns=[
{'name': 'hop', 'label': 'Hop', 'field': 'hop', 'align': 'center'},
{'name': 'role', 'label': 'Role', 'field': 'role'},
{'name': 'name', 'label': 'Name', 'field': 'name'},
{'name': 'hash', 'label': 'ID', 'field': 'hash', 'align': 'center'},
{'name': 'type', 'label': 'Type', 'field': 'type'},
{'name': 'location', 'label': 'Location', 'field': 'location'},
],
rows=rows,
).props('dense flat bordered').classes('w-full')
# Footnotes
if msg_path_len == 0 and msg.direction == 'in':
ui.label(
' Direct message — no intermediate hops.'
).classes('text-xs text-gray-400 italic mt-2')
elif path_source == 'rx_log':
ui.label(
' Path extracted from received LoRa packet (RX_LOG). '
'Each ID is the first byte of a node\'s public key.'
).classes('text-xs text-gray-400 italic mt-2')
elif path_source == 'contact_out_path':
ui.label(
' Path from sender\'s stored contact route (out_path). '
'Last known route, not necessarily this message\'s path.'
).classes('text-xs text-gray-400 italic mt-2')
elif msg_path_len > 0 and resolved_hops == 0:
ui.label(
' Repeater identities could not be resolved.'
).classes('text-xs text-gray-400 italic mt-2')
elif msg.direction == 'out':
ui.label(
' Hop information is only available for received messages.'
).classes('text-xs text-gray-400 italic mt-2')
def _render_send_panel(
self, msg: Message, route: Dict, data: Dict,
) -> None:
"""Send widget pre-filled with route acknowledgement message."""
path_hashes = msg.path_hashes
parts = [f"@[{msg.sender or 'Unknown'}] Received in Zwolle path({msg.path_len})"]
if path_hashes:
path_str = '>'.join(h.upper() for h in path_hashes)
parts.append(f"; {path_str}")
prefilled = ''.join(parts)
ch_options = {
ch['idx']: f"[{ch['idx']}] {ch['name']}"
for ch in data['channels']
}
default_ch = data['channels'][0]['idx'] if data['channels'] else 0
with ui.card().classes('w-full'):
ui.label('📤 Reply').classes('font-bold text-gray-600')
with ui.row().classes('w-full items-center gap-2'):
msg_input = ui.input(value=prefilled).classes('flex-grow')
ch_select = ui.select(options=ch_options, value=default_ch).classes('w-32')
def send(inp=msg_input, sel=ch_select):
text = inp.value
if text:
self._shared.put_command({
'action': 'send_message',
'channel': sel.value,
'text': text,
})
inp.value = ''
ui.button('Send', on_click=send).classes('bg-blue-500 text-white')

View File

@@ -0,0 +1,3 @@
"""
Business logic services — bot, deduplication and route building.
"""

View File

@@ -0,0 +1,195 @@
"""
Keyword-triggered auto-reply bot for MeshCore GUI.
Extracted from BLEWorker to satisfy the Single Responsibility Principle.
The bot listens on a configured channel and replies to messages that
contain recognised keywords.
Open/Closed
~~~~~~~~~~~
New keywords are added via ``BotConfig.keywords`` (data) without
modifying the ``MeshBot`` class (code). Custom matching strategies
can be implemented by subclassing and overriding ``_match_keyword``.
"""
import time
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional
from meshcore_gui.config import debug_print
# ==============================================================================
# Bot defaults (previously in config.py)
# ==============================================================================
# Channel indices the bot listens on (must match CHANNELS_CONFIG).
BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot
# Display name prepended to every bot reply.
BOT_NAME: str = "Zwolle Bot"
# Minimum seconds between two bot replies (prevents reply-storms).
BOT_COOLDOWN_SECONDS: float = 5.0
# Keyword → reply template mapping.
# Available variables: {bot}, {sender}, {snr}, {path}
# The bot checks whether the incoming message text *contains* the keyword
# (case-insensitive). First match wins.
BOT_KEYWORDS: Dict[str, str] = {
'test': '{bot}: {sender}, rcvd | SNR {snr} | {path}',
'ping': '{bot}: Pong!',
'help': '{bot}: test, ping, help',
}
@dataclass
class BotConfig:
"""Configuration for :class:`MeshBot`.
Attributes:
channels: Channel indices to listen on.
name: Display name prepended to replies.
cooldown_seconds: Minimum seconds between replies.
keywords: Keyword → reply template mapping.
"""
channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS))
name: str = BOT_NAME
cooldown_seconds: float = BOT_COOLDOWN_SECONDS
keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS))
class MeshBot:
"""Keyword-triggered auto-reply bot.
The bot checks incoming messages against a set of keyword → template
pairs. When a keyword is found (case-insensitive substring match,
first match wins), the template is expanded and queued as a channel
message via *command_sink*.
Args:
config: Bot configuration.
command_sink: Callable that enqueues a command dict for the
BLE worker (typically ``shared.put_command``).
enabled_check: Callable that returns ``True`` when the bot is
enabled (typically ``shared.is_bot_enabled``).
"""
def __init__(
self,
config: BotConfig,
command_sink: Callable[[Dict], None],
enabled_check: Callable[[], bool],
) -> None:
self._config = config
self._sink = command_sink
self._enabled = enabled_check
self._last_reply: float = 0.0
def check_and_reply(
self,
sender: str,
text: str,
channel_idx: Optional[int],
snr: Optional[float],
path_len: int,
path_hashes: Optional[List[str]] = None,
) -> None:
"""Evaluate an incoming message and queue a reply if appropriate.
Guards (in order):
1. Bot is enabled (checkbox in GUI).
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.
6. Message text contains a recognised keyword.
"""
# Guard 1: enabled?
if not self._enabled():
return
# Guard 2: correct channel?
if channel_idx not in self._config.channels:
return
# Guard 3: own messages?
if sender == "Me" or (text and text.startswith(self._config.name)):
return
# Guard 4: other bots?
if sender and sender.rstrip().lower().endswith("bot"):
debug_print(f"BOT: skipping message from other bot '{sender}'")
return
# Guard 5: cooldown?
now = time.time()
if now - self._last_reply < self._config.cooldown_seconds:
debug_print("BOT: cooldown active, skipping")
return
# Guard 6: keyword match
template = self._match_keyword(text)
if template is None:
return
# Build reply
path_str = self._format_path(path_len, path_hashes)
snr_str = f"{snr:.1f}" if snr is not None else "?"
reply = template.format(
bot=self._config.name,
sender=sender or "?",
snr=snr_str,
path=path_str,
)
self._last_reply = now
self._sink({
"action": "send_message",
"channel": channel_idx,
"text": reply,
"_bot": True,
})
debug_print(f"BOT: queued reply to '{sender}': {reply}")
# ------------------------------------------------------------------
# Extension point (OCP)
# ------------------------------------------------------------------
def _match_keyword(self, text: str) -> Optional[str]:
"""Return the reply template for the first matching keyword.
Override this method for custom matching strategies (regex,
exact match, priority ordering, etc.).
Returns:
Template string, or ``None`` if no keyword matched.
"""
text_lower = (text or "").lower()
for keyword, template in self._config.keywords.items():
if keyword in text_lower:
return template
return None
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _format_path(
path_len: int,
path_hashes: Optional[List[str]],
) -> str:
"""Format path info as ``path(N); 8D>A8`` or ``path(0)``."""
if not path_len:
return "path(0)"
if not path_hashes:
return f"path({path_len})"
hop_names = [h.upper() for h in path_hashes if h and len(h) >= 2]
if hop_names:
return f"path({path_len}); {'>'.join(hop_names)}"
return f"path({path_len})"

View File

@@ -0,0 +1,108 @@
"""
Message deduplication for MeshCore GUI.
Extracted from BLEWorker to satisfy the Single Responsibility Principle.
Provides bounded-size deduplication via message hash and content keys.
Two strategies are used because the two event sources carry different
identifiers:
1. **Hash-based** — ``RX_LOG_DATA`` events produce a deterministic
``message_hash``. When ``CHANNEL_MSG_RECV`` arrives for the same
packet, it is suppressed.
2. **Content-based** — ``CHANNEL_MSG_RECV`` events do *not* include
``message_hash``, so a composite key of ``channel:sender:text`` is
used as a fallback.
Both stores are bounded to prevent unbounded memory growth.
"""
from collections import OrderedDict
class MessageDeduplicator:
"""Bounded-size message deduplication store.
Uses an :class:`OrderedDict` as an LRU-style bounded set.
Oldest entries are evicted when the store exceeds ``max_size``.
Args:
max_size: Maximum number of keys to retain. 200 is generous
for the typical message rate of a mesh network.
"""
def __init__(self, max_size: int = 200) -> None:
self._max = max_size
self._seen: OrderedDict[str, None] = OrderedDict()
def is_seen(self, key: str) -> bool:
"""Check if a key has already been recorded."""
return key in self._seen
def mark(self, key: str) -> None:
"""Record a key. Evicts the oldest entry if at capacity."""
if key in self._seen:
# Move to end (most recent)
self._seen.move_to_end(key)
return
self._seen[key] = None
while len(self._seen) > self._max:
self._seen.popitem(last=False)
def clear(self) -> None:
"""Remove all recorded keys."""
self._seen.clear()
def __len__(self) -> int:
return len(self._seen)
class DualDeduplicator:
"""Combined hash-based and content-based deduplication.
Wraps two :class:`MessageDeduplicator` instances — one for
message hashes and one for content keys — behind a single
interface.
Args:
max_size: Maximum entries per store.
"""
def __init__(self, max_size: int = 200) -> None:
self._by_hash = MessageDeduplicator(max_size)
self._by_content = MessageDeduplicator(max_size)
# -- Hash-based --
def mark_hash(self, message_hash: str) -> None:
"""Record a message hash as processed."""
if message_hash:
self._by_hash.mark(message_hash)
def is_hash_seen(self, message_hash: str) -> bool:
"""Check if a message hash has already been processed."""
return bool(message_hash) and self._by_hash.is_seen(message_hash)
# -- Content-based --
def mark_content(self, sender: str, channel, text: str) -> None:
"""Record a content key as processed."""
key = self._content_key(sender, channel, text)
self._by_content.mark(key)
def is_content_seen(self, sender: str, channel, text: str) -> bool:
"""Check if a content key has already been processed."""
key = self._content_key(sender, channel, text)
return self._by_content.is_seen(key)
# -- Bulk --
def clear(self) -> None:
"""Clear both stores."""
self._by_hash.clear()
self._by_content.clear()
@staticmethod
def _content_key(sender: str, channel, text: str) -> str:
return f"{channel}:{sender}:{text}"

View File

@@ -0,0 +1,214 @@
"""
Route data builder for MeshCore GUI.
Pure data logic — no UI code. Given a message and a data snapshot, this
module constructs a route dictionary that describes the path the message
has taken through the mesh network (sender → repeaters → receiver).
v4.1 changes
~~~~~~~~~~~~~
- ``build()`` now accepts a :class:`~meshcore_gui.models.Message`
dataclass instead of a plain dict.
- Route nodes returned as :class:`~meshcore_gui.models.RouteNode`.
"""
from typing import Dict, List, Optional
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RouteNode
from meshcore_gui.core.protocols import ContactLookup
class RouteBuilder:
"""
Builds route data for a message from available contact information.
Uses only data already in memory — no extra BLE commands are sent.
Args:
shared: ContactLookup for resolving pubkey prefixes to contacts
"""
def __init__(self, shared: ContactLookup) -> None:
self._shared = shared
def build(self, msg: Message, data: Dict) -> Dict:
"""
Build route data for a single message.
Args:
msg: Message dataclass instance.
data: Snapshot dictionary from SharedData.get_snapshot().
Returns:
Dictionary with keys:
sender: RouteNode or None
self_node: RouteNode
path_nodes: List[RouteNode]
snr: float or None
msg_path_len: int — hop count from the message itself
has_locations: bool — True if any node has GPS coords
path_source: str — 'rx_log', 'contact_out_path' or 'none'
"""
result: Dict = {
'sender': None,
'self_node': RouteNode(
name=data['name'] or 'Me',
lat=data['adv_lat'],
lon=data['adv_lon'],
),
'path_nodes': [],
'snr': msg.snr,
'msg_path_len': msg.path_len,
'has_locations': False,
'path_source': 'none',
}
# Look up sender in contacts
pubkey = msg.sender_pubkey
contact: Optional[Dict] = None
debug_print(
f"Route build: sender_pubkey={pubkey!r} "
f"(len={len(pubkey)}, first2={pubkey[:2]!r})"
)
if pubkey:
contact = self._shared.get_contact_by_prefix(pubkey)
debug_print(
f"Route build: contact lookup "
f"{'FOUND ' + contact.get('adv_name', '?') if contact else 'NOT FOUND'}"
)
if contact:
result['sender'] = RouteNode(
name=contact.get('adv_name', pubkey[:8]),
lat=contact.get('adv_lat', 0),
lon=contact.get('adv_lon', 0),
type=contact.get('type', 0),
pubkey=pubkey,
)
else:
# Deferred sender lookup: try fuzzy name match
sender_name = msg.sender
if sender_name:
match = self._shared.get_contact_by_name(sender_name)
if match:
pubkey, contact_data = match
contact = contact_data
result['sender'] = RouteNode(
name=contact_data.get('adv_name', pubkey[:8]),
lat=contact_data.get('adv_lat', 0),
lon=contact_data.get('adv_lon', 0),
type=contact_data.get('type', 0),
pubkey=pubkey,
)
debug_print(
f"Route build: deferred name lookup "
f"'{sender_name}' → pubkey={pubkey[:16]!r}"
)
# --- Resolve path nodes (priority order) ---
# Priority 1: path_hashes from RX_LOG decode
rx_hashes = msg.path_hashes
if rx_hashes:
result['path_nodes'] = self._resolve_hashes(
rx_hashes, data['contacts'],
)
result['path_source'] = 'rx_log'
debug_print(
f"Route from RX_LOG: {len(rx_hashes)} hashes → "
f"{len(result['path_nodes'])} nodes"
)
# Priority 2: out_path from sender's contact record
elif contact:
out_path = contact.get('out_path', '')
out_path_len = contact.get('out_path_len', 0)
debug_print(
f"Route: sender={contact.get('adv_name')}, "
f"out_path={out_path!r}, out_path_len={out_path_len}, "
f"msg_path_len={result['msg_path_len']}"
)
if out_path and out_path_len and out_path_len > 0:
result['path_nodes'] = self._parse_out_path(
out_path, out_path_len, data['contacts'],
)
result['path_source'] = 'contact_out_path'
# Determine if any node has GPS coordinates
all_nodes: List[RouteNode] = [result['self_node']]
if result['sender']:
all_nodes.append(result['sender'])
all_nodes.extend(result['path_nodes'])
result['has_locations'] = any(n.has_location for n in all_nodes)
return result
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _resolve_hashes(
hashes: List[str],
contacts: Dict,
) -> List[RouteNode]:
"""Resolve a list of 1-byte path hashes into RouteNode objects."""
nodes: List[RouteNode] = []
for hop_hash in hashes:
if not hop_hash or len(hop_hash) < 2:
continue
hop_contact = RouteBuilder._find_contact_by_pubkey_hash(
hop_hash, contacts,
)
if hop_contact:
nodes.append(RouteNode(
name=hop_contact.get('adv_name', f'0x{hop_hash}'),
lat=hop_contact.get('adv_lat', 0),
lon=hop_contact.get('adv_lon', 0),
type=hop_contact.get('type', 0),
pubkey=hop_hash,
))
else:
nodes.append(RouteNode(
name='-',
pubkey=hop_hash,
))
return nodes
@staticmethod
def _parse_out_path(
out_path: str,
out_path_len: int,
contacts: Dict,
) -> List[RouteNode]:
"""Parse out_path hex string into a list of RouteNode objects."""
hashes: List[str] = []
hop_hex_len = 2
for i in range(0, min(len(out_path), out_path_len * 2), hop_hex_len):
hop_hash = out_path[i:i + hop_hex_len]
if hop_hash and len(hop_hash) == 2:
hashes.append(hop_hash)
return RouteBuilder._resolve_hashes(hashes, contacts)
@staticmethod
def _find_contact_by_pubkey_hash(
hash_hex: str, contacts: Dict,
) -> Optional[Dict]:
hash_hex = hash_hex.lower()
for pubkey, contact in contacts.items():
if pubkey.lower().startswith(hash_hex):
return contact
return None

12
tools/ble_observe.py Normal file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env python3
from pathlib import Path
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = REPO_ROOT / "src"
sys.path.insert(0, str(SRC_PATH))
from mc_tools.ble_observe.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,10 @@
"""BLE observe tool entrypoint.
Usage:
python -m tools.ble_observe [options]
"""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

108
tools/ble_observe/cli.py Normal file
View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
# Ensure local src/ is importable
REPO_ROOT = Path(__file__).resolve().parents[2]
SRC_PATH = REPO_ROOT / "src"
if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH))
from transport import (
BleakTransport,
ensure_exclusive_access,
OwnershipError,
DiscoveryError,
ConnectionError,
NotificationError,
exitcodes,
)
NUS_CHAR_NOTIFY_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"
NUS_CHAR_WRITE_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # host -> device (Companion protocol write)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="ble-observe", description="MeshCore BLE observe (read-only)")
p.add_argument("--scan-only", action="store_true", help="Only scan and list devices")
p.add_argument("--address", type=str, help="BLE address (e.g. FF:05:D6:71:83:8D)")
p.add_argument("--scan-seconds", type=float, default=5.0, help="Scan duration")
p.add_argument("--pre-scan-seconds", type=float, default=5.0, help="Pre-scan before connect")
p.add_argument("--connect-timeout", type=float, default=20.0, help="Connect timeout")
p.add_argument("--notify", action="store_true", help="Listen for notifications (read-only)")
p.add_argument("--app-start", action="store_true", help="Send CMD_APP_START (0x01) before enabling notify (protocol write)")
p.add_argument("--notify-seconds", type=float, default=10.0, help="Notify listen duration")
return p
async def scan(scan_seconds: float) -> int:
t = BleakTransport()
devices = await t.discover(timeout=scan_seconds)
for d in devices:
name = d.name or ""
rssi = "" if d.rssi is None else str(d.rssi)
print(f"{d.address}\t{name}\t{rssi}")
return exitcodes.OK
async def observe(address: str, *, pre_scan: float, connect_timeout: float, notify: bool, notify_seconds: float, app_start: bool) -> int:
await ensure_exclusive_access(address, pre_scan_seconds=pre_scan)
t = BleakTransport(allow_write=bool(app_start))
await t.connect(address, timeout=connect_timeout)
try:
services = await t.get_services()
print("SERVICES:")
for svc in services:
print(f"- {svc.uuid}")
if notify:
if app_start:
# Companion BLE handshake: CMD_APP_START (0x01)
await t.write(NUS_CHAR_WRITE_UUID, bytes([0x01]), response=False)
await asyncio.sleep(0.1)
def on_rx(data: bytearray) -> None:
print(data.hex())
await t.start_notify(NUS_CHAR_NOTIFY_UUID, on_rx)
await asyncio.sleep(notify_seconds)
await t.stop_notify(NUS_CHAR_NOTIFY_UUID)
return exitcodes.OK
finally:
await t.disconnect()
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.scan_only:
return asyncio.run(scan(args.scan_seconds))
if not args.address:
print("ERROR: --address required unless --scan-only", file=sys.stderr)
return exitcodes.USAGE
return asyncio.run(
observe(
args.address,
pre_scan=args.pre_scan_seconds,
connect_timeout=args.connect_timeout,
notify=args.notify,
notify_seconds=args.notify_seconds,
app_start=args.app_start,
)
)
except OwnershipError as exc:
print(f"ERROR(OWNERSHIP): {exc}", file=sys.stderr)
return exitcodes.OWNERSHIP
except DiscoveryError as exc:
print(f"ERROR(DISCOVERY): {exc}", file=sys.stderr)
return exitcodes.DISCOVERY
except ConnectionError as exc:
print(f"ERROR(CONNECT): {exc}", file=sys.stderr)
return exitcodes.CONNECT
except NotificationError as exc:
print(f"ERROR(NOTIFY): {exc}", file=sys.stderr)
return exitcodes.NOTIFY
except KeyboardInterrupt:
print("Interrupted", file=sys.stderr)
return 130
except Exception as exc:
print(f"ERROR(INTERNAL): {exc}", file=sys.stderr)
return exitcodes.INTERNAL