mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-05-02 19:42:29 +02:00
Refactoring part 1
This commit is contained in:
37
README.md
37
README.md
@@ -1,11 +1,13 @@
|
||||
# MeshCore GUI
|
||||
|
||||
A graphical user interface for MeshCore mesh network devices via Bluetooth Low Energy (BLE) for on your desktop.
|
||||
|
||||

|
||||
> ⚠️ **This branch is in active development and testing. It is not production-ready. Use at your own risk. **
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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
142
RELEASE.md
Normal 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) |
|
||||
Binary file not shown.
219
docs/SOLID_ANALYSIS.md
Normal file
219
docs/SOLID_ANALYSIS.md
Normal 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 |
|
||||
113
meshcore-gui/meshcore_gui.py
Normal file
113
meshcore-gui/meshcore_gui.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/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
|
||||
|
||||
Author: PE1HVH
|
||||
Version: 3.2
|
||||
SPDX-License-Identifier: MIT
|
||||
Copyright: (c) 2026 PE1HVH
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
# Allow overriding DEBUG and CHANNELS_CONFIG before anything imports them
|
||||
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.main_page import DashboardPage
|
||||
from meshcore_gui.route_page import RoutePage
|
||||
from meshcore_gui.shared_data import SharedData
|
||||
|
||||
|
||||
# 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()
|
||||
8
meshcore-gui/meshcore_gui/__init__.py
Normal file
8
meshcore-gui/meshcore_gui/__init__.py
Normal 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__ = "3.1"
|
||||
252
meshcore-gui/meshcore_gui/ble_worker.py
Normal file
252
meshcore-gui/meshcore_gui/ble_worker.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
BLE communication worker for MeshCore GUI.
|
||||
|
||||
Runs in a separate thread with its own asyncio event loop. Connects to
|
||||
the MeshCore device, subscribes to events, and processes commands sent
|
||||
from the GUI via the SharedData command queue.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
|
||||
from meshcore import MeshCore, EventType
|
||||
|
||||
from meshcore_gui.config import CHANNELS_CONFIG, debug_print
|
||||
from meshcore_gui.protocols import SharedDataWriter
|
||||
|
||||
|
||||
class BLEWorker:
|
||||
"""
|
||||
BLE communication worker that runs in a separate thread.
|
||||
|
||||
Attributes:
|
||||
address: BLE MAC address of the device
|
||||
shared: SharedDataWriter for thread-safe communication
|
||||
mc: MeshCore instance after connection
|
||||
running: Boolean to control the worker loop
|
||||
"""
|
||||
|
||||
def __init__(self, address: str, shared: SharedDataWriter) -> None:
|
||||
self.address = address
|
||||
self.shared = shared
|
||||
self.mc: Optional[MeshCore] = None
|
||||
self.running = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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:
|
||||
"""Entry point for the worker thread."""
|
||||
asyncio.run(self._async_main())
|
||||
|
||||
async def _async_main(self) -> None:
|
||||
"""Connect, then process commands in an infinite loop."""
|
||||
await self._connect()
|
||||
if self.mc:
|
||||
while self.running:
|
||||
await self._process_commands()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""Connect to the BLE device and load initial data."""
|
||||
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)
|
||||
|
||||
# Subscribe to events
|
||||
self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_msg)
|
||||
self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_contact_msg)
|
||||
self.mc.subscribe(EventType.RX_LOG_DATA, self._on_rx_log)
|
||||
|
||||
await self._load_data()
|
||||
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}")
|
||||
|
||||
async def _load_data(self) -> None:
|
||||
"""
|
||||
Load device data with retry mechanism.
|
||||
|
||||
Tries send_appstart and send_device_query each up to 5 times.
|
||||
Channels come from hardcoded config.
|
||||
"""
|
||||
# send_appstart
|
||||
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
|
||||
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")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command handling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _process_commands(self) -> None:
|
||||
"""Process all commands queued by the GUI."""
|
||||
while True:
|
||||
cmd = self.shared.get_next_command()
|
||||
if cmd is None:
|
||||
break
|
||||
await self._handle_command(cmd)
|
||||
|
||||
async def _handle_command(self, cmd: Dict) -> None:
|
||||
"""
|
||||
Process a single command from the GUI.
|
||||
|
||||
Supported actions: send_message, send_dm, send_advert, refresh.
|
||||
"""
|
||||
action = cmd.get('action')
|
||||
|
||||
if action == 'send_message':
|
||||
channel = cmd.get('channel', 0)
|
||||
text = cmd.get('text', '')
|
||||
if text and self.mc:
|
||||
await self.mc.commands.send_chan_msg(channel, text)
|
||||
self.shared.add_message({
|
||||
'time': datetime.now().strftime('%H:%M:%S'),
|
||||
'sender': 'Me',
|
||||
'text': text,
|
||||
'channel': channel,
|
||||
'direction': 'out',
|
||||
'sender_pubkey': '',
|
||||
})
|
||||
debug_print(f"Sent message to channel {channel}: {text[:30]}")
|
||||
|
||||
elif action == 'send_advert':
|
||||
if self.mc:
|
||||
await self.mc.commands.send_advert(flood=True)
|
||||
self.shared.set_status("📢 Advert sent")
|
||||
debug_print("Advert sent")
|
||||
|
||||
elif action == 'send_dm':
|
||||
pubkey = cmd.get('pubkey', '')
|
||||
text = cmd.get('text', '')
|
||||
contact_name = cmd.get('contact_name', pubkey[:8])
|
||||
if text and pubkey and self.mc:
|
||||
await self.mc.commands.send_msg(pubkey, text)
|
||||
self.shared.add_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]}")
|
||||
|
||||
elif action == 'refresh':
|
||||
if self.mc:
|
||||
debug_print("Refresh requested")
|
||||
await self._load_data()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callbacks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_channel_msg(self, event) -> None:
|
||||
"""Callback for received channel messages."""
|
||||
payload = event.payload
|
||||
sender = payload.get('sender_name') or payload.get('sender') or ''
|
||||
|
||||
debug_print(f"Channel msg payload keys: {list(payload.keys())}")
|
||||
debug_print(f"Channel msg payload: {payload}")
|
||||
|
||||
self.shared.add_message({
|
||||
'time': datetime.now().strftime('%H:%M:%S'),
|
||||
'sender': sender[:15] if sender else '',
|
||||
'text': payload.get('text', ''),
|
||||
'channel': payload.get('channel_idx'),
|
||||
'direction': 'in',
|
||||
'snr': payload.get('SNR') or payload.get('snr'),
|
||||
'path_len': payload.get('path_len', 0),
|
||||
'sender_pubkey': payload.get('sender', ''),
|
||||
})
|
||||
|
||||
def _on_contact_msg(self, event) -> None:
|
||||
"""Callback for received DMs; resolves sender name via pubkey."""
|
||||
payload = event.payload
|
||||
pubkey = payload.get('pubkey_prefix', '')
|
||||
sender = ''
|
||||
|
||||
debug_print(f"DM payload keys: {list(payload.keys())}")
|
||||
debug_print(f"DM payload: {payload}")
|
||||
|
||||
if pubkey:
|
||||
sender = self.shared.get_contact_name_by_prefix(pubkey)
|
||||
|
||||
if not sender:
|
||||
sender = pubkey[:8] if pubkey else ''
|
||||
|
||||
self.shared.add_message({
|
||||
'time': datetime.now().strftime('%H:%M:%S'),
|
||||
'sender': sender[:15] if sender else '',
|
||||
'text': payload.get('text', ''),
|
||||
'channel': None,
|
||||
'direction': 'in',
|
||||
'snr': payload.get('SNR') or payload.get('snr'),
|
||||
'path_len': payload.get('path_len', 0),
|
||||
'sender_pubkey': pubkey,
|
||||
})
|
||||
|
||||
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
|
||||
|
||||
def _on_rx_log(self, event) -> None:
|
||||
"""Callback for RX log data."""
|
||||
payload = event.payload
|
||||
self.shared.add_rx_log({
|
||||
'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),
|
||||
})
|
||||
57
meshcore-gui/meshcore_gui/config.py
Normal file
57
meshcore-gui/meshcore_gui/config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Configuration and shared constants for MeshCore GUI.
|
||||
|
||||
Contains:
|
||||
- Debug flag and debug_print helper
|
||||
- Channel configuration
|
||||
- Contact type mappings
|
||||
|
||||
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 = False
|
||||
|
||||
|
||||
def debug_print(msg: str) -> None:
|
||||
"""
|
||||
Print debug message if DEBUG mode is enabled.
|
||||
|
||||
Args:
|
||||
msg: The message to print
|
||||
"""
|
||||
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'},
|
||||
]
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# CONTACT TYPE MAPPINGS
|
||||
# ==============================================================================
|
||||
|
||||
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"}
|
||||
402
meshcore-gui/meshcore_gui/main_page.py
Normal file
402
meshcore-gui/meshcore_gui/main_page.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Main dashboard page for MeshCore GUI.
|
||||
|
||||
Contains the three-column layout with device info, contacts, map,
|
||||
messaging, filters and RX log. The 500 ms update timer lives here.
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from meshcore_gui.config import TYPE_ICONS, TYPE_NAMES
|
||||
from meshcore_gui.protocols import SharedDataReader
|
||||
|
||||
|
||||
class DashboardPage:
|
||||
"""
|
||||
Main dashboard rendered at ``/``.
|
||||
|
||||
Args:
|
||||
shared: SharedDataReader for data access and command dispatch
|
||||
"""
|
||||
|
||||
def __init__(self, shared: SharedDataReader) -> None:
|
||||
self._shared = shared
|
||||
|
||||
# UI element references
|
||||
self._status_label = None
|
||||
self._device_label = None
|
||||
self._channel_select = None
|
||||
self._channels_filter_container = None
|
||||
self._channel_filters: Dict = {}
|
||||
self._contacts_container = None
|
||||
self._map_widget = None
|
||||
self._messages_container = None
|
||||
self._rxlog_table = None
|
||||
self._msg_input = None
|
||||
|
||||
# Map markers tracking
|
||||
self._markers: List = []
|
||||
|
||||
# Channel data for message display
|
||||
self._last_channels: List[Dict] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self) -> None:
|
||||
"""Build the complete dashboard layout and start the timer."""
|
||||
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 columns
|
||||
with ui.row().classes('w-full h-full gap-2 p-2'):
|
||||
with ui.column().classes('w-64 gap-2'):
|
||||
self._render_device_panel()
|
||||
self._render_contacts_panel()
|
||||
|
||||
with ui.column().classes('flex-grow gap-2'):
|
||||
self._render_map_panel()
|
||||
self._render_input_panel()
|
||||
self._render_channels_filter()
|
||||
self._render_messages_panel()
|
||||
|
||||
with ui.column().classes('w-64 gap-2'):
|
||||
self._render_actions_panel()
|
||||
self._render_rxlog_panel()
|
||||
|
||||
# 500 ms update timer
|
||||
ui.timer(0.5, self._update_ui)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Panel builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _render_device_panel(self) -> None:
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('📡 Device').classes('font-bold text-gray-600')
|
||||
self._device_label = ui.label('Connecting...').classes(
|
||||
'text-sm whitespace-pre-line'
|
||||
)
|
||||
|
||||
def _render_contacts_panel(self) -> None:
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('👥 Contacts').classes('font-bold text-gray-600')
|
||||
self._contacts_container = ui.column().classes(
|
||||
'w-full gap-1 max-h-96 overflow-y-auto'
|
||||
)
|
||||
|
||||
def _render_map_panel(self) -> None:
|
||||
with ui.card().classes('w-full'):
|
||||
self._map_widget = ui.leaflet(
|
||||
center=(52.5, 6.0), zoom=9
|
||||
).classes('w-full h-72')
|
||||
|
||||
def _render_input_panel(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 _render_channels_filter(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._channels_filter_container = ui.row().classes('gap-4')
|
||||
|
||||
def _render_messages_panel(self) -> None:
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('💬 Messages').classes('font-bold text-gray-600')
|
||||
self._messages_container = ui.column().classes(
|
||||
'w-full h-40 overflow-y-auto gap-0 text-sm font-mono '
|
||||
'bg-gray-50 p-2 rounded'
|
||||
)
|
||||
|
||||
def _render_actions_panel(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._cmd_refresh)
|
||||
ui.button('📢 Advert', on_click=self._cmd_send_advert)
|
||||
|
||||
def _render_rxlog_panel(self) -> None:
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('📊 RX Log').classes('font-bold text-gray-600')
|
||||
self._rxlog_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')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer-driven UI update
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_ui(self) -> None:
|
||||
"""Periodic UI refresh — called every 500 ms."""
|
||||
try:
|
||||
if not self._status_label or not self._device_label:
|
||||
return
|
||||
|
||||
data = self._shared.get_snapshot()
|
||||
is_first = not data['gui_initialized']
|
||||
|
||||
self._status_label.text = data['status']
|
||||
|
||||
if data['device_updated'] or is_first:
|
||||
self._update_device_info(data)
|
||||
if data['channels_updated'] or is_first:
|
||||
self._update_channels(data)
|
||||
if data['contacts_updated'] or is_first:
|
||||
self._update_contacts(data)
|
||||
if data['contacts'] and (
|
||||
data['contacts_updated'] or not self._markers or is_first
|
||||
):
|
||||
self._update_map(data)
|
||||
|
||||
self._refresh_messages(data)
|
||||
|
||||
if data['rxlog_updated'] and self._rxlog_table:
|
||||
self._update_rxlog(data)
|
||||
|
||||
self._shared.clear_update_flags()
|
||||
|
||||
if is_first and data['channels'] and data['contacts']:
|
||||
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}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data → UI updaters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_device_info(self, data: Dict) -> None:
|
||||
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._device_label.text = "\n".join(lines) if lines else "Loading..."
|
||||
|
||||
def _update_channels(self, data: Dict) -> None:
|
||||
if not self._channels_filter_container or not data['channels']:
|
||||
return
|
||||
|
||||
self._channels_filter_container.clear()
|
||||
self._channel_filters = {}
|
||||
|
||||
with self._channels_filter_container:
|
||||
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']
|
||||
|
||||
if self._channel_select and data['channels']:
|
||||
opts = {
|
||||
ch['idx']: f"[{ch['idx']}] {ch['name']}"
|
||||
for ch in data['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 _update_contacts(self, data: Dict) -> None:
|
||||
if not self._contacts_container:
|
||||
return
|
||||
|
||||
self._contacts_container.clear()
|
||||
|
||||
with self._contacts_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')
|
||||
|
||||
def _update_map(self, data: Dict) -> None:
|
||||
if not self._map_widget:
|
||||
return
|
||||
|
||||
for marker in self._markers:
|
||||
try:
|
||||
self._map_widget.remove_layer(marker)
|
||||
except Exception:
|
||||
pass
|
||||
self._markers.clear()
|
||||
|
||||
if data['adv_lat'] and data['adv_lon']:
|
||||
m = self._map_widget.marker(
|
||||
latlng=(data['adv_lat'], data['adv_lon'])
|
||||
)
|
||||
self._markers.append(m)
|
||||
self._map_widget.set_center((data['adv_lat'], data['adv_lon']))
|
||||
|
||||
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_widget.marker(latlng=(lat, lon))
|
||||
self._markers.append(m)
|
||||
|
||||
def _update_rxlog(self, data: Dict) -> None:
|
||||
rows = [
|
||||
{
|
||||
'time': e['time'],
|
||||
'snr': f"{e['snr']:.1f}",
|
||||
'type': e['payload_type'],
|
||||
}
|
||||
for e in data['rx_log'][:20]
|
||||
]
|
||||
self._rxlog_table.rows = rows
|
||||
self._rxlog_table.update()
|
||||
|
||||
def _refresh_messages(self, data: Dict) -> None:
|
||||
if not self._messages_container:
|
||||
return
|
||||
|
||||
channel_names = {ch['idx']: ch['name'] for ch in self._last_channels}
|
||||
|
||||
filtered = []
|
||||
for msg in data['messages']:
|
||||
ch = msg['channel']
|
||||
if ch is None:
|
||||
if self._channel_filters.get('DM') and not self._channel_filters['DM'].value:
|
||||
continue
|
||||
else:
|
||||
if ch in self._channel_filters and not self._channel_filters[ch].value:
|
||||
continue
|
||||
filtered.append(msg)
|
||||
|
||||
self._messages_container.clear()
|
||||
|
||||
with self._messages_container:
|
||||
for msg in reversed(filtered[-50:]):
|
||||
direction = '→' if msg['direction'] == 'out' else '←'
|
||||
ch = msg['channel']
|
||||
|
||||
ch_label = (
|
||||
f"[{channel_names.get(ch, f'ch{ch}')}]"
|
||||
if ch is not None
|
||||
else '[DM]'
|
||||
)
|
||||
|
||||
sender = msg.get('sender', '')
|
||||
path_len = msg.get('path_len', 0)
|
||||
hop_tag = f' [{path_len}h]' if msg['direction'] == 'in' and path_len > 0 else ''
|
||||
|
||||
if sender:
|
||||
line = f"{msg['time']} {direction} {ch_label}{hop_tag} {sender}: {msg['text']}"
|
||||
else:
|
||||
line = f"{msg['time']} {direction} {ch_label}{hop_tag} {msg['text']}"
|
||||
|
||||
msg_idx = len(filtered) - 1 - filtered[::-1].index(msg)
|
||||
ui.label(line).classes(
|
||||
'text-xs leading-tight cursor-pointer '
|
||||
'hover:bg-blue-50 rounded px-1'
|
||||
).on('click', lambda e, i=msg_idx: ui.navigate.to(
|
||||
f'/route/{i}', new_tab=True
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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._shared.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()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _send_message(self) -> None:
|
||||
text = self._msg_input.value
|
||||
channel = self._channel_select.value
|
||||
if text:
|
||||
self._shared.put_command({
|
||||
'action': 'send_message',
|
||||
'channel': channel,
|
||||
'text': text,
|
||||
})
|
||||
self._msg_input.value = ''
|
||||
|
||||
def _cmd_send_advert(self) -> None:
|
||||
self._shared.put_command({'action': 'send_advert'})
|
||||
|
||||
def _cmd_refresh(self) -> None:
|
||||
self._shared.put_command({'action': 'refresh'})
|
||||
83
meshcore-gui/meshcore_gui/protocols.py
Normal file
83
meshcore-gui/meshcore_gui/protocols.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 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: Dict) -> None: ...
|
||||
def add_rx_log(self, entry: Dict) -> None: ...
|
||||
def get_next_command(self) -> Optional[Dict]: ...
|
||||
def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: ...
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Reader — used by DashboardPage and RoutePage
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@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: ...
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# ContactLookup — used by RouteBuilder
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@runtime_checkable
|
||||
class ContactLookup(Protocol):
|
||||
"""Contact lookup interface used by RouteBuilder.
|
||||
|
||||
RouteBuilder only needs to resolve public key prefixes to
|
||||
contact records.
|
||||
"""
|
||||
|
||||
def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: ...
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 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."""
|
||||
...
|
||||
174
meshcore-gui/meshcore_gui/route_builder.py
Normal file
174
meshcore-gui/meshcore_gui/route_builder.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
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).
|
||||
|
||||
The route information comes from two sources:
|
||||
|
||||
1. **path_len** (from the message itself) — number of hops the message
|
||||
traveled. Always available for received messages.
|
||||
|
||||
2. **out_path** (from the sender's contact record) — hex string where
|
||||
each byte (2 hex chars) is the first byte of a repeater's public
|
||||
key. Only available when the sender is a known contact with a stored
|
||||
route.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
from meshcore_gui.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: Dict, data: Dict) -> Dict:
|
||||
"""
|
||||
Build route data for a single message.
|
||||
|
||||
Args:
|
||||
msg: Message dict (must contain 'sender_pubkey', may contain
|
||||
'path_len' and 'snr')
|
||||
data: Snapshot dictionary from SharedData.get_snapshot()
|
||||
|
||||
Returns:
|
||||
Dictionary with keys:
|
||||
sender: {name, lat, lon, type, pubkey} or None
|
||||
self_node: {name, lat, lon}
|
||||
path_nodes: [{name, lat, lon, type, pubkey}, …]
|
||||
snr: float or None
|
||||
msg_path_len: int — hop count from the message itself
|
||||
has_locations: bool — True if any node has GPS coords
|
||||
"""
|
||||
result: Dict = {
|
||||
'sender': None,
|
||||
'self_node': {
|
||||
'name': data['name'] or 'Me',
|
||||
'lat': data['adv_lat'],
|
||||
'lon': data['adv_lon'],
|
||||
},
|
||||
'path_nodes': [],
|
||||
'snr': msg.get('snr'),
|
||||
'msg_path_len': msg.get('path_len', 0),
|
||||
'has_locations': False,
|
||||
}
|
||||
|
||||
# Look up sender in contacts
|
||||
pubkey = msg.get('sender_pubkey', '')
|
||||
if pubkey:
|
||||
contact = self._shared.get_contact_by_prefix(pubkey)
|
||||
if contact:
|
||||
result['sender'] = {
|
||||
'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,
|
||||
}
|
||||
|
||||
# Parse out_path for intermediate hops
|
||||
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']
|
||||
)
|
||||
|
||||
# Determine if any node has GPS coordinates
|
||||
all_points = [result['self_node']]
|
||||
if result['sender']:
|
||||
all_points.append(result['sender'])
|
||||
all_points.extend(result['path_nodes'])
|
||||
|
||||
result['has_locations'] = any(
|
||||
p.get('lat', 0) != 0 or p.get('lon', 0) != 0
|
||||
for p in all_points
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_out_path(
|
||||
out_path: str,
|
||||
out_path_len: int,
|
||||
contacts: Dict,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Parse out_path hex string into a list of hop nodes.
|
||||
|
||||
Each byte (2 hex chars) in out_path is the first byte of a
|
||||
repeater's public key.
|
||||
|
||||
Returns:
|
||||
List of hop node dicts.
|
||||
"""
|
||||
nodes: List[Dict] = []
|
||||
hop_hex_len = 2 # 1 byte = 2 hex chars
|
||||
|
||||
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 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({
|
||||
'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({
|
||||
'name': f'Unknown (0x{hop_hash})',
|
||||
'lat': 0,
|
||||
'lon': 0,
|
||||
'type': 0,
|
||||
'pubkey': hop_hash,
|
||||
})
|
||||
|
||||
return nodes
|
||||
|
||||
@staticmethod
|
||||
def _find_contact_by_pubkey_hash(
|
||||
hash_hex: str, contacts: Dict
|
||||
) -> Optional[Dict]:
|
||||
"""
|
||||
Find a contact whose pubkey starts with the given 1-byte hash.
|
||||
|
||||
Note: with only 256 possible values, collisions are possible
|
||||
when there are many contacts. Returns the first match.
|
||||
"""
|
||||
hash_hex = hash_hex.lower()
|
||||
for pubkey, contact in contacts.items():
|
||||
if pubkey.lower().startswith(hash_hex):
|
||||
return contact
|
||||
return None
|
||||
258
meshcore-gui/meshcore_gui/route_page.py
Normal file
258
meshcore-gui/meshcore_gui/route_page.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from meshcore_gui.config import TYPE_LABELS
|
||||
from meshcore_gui.route_builder import RouteBuilder
|
||||
from meshcore_gui.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:
|
||||
"""
|
||||
Render the route page for a specific message.
|
||||
|
||||
Args:
|
||||
msg_index: Index into SharedData.messages list
|
||||
"""
|
||||
data = self._shared.get_snapshot()
|
||||
|
||||
# Validate
|
||||
if msg_index < 0 or msg_index >= len(data['messages']):
|
||||
ui.label('❌ Message not found').classes('text-xl p-8')
|
||||
return
|
||||
|
||||
msg = data['messages'][msg_index]
|
||||
route = self._builder.build(msg, data)
|
||||
|
||||
ui.dark_mode(False)
|
||||
|
||||
# Header
|
||||
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_route_table(msg, data, route)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private — sub-sections
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _render_message_info(msg: Dict) -> None:
|
||||
"""Message header with direction and text."""
|
||||
direction = '→ Sent' if msg['direction'] == 'out' else '← Received'
|
||||
ui.label(f'Message Route — {direction}').classes('font-bold text-lg')
|
||||
ui.label(
|
||||
f"{msg['time']} {msg.get('sender', '')}: "
|
||||
f"{msg['text'][:120]}"
|
||||
).classes('text-sm text-gray-600')
|
||||
|
||||
@staticmethod
|
||||
def _render_hop_summary(msg: Dict, route: Dict) -> None:
|
||||
"""Hop count banner with SNR."""
|
||||
msg_path_len = route['msg_path_len']
|
||||
resolved_hops = len(route['path_nodes'])
|
||||
|
||||
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')
|
||||
|
||||
# Resolution status
|
||||
if msg_path_len > 0 and resolved_hops > 0:
|
||||
ui.label(
|
||||
f'✅ {resolved_hops} of {msg_path_len} '
|
||||
f'repeater{"s" if msg_path_len != 1 else ""} identified'
|
||||
).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 '
|
||||
f'(not in out_path or not in contacts)'
|
||||
).classes('text-xs text-gray-500 mt-1')
|
||||
|
||||
@staticmethod
|
||||
def _render_map(data: Dict, route: Dict) -> None:
|
||||
"""Leaflet map with route markers and polyline."""
|
||||
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')
|
||||
|
||||
path_points = []
|
||||
|
||||
# Sender
|
||||
if route['sender'] and (route['sender']['lat'] or route['sender']['lon']):
|
||||
lat, lon = route['sender']['lat'], route['sender']['lon']
|
||||
route_map.marker(latlng=(lat, lon))
|
||||
path_points.append((lat, lon))
|
||||
|
||||
# Repeaters
|
||||
for node in route['path_nodes']:
|
||||
if node['lat'] or node['lon']:
|
||||
lat, lon = node['lat'], node['lon']
|
||||
route_map.marker(latlng=(lat, lon))
|
||||
path_points.append((lat, lon))
|
||||
|
||||
# Own position
|
||||
if data['adv_lat'] or data['adv_lon']:
|
||||
route_map.marker(latlng=(data['adv_lat'], data['adv_lon']))
|
||||
path_points.append((data['adv_lat'], data['adv_lon']))
|
||||
|
||||
# Polyline
|
||||
if len(path_points) >= 2:
|
||||
route_map.generic_layer(
|
||||
name='polyline',
|
||||
args=[path_points],
|
||||
options={'color': '#2563eb', 'weight': 3},
|
||||
)
|
||||
lats = [p[0] for p in path_points]
|
||||
lons = [p[1] for p in path_points]
|
||||
route_map.set_center(
|
||||
(sum(lats) / len(lats), sum(lons) / len(lons))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _render_route_table(msg: Dict, data: Dict, route: Dict) -> None:
|
||||
"""Route details table with sender, hops and receiver."""
|
||||
msg_path_len = route['msg_path_len']
|
||||
resolved_hops = len(route['path_nodes'])
|
||||
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('📋 Route Details').classes('font-bold text-gray-600')
|
||||
|
||||
rows = []
|
||||
|
||||
# Sender
|
||||
if route['sender']:
|
||||
s = route['sender']
|
||||
has_loc = s['lat'] != 0 or s['lon'] != 0
|
||||
rows.append({
|
||||
'hop': 'Start',
|
||||
'name': s['name'],
|
||||
'type': TYPE_LABELS.get(s['type'], '-'),
|
||||
'location': f"{s['lat']:.4f}, {s['lon']:.4f}" if has_loc else '-',
|
||||
'role': '📱 Sender',
|
||||
})
|
||||
else:
|
||||
rows.append({
|
||||
'hop': 'Start',
|
||||
'name': msg.get('sender', 'Unknown'),
|
||||
'type': '-',
|
||||
'location': '-',
|
||||
'role': '📱 Sender',
|
||||
})
|
||||
|
||||
# Resolved repeaters
|
||||
for i, node in enumerate(route['path_nodes']):
|
||||
has_loc = node['lat'] != 0 or node['lon'] != 0
|
||||
rows.append({
|
||||
'hop': str(i + 1),
|
||||
'name': node['name'],
|
||||
'type': TYPE_LABELS.get(node['type'], '-'),
|
||||
'location': f"{node['lat']:.4f}, {node['lon']:.4f}" if has_loc else '-',
|
||||
'role': '📡 Repeater',
|
||||
})
|
||||
|
||||
# Placeholder rows for unresolved hops
|
||||
if msg_path_len > resolved_hops:
|
||||
for i in range(resolved_hops, msg_path_len):
|
||||
rows.append({
|
||||
'hop': str(i + 1),
|
||||
'name': '(unknown repeater)',
|
||||
'type': '-',
|
||||
'location': '-',
|
||||
'role': '📡 Repeater',
|
||||
})
|
||||
|
||||
# Own position
|
||||
self_has_loc = data['adv_lat'] != 0 or data['adv_lon'] != 0
|
||||
rows.append({
|
||||
'hop': 'End',
|
||||
'name': data['name'] or 'Me',
|
||||
'type': 'Companion',
|
||||
'location': f"{data['adv_lat']:.4f}, {data['adv_lon']:.4f}" if self_has_loc 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': 'type', 'label': 'Type', 'field': 'type'},
|
||||
{'name': 'location', 'label': 'Location', 'field': 'location'},
|
||||
],
|
||||
rows=rows,
|
||||
).props('dense flat bordered').classes('w-full')
|
||||
|
||||
# Footnote
|
||||
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 msg_path_len > 0 and resolved_hops == 0:
|
||||
ui.label(
|
||||
"ℹ️ The repeater identities could not be resolved. "
|
||||
"This happens when the sender's out_path is empty "
|
||||
"(e.g. channel messages) or the repeaters are not in "
|
||||
"your contacts list."
|
||||
).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')
|
||||
263
meshcore-gui/meshcore_gui/shared_data.py
Normal file
263
meshcore-gui/meshcore_gui/shared_data.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
|
||||
class SharedData:
|
||||
"""
|
||||
Thread-safe container for shared data between BLE worker and GUI.
|
||||
|
||||
Attributes:
|
||||
lock: Threading lock for thread-safe access
|
||||
name: Device name
|
||||
public_key: Device public key
|
||||
radio_freq: Radio frequency in MHz
|
||||
radio_sf: 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
|
||||
connected: Whether device is connected
|
||||
status: Status text for UI
|
||||
contacts: Dict of contacts {key: {adv_name, type, lat, lon, …}}
|
||||
channels: List of channels [{idx, name}, …]
|
||||
messages: List of messages
|
||||
rx_log: List of RX log entries
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize SharedData with empty values and flags set to True."""
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Device info
|
||||
self.name: str = ""
|
||||
self.public_key: str = ""
|
||||
self.radio_freq: float = 0.0
|
||||
self.radio_sf: int = 0
|
||||
self.radio_bw: float = 0.0
|
||||
self.tx_power: int = 0
|
||||
self.adv_lat: float = 0.0
|
||||
self.adv_lon: float = 0.0
|
||||
self.firmware_version: str = ""
|
||||
|
||||
# Connection status
|
||||
self.connected: bool = False
|
||||
self.status: str = "Starting..."
|
||||
|
||||
# Data collections
|
||||
self.contacts: Dict = {}
|
||||
self.channels: List[Dict] = []
|
||||
self.messages: List[Dict] = []
|
||||
self.rx_log: List[Dict] = []
|
||||
|
||||
# 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Device info updates
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_from_appstart(self, payload: Dict) -> None:
|
||||
"""Update device info from send_appstart response."""
|
||||
with self.lock:
|
||||
self.name = payload.get('name', self.name)
|
||||
self.public_key = payload.get('public_key', self.public_key)
|
||||
self.radio_freq = payload.get('radio_freq', self.radio_freq)
|
||||
self.radio_sf = payload.get('radio_sf', self.radio_sf)
|
||||
self.radio_bw = payload.get('radio_bw', self.radio_bw)
|
||||
self.tx_power = payload.get('tx_power', self.tx_power)
|
||||
self.adv_lat = payload.get('adv_lat', self.adv_lat)
|
||||
self.adv_lon = payload.get('adv_lon', self.adv_lon)
|
||||
self.device_updated = True
|
||||
debug_print(f"Device info updated: {self.name}")
|
||||
|
||||
def update_from_device_query(self, payload: Dict) -> None:
|
||||
"""Update firmware version from send_device_query response."""
|
||||
with self.lock:
|
||||
self.firmware_version = payload.get('ver', self.firmware_version)
|
||||
self.device_updated = True
|
||||
debug_print(f"Firmware version: {self.firmware_version}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_status(self, status: str) -> None:
|
||||
"""Update status text."""
|
||||
with self.lock:
|
||||
self.status = status
|
||||
|
||||
def set_connected(self, connected: bool) -> None:
|
||||
"""Update connection status."""
|
||||
with self.lock:
|
||||
self.connected = connected
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Command queue
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def put_command(self, cmd: Dict) -> None:
|
||||
"""Enqueue a command for the BLE worker."""
|
||||
self.cmd_queue.put(cmd)
|
||||
|
||||
def get_next_command(self) -> Optional[Dict]:
|
||||
"""
|
||||
Dequeue the next command, or return None if the queue is empty.
|
||||
|
||||
Returns:
|
||||
Command dictionary, or None.
|
||||
"""
|
||||
try:
|
||||
return self.cmd_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Collections
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_contacts(self, contacts_dict: Dict) -> None:
|
||||
"""Replace the contacts dictionary."""
|
||||
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:
|
||||
"""Replace the channels list."""
|
||||
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: Dict) -> None:
|
||||
"""
|
||||
Add a message to the messages list (max 100).
|
||||
|
||||
Args:
|
||||
msg: Message dict with time, sender, text, channel,
|
||||
direction, path_len, snr, sender_pubkey
|
||||
"""
|
||||
with self.lock:
|
||||
self.messages.append(msg)
|
||||
if len(self.messages) > 100:
|
||||
self.messages.pop(0)
|
||||
debug_print(
|
||||
f"Message added: {msg.get('sender', '?')}: "
|
||||
f"{msg.get('text', '')[:30]}"
|
||||
)
|
||||
|
||||
def add_rx_log(self, entry: Dict) -> None:
|
||||
"""Add an RX log entry (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."""
|
||||
with self.lock:
|
||||
return {
|
||||
'name': self.name,
|
||||
'public_key': self.public_key,
|
||||
'radio_freq': self.radio_freq,
|
||||
'radio_sf': self.radio_sf,
|
||||
'radio_bw': self.radio_bw,
|
||||
'tx_power': self.tx_power,
|
||||
'adv_lat': self.adv_lat,
|
||||
'adv_lon': self.adv_lon,
|
||||
'firmware_version': self.firmware_version,
|
||||
'connected': self.connected,
|
||||
'status': self.status,
|
||||
'contacts': self.contacts.copy(),
|
||||
'channels': self.channels.copy(),
|
||||
'messages': self.messages.copy(),
|
||||
'rx_log': self.rx_log.copy(),
|
||||
'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,
|
||||
}
|
||||
|
||||
def clear_update_flags(self) -> None:
|
||||
"""Reset all update flags to False."""
|
||||
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:
|
||||
"""Mark that the GUI has completed its first render."""
|
||||
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]:
|
||||
"""
|
||||
Look up a contact by public key prefix.
|
||||
|
||||
Used by route visualization to resolve pubkey prefixes (from
|
||||
messages and out_path) to full contact records.
|
||||
|
||||
Returns:
|
||||
Copy of the contact dictionary, or None if not found.
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
Look up a contact name by public key prefix.
|
||||
|
||||
Returns:
|
||||
The contact's adv_name, or the first 8 chars of the prefix
|
||||
if not found, or empty string if prefix is empty.
|
||||
"""
|
||||
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]
|
||||
1105
meshcore_gui.py
1105
meshcore_gui.py
File diff suppressed because it is too large
Load Diff
12
tools/ble_observe.py
Normal file
12
tools/ble_observe.py
Normal 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())
|
||||
10
tools/ble_observe/__main__.py
Normal file
10
tools/ble_observe/__main__.py
Normal 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
108
tools/ble_observe/cli.py
Normal 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
|
||||
Reference in New Issue
Block a user