forked from iarv/meshcore-gui
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:
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) |
|
||||
1
data/nodes.json
Normal file
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
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 |
|
||||
1120
meshcore_gui.py
1120
meshcore_gui.py
File diff suppressed because it is too large
Load Diff
BIN
meshcore_gui.zip
Normal file
BIN
meshcore_gui.zip
Normal file
Binary file not shown.
8
meshcore_gui/__init__.py
Normal file
8
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__ = "5.0"
|
||||
114
meshcore_gui/__main__.py
Normal file
114
meshcore_gui/__main__.py
Normal 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()
|
||||
3
meshcore_gui/ble/__init__.py
Normal file
3
meshcore_gui/ble/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
BLE infrastructure layer — device connection, commands and events.
|
||||
"""
|
||||
113
meshcore_gui/ble/commands.py
Normal file
113
meshcore_gui/ble/commands.py
Normal 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
216
meshcore_gui/ble/events.py
Normal 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
|
||||
206
meshcore_gui/ble/packet_decoder.py
Normal file
206
meshcore_gui/ble/packet_decoder.py
Normal 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
199
meshcore_gui/ble/worker.py
Normal 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
43
meshcore_gui/config.py
Normal 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'},
|
||||
]
|
||||
16
meshcore_gui/core/__init__.py
Normal file
16
meshcore_gui/core/__init__.py
Normal 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
174
meshcore_gui/core/models.py
Normal 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
|
||||
107
meshcore_gui/core/protocols.py
Normal file
107
meshcore_gui/core/protocols.py
Normal 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."""
|
||||
...
|
||||
261
meshcore_gui/core/shared_data.py
Normal file
261
meshcore_gui/core/shared_data.py
Normal 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
|
||||
3
meshcore_gui/gui/__init__.py
Normal file
3
meshcore_gui/gui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Presentation layer — NiceGUI pages and panels.
|
||||
"""
|
||||
11
meshcore_gui/gui/constants.py
Normal file
11
meshcore_gui/gui/constants.py
Normal 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"}
|
||||
165
meshcore_gui/gui/dashboard.py
Normal file
165
meshcore_gui/gui/dashboard.py
Normal 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}")
|
||||
16
meshcore_gui/gui/panels/__init__.py
Normal file
16
meshcore_gui/gui/panels/__init__.py
Normal 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
|
||||
29
meshcore_gui/gui/panels/actions_panel.py
Normal file
29
meshcore_gui/gui/panels/actions_panel.py
Normal 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'})
|
||||
87
meshcore_gui/gui/panels/contacts_panel.py
Normal file
87
meshcore_gui/gui/panels/contacts_panel.py
Normal 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()
|
||||
40
meshcore_gui/gui/panels/device_panel.py
Normal file
40
meshcore_gui/gui/panels/device_panel.py
Normal 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..."
|
||||
61
meshcore_gui/gui/panels/filter_panel.py
Normal file
61
meshcore_gui/gui/panels/filter_panel.py
Normal 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']
|
||||
59
meshcore_gui/gui/panels/input_panel.py
Normal file
59
meshcore_gui/gui/panels/input_panel.py
Normal 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 = ''
|
||||
49
meshcore_gui/gui/panels/map_panel.py
Normal file
49
meshcore_gui/gui/panels/map_panel.py
Normal 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)
|
||||
89
meshcore_gui/gui/panels/messages_panel.py
Normal file
89
meshcore_gui/gui/panels/messages_panel.py
Normal 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
|
||||
))
|
||||
41
meshcore_gui/gui/panels/rxlog_panel.py
Normal file
41
meshcore_gui/gui/panels/rxlog_panel.py
Normal 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()
|
||||
312
meshcore_gui/gui/route_page.py
Normal file
312
meshcore_gui/gui/route_page.py
Normal 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')
|
||||
3
meshcore_gui/services/__init__.py
Normal file
3
meshcore_gui/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Business logic services — bot, deduplication and route building.
|
||||
"""
|
||||
195
meshcore_gui/services/bot.py
Normal file
195
meshcore_gui/services/bot.py
Normal 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})"
|
||||
108
meshcore_gui/services/dedup.py
Normal file
108
meshcore_gui/services/dedup.py
Normal 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}"
|
||||
214
meshcore_gui/services/route_builder.py
Normal file
214
meshcore_gui/services/route_builder.py
Normal 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
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