diff --git a/README.md b/README.md index c1638b1..c315a24 100644 --- a/README.md +++ b/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. - +![Status](https://img.shields.io/badge/Status-Testing%20%2F%20Not%20Production%20Ready-orange.svg) +> ⚠️ **This branch is in active development and testing. It is not production-ready. Use at your own risk. ** + ![Python](https://img.shields.io/badge/Python-3.10+-blue.svg) ![License](https://img.shields.io/badge/License-MIT-green.svg) ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-orange.svg) +A graphical user interface for MeshCore mesh network devices via Bluetooth Low Energy (BLE) for on your desktop. + ## Why This Project Exists MeshCore devices like the SenseCAP T1000-E can be managed through two interfaces: USB serial and BLE (Bluetooth Low Energy). The official companion apps communicate with devices over BLE, but they are mobile-only. If you want to manage your MeshCore device from a desktop or laptop, the usual approach is to **flash USB-serial firmware** via the web flasher. However, this replaces the BLE Companion firmware, which means you can no longer use the device with mobile companion apps (Android/iOS). @@ -139,7 +141,7 @@ On macOS the address will be a UUID (e.g., `12345678-ABCD-...`) rather than a MA ### 3. Configure channels -Open `meshcore_gui.py` and adjust `CHANNELS_CONFIG` to your own channels: +Open `meshcore_gui/config.py` and adjust `CHANNELS_CONFIG` to your own channels: ```python CHANNELS_CONFIG = [ @@ -313,13 +315,30 @@ DEBUG = True ``` meshcore-gui/ -├── meshcore_gui.py # Main application -├── README.md # This file -└── docs/ - ├── TROUBLESHOOTING.md # BLE troubleshooting guide (Linux) - └── MeshCore_GUI_Design.docx # Design document +├── meshcore_gui.py # Entry point +├── meshcore_gui/ # Application package +│ ├── __init__.py +│ ├── ble_worker.py +│ ├── config.py +│ ├── main_page.py +│ ├── protocols.py +│ ├── route_builder.py +│ ├── route_page.py +│ └── shared_data.py +├── docs/ +│ ├── SOLID_ANALYSIS.md +│ ├── TROUBLESHOOTING.md +│ ├── MeshCore_GUI_Design.docx +│ ├── ble_capture_workflow_t_1000_e_explanation.md +│ └── ble_capture_workflow_t_1000_e_uitleg.md +├── .gitattributes +├── .gitignore +├── LICENSE +└── README.md ``` +For a SOLID principles analysis of the project structure, see [SOLID_ANALYSIS.md](docs/SOLID_ANALYSIS.md). + ## Disclaimer This is an **independent community project** and is not affiliated with or endorsed by the official [MeshCore](https://github.com/meshcore-dev) development team. It is built on top of the open-source `meshcore` Python library and `bleak` BLE library. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..328006c --- /dev/null +++ b/RELEASE.md @@ -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) | diff --git a/docs/MeshCore_GUI_Design.docx b/docs/MeshCore_GUI_Design.docx index 5e0ddda..057cdbb 100644 Binary files a/docs/MeshCore_GUI_Design.docx and b/docs/MeshCore_GUI_Design.docx differ diff --git a/docs/SOLID_ANALYSIS.md b/docs/SOLID_ANALYSIS.md new file mode 100644 index 0000000..ea2d8ff --- /dev/null +++ b/docs/SOLID_ANALYSIS.md @@ -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 | diff --git a/meshcore-gui/meshcore_gui.py b/meshcore-gui/meshcore_gui.py new file mode 100644 index 0000000..8f113b7 --- /dev/null +++ b/meshcore-gui/meshcore_gui.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +MeshCore GUI - Threaded BLE Edition +==================================== + +Entry point. Parses arguments, wires up the components, registers +NiceGUI pages and starts the server. + +Usage: + python meshcore_gui.py + python meshcore_gui.py --debug-on + + Author: PE1HVH + Version: 3.2 + SPDX-License-Identifier: MIT + Copyright: (c) 2026 PE1HVH +""" + +import sys + +from nicegui import ui + +# Allow overriding DEBUG and CHANNELS_CONFIG before anything imports them +import meshcore_gui.config as config + +try: + from meshcore import MeshCore, EventType # noqa: F401 — availability check +except ImportError: + print("ERROR: meshcore library not found") + print("Install with: pip install meshcore") + sys.exit(1) + +from meshcore_gui.ble_worker import BLEWorker +from meshcore_gui.main_page import DashboardPage +from meshcore_gui.route_page import RoutePage +from meshcore_gui.shared_data import SharedData + + +# Global instances (needed by NiceGUI page decorators) +_shared = None +_dashboard = None +_route_page = None + + +@ui.page('/') +def _page_dashboard(): + """NiceGUI page handler — main dashboard.""" + if _dashboard: + _dashboard.render() + + +@ui.page('/route/{msg_index}') +def _page_route(msg_index: int): + """NiceGUI page handler — route visualization (new tab).""" + if _route_page: + _route_page.render(msg_index) + + +def main(): + """ + Main entry point. + + Parses CLI arguments, initialises all components and starts the + NiceGUI server. + """ + global _shared, _dashboard, _route_page + + # Parse arguments + args = [a for a in sys.argv[1:] if not a.startswith('--')] + flags = [a for a in sys.argv[1:] if a.startswith('--')] + + if not args: + print("MeshCore GUI - Threaded BLE Edition") + print("=" * 40) + print("Usage: python meshcore_gui.py [--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() diff --git a/meshcore-gui/meshcore_gui/__init__.py b/meshcore-gui/meshcore_gui/__init__.py new file mode 100644 index 0000000..69a3ae8 --- /dev/null +++ b/meshcore-gui/meshcore_gui/__init__.py @@ -0,0 +1,8 @@ +""" +MeshCore GUI — Threaded BLE Edition. + +A graphical user interface for MeshCore mesh network devices, +communicating via Bluetooth Low Energy (BLE). +""" + +__version__ = "3.1" diff --git a/meshcore-gui/meshcore_gui/ble_worker.py b/meshcore-gui/meshcore_gui/ble_worker.py new file mode 100644 index 0000000..b5b4796 --- /dev/null +++ b/meshcore-gui/meshcore_gui/ble_worker.py @@ -0,0 +1,252 @@ +""" +BLE communication worker for MeshCore GUI. + +Runs in a separate thread with its own asyncio event loop. Connects to +the MeshCore device, subscribes to events, and processes commands sent +from the GUI via the SharedData command queue. +""" + +import asyncio +import threading +from datetime import datetime +from typing import Dict, Optional + +from meshcore import MeshCore, EventType + +from meshcore_gui.config import CHANNELS_CONFIG, debug_print +from meshcore_gui.protocols import SharedDataWriter + + +class BLEWorker: + """ + BLE communication worker that runs in a separate thread. + + Attributes: + address: BLE MAC address of the device + shared: SharedDataWriter for thread-safe communication + mc: MeshCore instance after connection + running: Boolean to control the worker loop + """ + + def __init__(self, address: str, shared: SharedDataWriter) -> None: + self.address = address + self.shared = shared + self.mc: Optional[MeshCore] = None + self.running = True + + # ------------------------------------------------------------------ + # Thread lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start the worker in a new daemon thread.""" + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + debug_print("BLE worker thread started") + + def _run(self) -> None: + """Entry point for the worker thread.""" + asyncio.run(self._async_main()) + + async def _async_main(self) -> None: + """Connect, then process commands in an infinite loop.""" + await self._connect() + if self.mc: + while self.running: + await self._process_commands() + await asyncio.sleep(0.1) + + # ------------------------------------------------------------------ + # Connection + # ------------------------------------------------------------------ + + async def _connect(self) -> None: + """Connect to the BLE device and load initial data.""" + self.shared.set_status(f"🔄 Connecting to {self.address}...") + + try: + print(f"BLE: Connecting to {self.address}...") + self.mc = await MeshCore.create_ble(self.address) + print("BLE: Connected!") + + await asyncio.sleep(1) + + # Subscribe to events + self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_msg) + self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_contact_msg) + self.mc.subscribe(EventType.RX_LOG_DATA, self._on_rx_log) + + await self._load_data() + await self.mc.start_auto_message_fetching() + + self.shared.set_connected(True) + self.shared.set_status("✅ Connected") + print("BLE: Ready!") + + except Exception as e: + print(f"BLE: Connection error: {e}") + self.shared.set_status(f"❌ {e}") + + async def _load_data(self) -> None: + """ + Load device data with retry mechanism. + + Tries send_appstart and send_device_query each up to 5 times. + Channels come from hardcoded config. + """ + # send_appstart + self.shared.set_status("🔄 Device info...") + for i in range(5): + debug_print(f"send_appstart attempt {i + 1}") + r = await self.mc.commands.send_appstart() + if r.type != EventType.ERROR: + print(f"BLE: send_appstart OK: {r.payload.get('name')}") + self.shared.update_from_appstart(r.payload) + break + await asyncio.sleep(0.3) + + # send_device_query + for i in range(5): + debug_print(f"send_device_query attempt {i + 1}") + r = await self.mc.commands.send_device_query() + if r.type != EventType.ERROR: + print(f"BLE: send_device_query OK: {r.payload.get('ver')}") + self.shared.update_from_device_query(r.payload) + break + await asyncio.sleep(0.3) + + # Channels (hardcoded — BLE get_channel is unreliable) + self.shared.set_status("🔄 Channels...") + self.shared.set_channels(CHANNELS_CONFIG) + print(f"BLE: Channels loaded: {[c['name'] for c in CHANNELS_CONFIG]}") + + # Contacts + self.shared.set_status("🔄 Contacts...") + r = await self.mc.commands.get_contacts() + if r.type != EventType.ERROR: + self.shared.set_contacts(r.payload) + print(f"BLE: Contacts loaded: {len(r.payload)} contacts") + + # ------------------------------------------------------------------ + # Command handling + # ------------------------------------------------------------------ + + async def _process_commands(self) -> None: + """Process all commands queued by the GUI.""" + while True: + cmd = self.shared.get_next_command() + if cmd is None: + break + await self._handle_command(cmd) + + async def _handle_command(self, cmd: Dict) -> None: + """ + Process a single command from the GUI. + + Supported actions: send_message, send_dm, send_advert, refresh. + """ + action = cmd.get('action') + + if action == 'send_message': + channel = cmd.get('channel', 0) + text = cmd.get('text', '') + if text and self.mc: + await self.mc.commands.send_chan_msg(channel, text) + self.shared.add_message({ + 'time': datetime.now().strftime('%H:%M:%S'), + 'sender': 'Me', + 'text': text, + 'channel': channel, + 'direction': 'out', + 'sender_pubkey': '', + }) + debug_print(f"Sent message to channel {channel}: {text[:30]}") + + elif action == 'send_advert': + if self.mc: + await self.mc.commands.send_advert(flood=True) + self.shared.set_status("📢 Advert sent") + debug_print("Advert sent") + + elif action == 'send_dm': + pubkey = cmd.get('pubkey', '') + text = cmd.get('text', '') + contact_name = cmd.get('contact_name', pubkey[:8]) + if text and pubkey and self.mc: + await self.mc.commands.send_msg(pubkey, text) + self.shared.add_message({ + 'time': datetime.now().strftime('%H:%M:%S'), + 'sender': 'Me', + 'text': text, + 'channel': None, + 'direction': 'out', + 'sender_pubkey': pubkey, + }) + debug_print(f"Sent DM to {contact_name}: {text[:30]}") + + elif action == 'refresh': + if self.mc: + debug_print("Refresh requested") + await self._load_data() + + # ------------------------------------------------------------------ + # Event callbacks + # ------------------------------------------------------------------ + + def _on_channel_msg(self, event) -> None: + """Callback for received channel messages.""" + payload = event.payload + sender = payload.get('sender_name') or payload.get('sender') or '' + + debug_print(f"Channel msg payload keys: {list(payload.keys())}") + debug_print(f"Channel msg payload: {payload}") + + self.shared.add_message({ + 'time': datetime.now().strftime('%H:%M:%S'), + 'sender': sender[:15] if sender else '', + 'text': payload.get('text', ''), + 'channel': payload.get('channel_idx'), + 'direction': 'in', + 'snr': payload.get('SNR') or payload.get('snr'), + 'path_len': payload.get('path_len', 0), + 'sender_pubkey': payload.get('sender', ''), + }) + + def _on_contact_msg(self, event) -> None: + """Callback for received DMs; resolves sender name via pubkey.""" + payload = event.payload + pubkey = payload.get('pubkey_prefix', '') + sender = '' + + debug_print(f"DM payload keys: {list(payload.keys())}") + debug_print(f"DM payload: {payload}") + + if pubkey: + sender = self.shared.get_contact_name_by_prefix(pubkey) + + if not sender: + sender = pubkey[:8] if pubkey else '' + + self.shared.add_message({ + 'time': datetime.now().strftime('%H:%M:%S'), + 'sender': sender[:15] if sender else '', + 'text': payload.get('text', ''), + 'channel': None, + 'direction': 'in', + 'snr': payload.get('SNR') or payload.get('snr'), + 'path_len': payload.get('path_len', 0), + 'sender_pubkey': pubkey, + }) + + debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") + + def _on_rx_log(self, event) -> None: + """Callback for RX log data.""" + payload = event.payload + self.shared.add_rx_log({ + 'time': datetime.now().strftime('%H:%M:%S'), + 'snr': payload.get('snr', 0), + 'rssi': payload.get('rssi', 0), + 'payload_type': payload.get('payload_type', '?'), + 'hops': payload.get('path_len', 0), + }) diff --git a/meshcore-gui/meshcore_gui/config.py b/meshcore-gui/meshcore_gui/config.py new file mode 100644 index 0000000..3267c88 --- /dev/null +++ b/meshcore-gui/meshcore_gui/config.py @@ -0,0 +1,57 @@ +""" +Configuration and shared constants for MeshCore GUI. + +Contains: + - Debug flag and debug_print helper + - Channel configuration + - Contact type mappings + +The DEBUG flag defaults to False and can be activated at startup +with the ``--debug-on`` command-line option. +""" + +from typing import Dict, List + + +# ============================================================================== +# DEBUG +# ============================================================================== + +DEBUG = False + + +def debug_print(msg: str) -> None: + """ + Print debug message if DEBUG mode is enabled. + + Args: + msg: The message to print + """ + if DEBUG: + print(f"DEBUG: {msg}") + + +# ============================================================================== +# CHANNELS +# ============================================================================== + +# Hardcoded channels configuration. +# Determine your channels with meshcli: +# meshcli -d +# > get_channels +# Output: 0: Public [...], 1: #test [...], etc. +CHANNELS_CONFIG: List[Dict] = [ + {'idx': 0, 'name': 'Public'}, + {'idx': 1, 'name': '#test'}, + {'idx': 2, 'name': '#zwolle'}, + {'idx': 3, 'name': 'RahanSom'}, +] + + +# ============================================================================== +# CONTACT TYPE MAPPINGS +# ============================================================================== + +TYPE_ICONS: Dict[int, str] = {0: "○", 1: "📱", 2: "📡", 3: "🏠"} +TYPE_NAMES: Dict[int, str] = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"} +TYPE_LABELS: Dict[int, str] = {0: "-", 1: "Companion", 2: "Repeater", 3: "Room Server"} diff --git a/meshcore-gui/meshcore_gui/main_page.py b/meshcore-gui/meshcore_gui/main_page.py new file mode 100644 index 0000000..94a64d7 --- /dev/null +++ b/meshcore-gui/meshcore_gui/main_page.py @@ -0,0 +1,402 @@ +""" +Main dashboard page for MeshCore GUI. + +Contains the three-column layout with device info, contacts, map, +messaging, filters and RX log. The 500 ms update timer lives here. +""" + +from typing import Dict, List + +from nicegui import ui + +from meshcore_gui.config import TYPE_ICONS, TYPE_NAMES +from meshcore_gui.protocols import SharedDataReader + + +class DashboardPage: + """ + Main dashboard rendered at ``/``. + + Args: + shared: SharedDataReader for data access and command dispatch + """ + + def __init__(self, shared: SharedDataReader) -> None: + self._shared = shared + + # UI element references + self._status_label = None + self._device_label = None + self._channel_select = None + self._channels_filter_container = None + self._channel_filters: Dict = {} + self._contacts_container = None + self._map_widget = None + self._messages_container = None + self._rxlog_table = None + self._msg_input = None + + # Map markers tracking + self._markers: List = [] + + # Channel data for message display + self._last_channels: List[Dict] = [] + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete dashboard layout and start the timer.""" + ui.dark_mode(False) + + # Header + with ui.header().classes('bg-blue-600 text-white'): + ui.label('🔗 MeshCore').classes('text-xl font-bold') + ui.space() + self._status_label = ui.label('Starting...').classes('text-sm') + + # Three columns + with ui.row().classes('w-full h-full gap-2 p-2'): + with ui.column().classes('w-64 gap-2'): + self._render_device_panel() + self._render_contacts_panel() + + with ui.column().classes('flex-grow gap-2'): + self._render_map_panel() + self._render_input_panel() + self._render_channels_filter() + self._render_messages_panel() + + with ui.column().classes('w-64 gap-2'): + self._render_actions_panel() + self._render_rxlog_panel() + + # 500 ms update timer + ui.timer(0.5, self._update_ui) + + # ------------------------------------------------------------------ + # Panel builders + # ------------------------------------------------------------------ + + def _render_device_panel(self) -> None: + with ui.card().classes('w-full'): + ui.label('📡 Device').classes('font-bold text-gray-600') + self._device_label = ui.label('Connecting...').classes( + 'text-sm whitespace-pre-line' + ) + + def _render_contacts_panel(self) -> None: + with ui.card().classes('w-full'): + ui.label('👥 Contacts').classes('font-bold text-gray-600') + self._contacts_container = ui.column().classes( + 'w-full gap-1 max-h-96 overflow-y-auto' + ) + + def _render_map_panel(self) -> None: + with ui.card().classes('w-full'): + self._map_widget = ui.leaflet( + center=(52.5, 6.0), zoom=9 + ).classes('w-full h-72') + + def _render_input_panel(self) -> None: + with ui.card().classes('w-full'): + with ui.row().classes('w-full items-center gap-2'): + self._msg_input = ui.input( + placeholder='Message...' + ).classes('flex-grow') + + self._channel_select = ui.select( + options={0: '[0] Public'}, value=0 + ).classes('w-32') + + ui.button( + 'Send', on_click=self._send_message + ).classes('bg-blue-500 text-white') + + def _render_channels_filter(self) -> None: + with ui.card().classes('w-full'): + with ui.row().classes('w-full items-center gap-4 justify-center'): + ui.label('📻 Filter:').classes('text-sm text-gray-600') + self._channels_filter_container = ui.row().classes('gap-4') + + def _render_messages_panel(self) -> None: + with ui.card().classes('w-full'): + ui.label('💬 Messages').classes('font-bold text-gray-600') + self._messages_container = ui.column().classes( + 'w-full h-40 overflow-y-auto gap-0 text-sm font-mono ' + 'bg-gray-50 p-2 rounded' + ) + + def _render_actions_panel(self) -> None: + with ui.card().classes('w-full'): + ui.label('⚡ Actions').classes('font-bold text-gray-600') + with ui.row().classes('gap-2'): + ui.button('🔄 Refresh', on_click=self._cmd_refresh) + ui.button('📢 Advert', on_click=self._cmd_send_advert) + + def _render_rxlog_panel(self) -> None: + with ui.card().classes('w-full'): + ui.label('📊 RX Log').classes('font-bold text-gray-600') + self._rxlog_table = ui.table( + columns=[ + {'name': 'time', 'label': 'Time', 'field': 'time'}, + {'name': 'snr', 'label': 'SNR', 'field': 'snr'}, + {'name': 'type', 'label': 'Type', 'field': 'type'}, + ], + rows=[], + ).props('dense flat').classes('text-xs max-h-48 overflow-y-auto') + + # ------------------------------------------------------------------ + # Timer-driven UI update + # ------------------------------------------------------------------ + + def _update_ui(self) -> None: + """Periodic UI refresh — called every 500 ms.""" + try: + if not self._status_label or not self._device_label: + return + + data = self._shared.get_snapshot() + is_first = not data['gui_initialized'] + + self._status_label.text = data['status'] + + if data['device_updated'] or is_first: + self._update_device_info(data) + if data['channels_updated'] or is_first: + self._update_channels(data) + if data['contacts_updated'] or is_first: + self._update_contacts(data) + if data['contacts'] and ( + data['contacts_updated'] or not self._markers or is_first + ): + self._update_map(data) + + self._refresh_messages(data) + + if data['rxlog_updated'] and self._rxlog_table: + self._update_rxlog(data) + + self._shared.clear_update_flags() + + if is_first and data['channels'] and data['contacts']: + self._shared.mark_gui_initialized() + + except Exception as e: + err = str(e).lower() + if "deleted" not in err and "client" not in err: + print(f"GUI update error: {e}") + + # ------------------------------------------------------------------ + # Data → UI updaters + # ------------------------------------------------------------------ + + def _update_device_info(self, data: Dict) -> None: + lines = [] + if data['name']: + lines.append(f"📡 {data['name']}") + if data['public_key']: + lines.append(f"🔑 {data['public_key'][:16]}...") + if data['radio_freq']: + lines.append(f"📻 {data['radio_freq']:.3f} MHz") + lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz") + if data['tx_power']: + lines.append(f"⚡ TX: {data['tx_power']} dBm") + if data['adv_lat'] and data['adv_lon']: + lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}") + if data['firmware_version']: + lines.append(f"🏷️ {data['firmware_version']}") + self._device_label.text = "\n".join(lines) if lines else "Loading..." + + def _update_channels(self, data: Dict) -> None: + if not self._channels_filter_container or not data['channels']: + return + + self._channels_filter_container.clear() + self._channel_filters = {} + + with self._channels_filter_container: + cb_dm = ui.checkbox('DM', value=True) + self._channel_filters['DM'] = cb_dm + for ch in data['channels']: + cb = ui.checkbox(f"[{ch['idx']}] {ch['name']}", value=True) + self._channel_filters[ch['idx']] = cb + + self._last_channels = data['channels'] + + if self._channel_select and data['channels']: + opts = { + ch['idx']: f"[{ch['idx']}] {ch['name']}" + for ch in data['channels'] + } + self._channel_select.options = opts + if self._channel_select.value not in opts: + self._channel_select.value = list(opts.keys())[0] + self._channel_select.update() + + def _update_contacts(self, data: Dict) -> None: + if not self._contacts_container: + return + + self._contacts_container.clear() + + with self._contacts_container: + for key, contact in data['contacts'].items(): + ctype = contact.get('type', 0) + icon = TYPE_ICONS.get(ctype, '○') + name = contact.get('adv_name', key[:12]) + type_name = TYPE_NAMES.get(ctype, '-') + lat = contact.get('adv_lat', 0) + lon = contact.get('adv_lon', 0) + has_loc = lat != 0 or lon != 0 + + tooltip = ( + f"{name}\nType: {type_name}\n" + f"Key: {key[:16]}...\nClick to send DM" + ) + if has_loc: + tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}" + + with ui.row().classes( + 'w-full items-center gap-2 p-1 ' + 'hover:bg-gray-100 rounded cursor-pointer' + ).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)): + ui.label(icon).classes('text-sm') + ui.label(name[:15]).classes( + 'text-sm flex-grow truncate' + ).tooltip(tooltip) + ui.label(type_name).classes('text-xs text-gray-500') + if has_loc: + ui.label('📍').classes('text-xs') + + def _update_map(self, data: Dict) -> None: + if not self._map_widget: + return + + for marker in self._markers: + try: + self._map_widget.remove_layer(marker) + except Exception: + pass + self._markers.clear() + + if data['adv_lat'] and data['adv_lon']: + m = self._map_widget.marker( + latlng=(data['adv_lat'], data['adv_lon']) + ) + self._markers.append(m) + self._map_widget.set_center((data['adv_lat'], data['adv_lon'])) + + for key, contact in data['contacts'].items(): + lat = contact.get('adv_lat', 0) + lon = contact.get('adv_lon', 0) + if lat != 0 or lon != 0: + m = self._map_widget.marker(latlng=(lat, lon)) + self._markers.append(m) + + def _update_rxlog(self, data: Dict) -> None: + rows = [ + { + 'time': e['time'], + 'snr': f"{e['snr']:.1f}", + 'type': e['payload_type'], + } + for e in data['rx_log'][:20] + ] + self._rxlog_table.rows = rows + self._rxlog_table.update() + + def _refresh_messages(self, data: Dict) -> None: + if not self._messages_container: + return + + channel_names = {ch['idx']: ch['name'] for ch in self._last_channels} + + filtered = [] + for msg in data['messages']: + ch = msg['channel'] + if ch is None: + if self._channel_filters.get('DM') and not self._channel_filters['DM'].value: + continue + else: + if ch in self._channel_filters and not self._channel_filters[ch].value: + continue + filtered.append(msg) + + self._messages_container.clear() + + with self._messages_container: + for msg in reversed(filtered[-50:]): + direction = '→' if msg['direction'] == 'out' else '←' + ch = msg['channel'] + + ch_label = ( + f"[{channel_names.get(ch, f'ch{ch}')}]" + if ch is not None + else '[DM]' + ) + + sender = msg.get('sender', '') + path_len = msg.get('path_len', 0) + hop_tag = f' [{path_len}h]' if msg['direction'] == 'in' and path_len > 0 else '' + + if sender: + line = f"{msg['time']} {direction} {ch_label}{hop_tag} {sender}: {msg['text']}" + else: + line = f"{msg['time']} {direction} {ch_label}{hop_tag} {msg['text']}" + + msg_idx = len(filtered) - 1 - filtered[::-1].index(msg) + ui.label(line).classes( + 'text-xs leading-tight cursor-pointer ' + 'hover:bg-blue-50 rounded px-1' + ).on('click', lambda e, i=msg_idx: ui.navigate.to( + f'/route/{i}', new_tab=True + )) + + # ------------------------------------------------------------------ + # DM dialog + # ------------------------------------------------------------------ + + def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None: + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg') + msg_input = ui.input(placeholder='Type your message...').classes('w-full') + + with ui.row().classes('w-full justify-end gap-2 mt-4'): + ui.button('Cancel', on_click=dialog.close).props('flat') + + def send_dm(): + text = msg_input.value + if text: + self._shared.put_command({ + 'action': 'send_dm', + 'pubkey': pubkey, + 'text': text, + 'contact_name': contact_name, + }) + dialog.close() + + ui.button('Send', on_click=send_dm).classes('bg-blue-500 text-white') + dialog.open() + + # ------------------------------------------------------------------ + # Command helpers + # ------------------------------------------------------------------ + + def _send_message(self) -> None: + text = self._msg_input.value + channel = self._channel_select.value + if text: + self._shared.put_command({ + 'action': 'send_message', + 'channel': channel, + 'text': text, + }) + self._msg_input.value = '' + + def _cmd_send_advert(self) -> None: + self._shared.put_command({'action': 'send_advert'}) + + def _cmd_refresh(self) -> None: + self._shared.put_command({'action': 'refresh'}) diff --git a/meshcore-gui/meshcore_gui/protocols.py b/meshcore-gui/meshcore_gui/protocols.py new file mode 100644 index 0000000..71e97a0 --- /dev/null +++ b/meshcore-gui/meshcore_gui/protocols.py @@ -0,0 +1,83 @@ +""" +Protocol interfaces for MeshCore GUI. + +Defines the contracts between components using ``typing.Protocol``. +Each protocol captures the subset of SharedData that a specific +consumer needs, following the Interface Segregation Principle (ISP) +and the Dependency Inversion Principle (DIP). + +Consumers depend on these protocols rather than on the concrete +SharedData class, which makes the contracts explicit and enables +testing with lightweight stubs. +""" + +from typing import Dict, List, Optional, Protocol, runtime_checkable + + +# ---------------------------------------------------------------------- +# Writer — used by BLEWorker +# ---------------------------------------------------------------------- + +@runtime_checkable +class SharedDataWriter(Protocol): + """Write-side interface used by BLEWorker. + + BLEWorker pushes data into the shared store: device info, + contacts, channels, messages, RX log entries and status updates. + It also reads commands enqueued by the GUI. + """ + + def update_from_appstart(self, payload: Dict) -> None: ... + def update_from_device_query(self, payload: Dict) -> None: ... + def set_status(self, status: str) -> None: ... + def set_connected(self, connected: bool) -> None: ... + def set_contacts(self, contacts_dict: Dict) -> None: ... + def set_channels(self, channels: List[Dict]) -> None: ... + def add_message(self, msg: Dict) -> None: ... + def add_rx_log(self, entry: Dict) -> None: ... + def get_next_command(self) -> Optional[Dict]: ... + def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: ... + + +# ---------------------------------------------------------------------- +# Reader — used by DashboardPage and RoutePage +# ---------------------------------------------------------------------- + +@runtime_checkable +class SharedDataReader(Protocol): + """Read-side interface used by GUI pages. + + GUI pages read snapshots of the shared data and manage + update flags. They also enqueue commands for the BLE worker. + """ + + def get_snapshot(self) -> Dict: ... + def clear_update_flags(self) -> None: ... + def mark_gui_initialized(self) -> None: ... + def put_command(self, cmd: Dict) -> None: ... + + +# ---------------------------------------------------------------------- +# ContactLookup — used by RouteBuilder +# ---------------------------------------------------------------------- + +@runtime_checkable +class ContactLookup(Protocol): + """Contact lookup interface used by RouteBuilder. + + RouteBuilder only needs to resolve public key prefixes to + contact records. + """ + + def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: ... + + +# ---------------------------------------------------------------------- +# ReadAndLookup — used by RoutePage (needs both Reader + Lookup) +# ---------------------------------------------------------------------- + +@runtime_checkable +class SharedDataReadAndLookup(SharedDataReader, ContactLookup, Protocol): + """Combined interface for RoutePage which reads snapshots and + delegates contact lookups to RouteBuilder.""" + ... diff --git a/meshcore-gui/meshcore_gui/route_builder.py b/meshcore-gui/meshcore_gui/route_builder.py new file mode 100644 index 0000000..0156637 --- /dev/null +++ b/meshcore-gui/meshcore_gui/route_builder.py @@ -0,0 +1,174 @@ +""" +Route data builder for MeshCore GUI. + +Pure data logic — no UI code. Given a message and a data snapshot, this +module constructs a route dictionary that describes the path the message +has taken through the mesh network (sender → repeaters → receiver). + +The route information comes from two sources: + +1. **path_len** (from the message itself) — number of hops the message + traveled. Always available for received messages. + +2. **out_path** (from the sender's contact record) — hex string where + each byte (2 hex chars) is the first byte of a repeater's public + key. Only available when the sender is a known contact with a stored + route. +""" + +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print +from meshcore_gui.protocols import ContactLookup + + +class RouteBuilder: + """ + Builds route data for a message from available contact information. + + Uses only data already in memory — no extra BLE commands are sent. + + Args: + shared: ContactLookup for resolving pubkey prefixes to contacts + """ + + def __init__(self, shared: ContactLookup) -> None: + self._shared = shared + + def build(self, msg: Dict, data: Dict) -> Dict: + """ + Build route data for a single message. + + Args: + msg: Message dict (must contain 'sender_pubkey', may contain + 'path_len' and 'snr') + data: Snapshot dictionary from SharedData.get_snapshot() + + Returns: + Dictionary with keys: + sender: {name, lat, lon, type, pubkey} or None + self_node: {name, lat, lon} + path_nodes: [{name, lat, lon, type, pubkey}, …] + snr: float or None + msg_path_len: int — hop count from the message itself + has_locations: bool — True if any node has GPS coords + """ + result: Dict = { + 'sender': None, + 'self_node': { + 'name': data['name'] or 'Me', + 'lat': data['adv_lat'], + 'lon': data['adv_lon'], + }, + 'path_nodes': [], + 'snr': msg.get('snr'), + 'msg_path_len': msg.get('path_len', 0), + 'has_locations': False, + } + + # Look up sender in contacts + pubkey = msg.get('sender_pubkey', '') + if pubkey: + contact = self._shared.get_contact_by_prefix(pubkey) + if contact: + result['sender'] = { + 'name': contact.get('adv_name', pubkey[:8]), + 'lat': contact.get('adv_lat', 0), + 'lon': contact.get('adv_lon', 0), + 'type': contact.get('type', 0), + 'pubkey': pubkey, + } + + # Parse out_path for intermediate hops + out_path = contact.get('out_path', '') + out_path_len = contact.get('out_path_len', 0) + + debug_print( + f"Route: sender={contact.get('adv_name')}, " + f"out_path={out_path!r}, out_path_len={out_path_len}, " + f"msg_path_len={result['msg_path_len']}" + ) + + if out_path and out_path_len and out_path_len > 0: + result['path_nodes'] = self._parse_out_path( + out_path, out_path_len, data['contacts'] + ) + + # Determine if any node has GPS coordinates + all_points = [result['self_node']] + if result['sender']: + all_points.append(result['sender']) + all_points.extend(result['path_nodes']) + + result['has_locations'] = any( + p.get('lat', 0) != 0 or p.get('lon', 0) != 0 + for p in all_points + ) + + return result + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _parse_out_path( + out_path: str, + out_path_len: int, + contacts: Dict, + ) -> List[Dict]: + """ + Parse out_path hex string into a list of hop nodes. + + Each byte (2 hex chars) in out_path is the first byte of a + repeater's public key. + + Returns: + List of hop node dicts. + """ + nodes: List[Dict] = [] + hop_hex_len = 2 # 1 byte = 2 hex chars + + for i in range(0, min(len(out_path), out_path_len * 2), hop_hex_len): + hop_hash = out_path[i:i + hop_hex_len] + if not hop_hash or len(hop_hash) < 2: + continue + + hop_contact = RouteBuilder._find_contact_by_pubkey_hash( + hop_hash, contacts + ) + + if hop_contact: + nodes.append({ + 'name': hop_contact.get('adv_name', f'0x{hop_hash}'), + 'lat': hop_contact.get('adv_lat', 0), + 'lon': hop_contact.get('adv_lon', 0), + 'type': hop_contact.get('type', 0), + 'pubkey': hop_hash, + }) + else: + nodes.append({ + 'name': f'Unknown (0x{hop_hash})', + 'lat': 0, + 'lon': 0, + 'type': 0, + 'pubkey': hop_hash, + }) + + return nodes + + @staticmethod + def _find_contact_by_pubkey_hash( + hash_hex: str, contacts: Dict + ) -> Optional[Dict]: + """ + Find a contact whose pubkey starts with the given 1-byte hash. + + Note: with only 256 possible values, collisions are possible + when there are many contacts. Returns the first match. + """ + hash_hex = hash_hex.lower() + for pubkey, contact in contacts.items(): + if pubkey.lower().startswith(hash_hex): + return contact + return None diff --git a/meshcore-gui/meshcore_gui/route_page.py b/meshcore-gui/meshcore_gui/route_page.py new file mode 100644 index 0000000..9740cb5 --- /dev/null +++ b/meshcore-gui/meshcore_gui/route_page.py @@ -0,0 +1,258 @@ +""" +Route visualization page for MeshCore GUI. + +Standalone NiceGUI page that opens in a new browser tab when a user +clicks on a message. Shows a Leaflet map with the message route, +a hop count summary, and a details table. +""" + +from typing import Dict + +from nicegui import ui + +from meshcore_gui.config import TYPE_LABELS +from meshcore_gui.route_builder import RouteBuilder +from meshcore_gui.protocols import SharedDataReadAndLookup + + +class RoutePage: + """ + Route visualization page rendered at ``/route/{msg_index}``. + + Args: + shared: SharedDataReadAndLookup for data access and contact lookups + """ + + def __init__(self, shared: SharedDataReadAndLookup) -> None: + self._shared = shared + self._builder = RouteBuilder(shared) + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def render(self, msg_index: int) -> None: + """ + Render the route page for a specific message. + + Args: + msg_index: Index into SharedData.messages list + """ + data = self._shared.get_snapshot() + + # Validate + if msg_index < 0 or msg_index >= len(data['messages']): + ui.label('❌ Message not found').classes('text-xl p-8') + return + + msg = data['messages'][msg_index] + route = self._builder.build(msg, data) + + ui.dark_mode(False) + + # Header + with ui.header().classes('bg-blue-600 text-white'): + ui.label('🗺️ MeshCore Route').classes('text-xl font-bold') + + with ui.column().classes('w-full max-w-4xl mx-auto p-4 gap-4'): + self._render_message_info(msg) + self._render_hop_summary(msg, route) + self._render_map(data, route) + self._render_route_table(msg, data, route) + + # ------------------------------------------------------------------ + # Private — sub-sections + # ------------------------------------------------------------------ + + @staticmethod + def _render_message_info(msg: Dict) -> None: + """Message header with direction and text.""" + direction = '→ Sent' if msg['direction'] == 'out' else '← Received' + ui.label(f'Message Route — {direction}').classes('font-bold text-lg') + ui.label( + f"{msg['time']} {msg.get('sender', '')}: " + f"{msg['text'][:120]}" + ).classes('text-sm text-gray-600') + + @staticmethod + def _render_hop_summary(msg: Dict, route: Dict) -> None: + """Hop count banner with SNR.""" + msg_path_len = route['msg_path_len'] + resolved_hops = len(route['path_nodes']) + + with ui.card().classes('w-full'): + with ui.row().classes('items-center gap-4'): + if msg['direction'] == 'in': + if msg_path_len == 0: + ui.label('📡 Direct (0 hops)').classes( + 'text-lg font-bold text-green-600' + ) + else: + hop_text = '1 hop' if msg_path_len == 1 else f'{msg_path_len} hops' + ui.label(f'📡 {hop_text}').classes( + 'text-lg font-bold text-blue-600' + ) + else: + ui.label('📡 Outgoing message').classes( + 'text-lg font-bold text-gray-600' + ) + + if route['snr'] is not None: + ui.label( + f'📶 SNR: {route["snr"]:.1f} dB' + ).classes('text-sm text-gray-600') + + # Resolution status + if msg_path_len > 0 and resolved_hops > 0: + ui.label( + f'✅ {resolved_hops} of {msg_path_len} ' + f'repeater{"s" if msg_path_len != 1 else ""} identified' + ).classes('text-xs text-gray-500 mt-1') + elif msg_path_len > 0 and resolved_hops == 0: + ui.label( + f'ℹ️ {msg_path_len} ' + f'hop{"s" if msg_path_len != 1 else ""} — ' + f'repeater identities not resolved ' + f'(not in out_path or not in contacts)' + ).classes('text-xs text-gray-500 mt-1') + + @staticmethod + def _render_map(data: Dict, route: Dict) -> None: + """Leaflet map with route markers and polyline.""" + with ui.card().classes('w-full'): + if not route['has_locations']: + ui.label( + '📍 No location data available for map display' + ).classes('text-gray-500 italic p-4') + return + + center_lat = data['adv_lat'] or 52.5 + center_lon = data['adv_lon'] or 6.0 + + route_map = ui.leaflet( + center=(center_lat, center_lon), zoom=10 + ).classes('w-full h-96') + + path_points = [] + + # Sender + if route['sender'] and (route['sender']['lat'] or route['sender']['lon']): + lat, lon = route['sender']['lat'], route['sender']['lon'] + route_map.marker(latlng=(lat, lon)) + path_points.append((lat, lon)) + + # Repeaters + for node in route['path_nodes']: + if node['lat'] or node['lon']: + lat, lon = node['lat'], node['lon'] + route_map.marker(latlng=(lat, lon)) + path_points.append((lat, lon)) + + # Own position + if data['adv_lat'] or data['adv_lon']: + route_map.marker(latlng=(data['adv_lat'], data['adv_lon'])) + path_points.append((data['adv_lat'], data['adv_lon'])) + + # Polyline + if len(path_points) >= 2: + route_map.generic_layer( + name='polyline', + args=[path_points], + options={'color': '#2563eb', 'weight': 3}, + ) + lats = [p[0] for p in path_points] + lons = [p[1] for p in path_points] + route_map.set_center( + (sum(lats) / len(lats), sum(lons) / len(lons)) + ) + + @staticmethod + def _render_route_table(msg: Dict, data: Dict, route: Dict) -> None: + """Route details table with sender, hops and receiver.""" + msg_path_len = route['msg_path_len'] + resolved_hops = len(route['path_nodes']) + + with ui.card().classes('w-full'): + ui.label('📋 Route Details').classes('font-bold text-gray-600') + + rows = [] + + # Sender + if route['sender']: + s = route['sender'] + has_loc = s['lat'] != 0 or s['lon'] != 0 + rows.append({ + 'hop': 'Start', + 'name': s['name'], + 'type': TYPE_LABELS.get(s['type'], '-'), + 'location': f"{s['lat']:.4f}, {s['lon']:.4f}" if has_loc else '-', + 'role': '📱 Sender', + }) + else: + rows.append({ + 'hop': 'Start', + 'name': msg.get('sender', 'Unknown'), + 'type': '-', + 'location': '-', + 'role': '📱 Sender', + }) + + # Resolved repeaters + for i, node in enumerate(route['path_nodes']): + has_loc = node['lat'] != 0 or node['lon'] != 0 + rows.append({ + 'hop': str(i + 1), + 'name': node['name'], + 'type': TYPE_LABELS.get(node['type'], '-'), + 'location': f"{node['lat']:.4f}, {node['lon']:.4f}" if has_loc else '-', + 'role': '📡 Repeater', + }) + + # Placeholder rows for unresolved hops + if msg_path_len > resolved_hops: + for i in range(resolved_hops, msg_path_len): + rows.append({ + 'hop': str(i + 1), + 'name': '(unknown repeater)', + 'type': '-', + 'location': '-', + 'role': '📡 Repeater', + }) + + # Own position + self_has_loc = data['adv_lat'] != 0 or data['adv_lon'] != 0 + rows.append({ + 'hop': 'End', + 'name': data['name'] or 'Me', + 'type': 'Companion', + 'location': f"{data['adv_lat']:.4f}, {data['adv_lon']:.4f}" if self_has_loc else '-', + 'role': '📱 Receiver' if msg['direction'] == 'in' else '📱 Sender', + }) + + ui.table( + columns=[ + {'name': 'hop', 'label': 'Hop', 'field': 'hop', 'align': 'center'}, + {'name': 'role', 'label': 'Role', 'field': 'role'}, + {'name': 'name', 'label': 'Name', 'field': 'name'}, + {'name': 'type', 'label': 'Type', 'field': 'type'}, + {'name': 'location', 'label': 'Location', 'field': 'location'}, + ], + rows=rows, + ).props('dense flat bordered').classes('w-full') + + # Footnote + if msg_path_len == 0 and msg['direction'] == 'in': + ui.label( + 'ℹ️ Direct message — no intermediate hops.' + ).classes('text-xs text-gray-400 italic mt-2') + elif msg_path_len > 0 and resolved_hops == 0: + ui.label( + "ℹ️ The repeater identities could not be resolved. " + "This happens when the sender's out_path is empty " + "(e.g. channel messages) or the repeaters are not in " + "your contacts list." + ).classes('text-xs text-gray-400 italic mt-2') + elif msg['direction'] == 'out': + ui.label( + 'ℹ️ Hop information is only available for received messages.' + ).classes('text-xs text-gray-400 italic mt-2') diff --git a/meshcore-gui/meshcore_gui/shared_data.py b/meshcore-gui/meshcore_gui/shared_data.py new file mode 100644 index 0000000..1a1a861 --- /dev/null +++ b/meshcore-gui/meshcore_gui/shared_data.py @@ -0,0 +1,263 @@ +""" +Thread-safe shared data container for MeshCore GUI. + +SharedData is the central data store shared between the BLE worker thread +and the GUI main thread. All access goes through methods that acquire a +threading.Lock so both threads can safely read and write. +""" + +import queue +import threading +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + + +class SharedData: + """ + Thread-safe container for shared data between BLE worker and GUI. + + Attributes: + lock: Threading lock for thread-safe access + name: Device name + public_key: Device public key + radio_freq: Radio frequency in MHz + radio_sf: Spreading factor + radio_bw: Bandwidth in kHz + tx_power: Transmit power in dBm + adv_lat: Advertised latitude + adv_lon: Advertised longitude + firmware_version: Firmware version string + connected: Whether device is connected + status: Status text for UI + contacts: Dict of contacts {key: {adv_name, type, lat, lon, …}} + channels: List of channels [{idx, name}, …] + messages: List of messages + rx_log: List of RX log entries + """ + + def __init__(self) -> None: + """Initialize SharedData with empty values and flags set to True.""" + self.lock = threading.Lock() + + # Device info + self.name: str = "" + self.public_key: str = "" + self.radio_freq: float = 0.0 + self.radio_sf: int = 0 + self.radio_bw: float = 0.0 + self.tx_power: int = 0 + self.adv_lat: float = 0.0 + self.adv_lon: float = 0.0 + self.firmware_version: str = "" + + # Connection status + self.connected: bool = False + self.status: str = "Starting..." + + # Data collections + self.contacts: Dict = {} + self.channels: List[Dict] = [] + self.messages: List[Dict] = [] + self.rx_log: List[Dict] = [] + + # Command queue (GUI → BLE) + self.cmd_queue: queue.Queue = queue.Queue() + + # Update flags — initially True so first GUI render shows data + self.device_updated: bool = True + self.contacts_updated: bool = True + self.channels_updated: bool = True + self.rxlog_updated: bool = True + + # Flag to track if GUI has done first render + self.gui_initialized: bool = False + + # ------------------------------------------------------------------ + # Device info updates + # ------------------------------------------------------------------ + + def update_from_appstart(self, payload: Dict) -> None: + """Update device info from send_appstart response.""" + with self.lock: + self.name = payload.get('name', self.name) + self.public_key = payload.get('public_key', self.public_key) + self.radio_freq = payload.get('radio_freq', self.radio_freq) + self.radio_sf = payload.get('radio_sf', self.radio_sf) + self.radio_bw = payload.get('radio_bw', self.radio_bw) + self.tx_power = payload.get('tx_power', self.tx_power) + self.adv_lat = payload.get('adv_lat', self.adv_lat) + self.adv_lon = payload.get('adv_lon', self.adv_lon) + self.device_updated = True + debug_print(f"Device info updated: {self.name}") + + def update_from_device_query(self, payload: Dict) -> None: + """Update firmware version from send_device_query response.""" + with self.lock: + self.firmware_version = payload.get('ver', self.firmware_version) + self.device_updated = True + debug_print(f"Firmware version: {self.firmware_version}") + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + def set_status(self, status: str) -> None: + """Update status text.""" + with self.lock: + self.status = status + + def set_connected(self, connected: bool) -> None: + """Update connection status.""" + with self.lock: + self.connected = connected + + # ------------------------------------------------------------------ + # Command queue + # ------------------------------------------------------------------ + + def put_command(self, cmd: Dict) -> None: + """Enqueue a command for the BLE worker.""" + self.cmd_queue.put(cmd) + + def get_next_command(self) -> Optional[Dict]: + """ + Dequeue the next command, or return None if the queue is empty. + + Returns: + Command dictionary, or None. + """ + try: + return self.cmd_queue.get_nowait() + except queue.Empty: + return None + + # ------------------------------------------------------------------ + # Collections + # ------------------------------------------------------------------ + + def set_contacts(self, contacts_dict: Dict) -> None: + """Replace the contacts dictionary.""" + with self.lock: + self.contacts = contacts_dict.copy() + self.contacts_updated = True + debug_print(f"Contacts updated: {len(self.contacts)} contacts") + + def set_channels(self, channels: List[Dict]) -> None: + """Replace the channels list.""" + with self.lock: + self.channels = channels.copy() + self.channels_updated = True + debug_print(f"Channels updated: {[c['name'] for c in channels]}") + + def add_message(self, msg: Dict) -> None: + """ + Add a message to the messages list (max 100). + + Args: + msg: Message dict with time, sender, text, channel, + direction, path_len, snr, sender_pubkey + """ + with self.lock: + self.messages.append(msg) + if len(self.messages) > 100: + self.messages.pop(0) + debug_print( + f"Message added: {msg.get('sender', '?')}: " + f"{msg.get('text', '')[:30]}" + ) + + def add_rx_log(self, entry: Dict) -> None: + """Add an RX log entry (max 50, newest first).""" + with self.lock: + self.rx_log.insert(0, entry) + if len(self.rx_log) > 50: + self.rx_log.pop() + self.rxlog_updated = True + + # ------------------------------------------------------------------ + # Snapshot and flags + # ------------------------------------------------------------------ + + def get_snapshot(self) -> Dict: + """Create a complete snapshot of all data for the GUI.""" + with self.lock: + return { + 'name': self.name, + 'public_key': self.public_key, + 'radio_freq': self.radio_freq, + 'radio_sf': self.radio_sf, + 'radio_bw': self.radio_bw, + 'tx_power': self.tx_power, + 'adv_lat': self.adv_lat, + 'adv_lon': self.adv_lon, + 'firmware_version': self.firmware_version, + 'connected': self.connected, + 'status': self.status, + 'contacts': self.contacts.copy(), + 'channels': self.channels.copy(), + 'messages': self.messages.copy(), + 'rx_log': self.rx_log.copy(), + 'device_updated': self.device_updated, + 'contacts_updated': self.contacts_updated, + 'channels_updated': self.channels_updated, + 'rxlog_updated': self.rxlog_updated, + 'gui_initialized': self.gui_initialized, + } + + def clear_update_flags(self) -> None: + """Reset all update flags to False.""" + with self.lock: + self.device_updated = False + self.contacts_updated = False + self.channels_updated = False + self.rxlog_updated = False + + def mark_gui_initialized(self) -> None: + """Mark that the GUI has completed its first render.""" + with self.lock: + self.gui_initialized = True + debug_print("GUI marked as initialized") + + # ------------------------------------------------------------------ + # Contact lookups + # ------------------------------------------------------------------ + + def get_contact_by_prefix(self, pubkey_prefix: str) -> Optional[Dict]: + """ + Look up a contact by public key prefix. + + Used by route visualization to resolve pubkey prefixes (from + messages and out_path) to full contact records. + + Returns: + Copy of the contact dictionary, or None if not found. + """ + if not pubkey_prefix: + return None + + with self.lock: + for key, contact in self.contacts.items(): + if key.startswith(pubkey_prefix) or pubkey_prefix.startswith(key): + return contact.copy() + return None + + def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str: + """ + Look up a contact name by public key prefix. + + Returns: + The contact's adv_name, or the first 8 chars of the prefix + if not found, or empty string if prefix is empty. + """ + if not pubkey_prefix: + return "" + + with self.lock: + for key, contact in self.contacts.items(): + if key.startswith(pubkey_prefix): + name = contact.get('adv_name', '') + if name: + return name + + return pubkey_prefix[:8] diff --git a/meshcore_gui.py b/meshcore_gui.py deleted file mode 100644 index ce073ed..0000000 --- a/meshcore_gui.py +++ /dev/null @@ -1,1105 +0,0 @@ -#!/usr/bin/env python3 -""" - -MeshCore GUI - Threaded BLE Edition -==================================== - -A graphical user interface for MeshCore mesh network devices. -Communicates via Bluetooth Low Energy (BLE) with a MeshCore companion device. - -Architecture: - - BLE communication runs in a separate thread with its own asyncio event loop - - NiceGUI web interface runs in the main thread - - Thread-safe SharedData class for communication between threads - - Command queue for GUI -> BLE communication - -Requirements: - pip install meshcore nicegui bleak - -Usage: - python meshcore_gui_v2.py - python meshcore_gui_v2.py literal:AA:BB:CC:DD:EE:FF - - Author: PE1HVH - Version: 2.0 - SPDX-License-Identifier: MIT - Copyright: (c) 2026 PE1HVH -""" - -import asyncio -import sys -import threading -import queue -from datetime import datetime -from typing import Optional, Dict, List - -from nicegui import ui, app - -try: - from meshcore import MeshCore, EventType -except ImportError: - print("ERROR: meshcore library not found") - print("Install with: pip install meshcore") - sys.exit(1) - - -# ============================================================================== -# CONFIGURATION -# ============================================================================== - -# Debug mode: set to True for verbose logging -DEBUG = False - -# Hardcoded channels configuration -# Determine your channels with meshcli: -# meshcli -d -# > get_channels -# Output: 0: Public [...], 1: #test [...], etc. -CHANNELS_CONFIG = [ - {'idx': 0, 'name': 'Public'}, - {'idx': 1, 'name': '#test'}, - {'idx': 2, 'name': '#zwolle'}, - {'idx': 3, 'name': 'RahanSom'}, -] - - -def debug_print(msg: str) -> None: - """ - Print debug message if DEBUG mode is enabled. - - Args: - msg: The message to print - """ - if DEBUG: - print(f"DEBUG: {msg}") - - -# ============================================================================== -# SHARED DATA - Thread-safe data container -# ============================================================================== - -class SharedData: - """ - Thread-safe container for shared data between BLE worker and GUI. - - All access to data goes through methods that use a threading.Lock - to prevent race conditions. - - Attributes: - lock: Threading lock for thread-safe access - name: Device name - public_key: Device public key - radio_freq: Radio frequency in MHz - radio_sf: Spreading factor - radio_bw: Bandwidth in kHz - tx_power: Transmit power in dBm - adv_lat: Advertised latitude - adv_lon: Advertised longitude - firmware_version: Firmware version string - connected: Boolean whether device is connected - status: Status text for UI - contacts: Dict of contacts {key: {adv_name, type, lat, lon, ...}} - channels: List of channels [{idx, name}, ...] - messages: List of messages - rx_log: List of RX log entries - """ - - def __init__(self): - """Initialize SharedData with empty values and flags set to True.""" - self.lock = threading.Lock() - - # Device info - self.name: str = "" - self.public_key: str = "" - self.radio_freq: float = 0.0 - self.radio_sf: int = 0 - self.radio_bw: float = 0.0 - self.tx_power: int = 0 - self.adv_lat: float = 0.0 - self.adv_lon: float = 0.0 - self.firmware_version: str = "" - - # Connection status - self.connected: bool = False - self.status: str = "Starting..." - - # Data collections - self.contacts: Dict = {} - self.channels: List[Dict] = [] - self.messages: List[Dict] = [] - self.rx_log: List[Dict] = [] - - # Command queue (GUI -> BLE) - self.cmd_queue: queue.Queue = queue.Queue() - - # Update flags - INITIALLY TRUE so first GUI render shows data - self.device_updated: bool = True - self.contacts_updated: bool = True - self.channels_updated: bool = True - self.rxlog_updated: bool = True - - # Flag to track if GUI has done first render - self.gui_initialized: bool = False - - def update_from_appstart(self, payload: Dict) -> None: - """ - Update device info from send_appstart response. - - Args: - payload: Response payload from send_appstart command - """ - with self.lock: - self.name = payload.get('name', self.name) - self.public_key = payload.get('public_key', self.public_key) - self.radio_freq = payload.get('radio_freq', self.radio_freq) - self.radio_sf = payload.get('radio_sf', self.radio_sf) - self.radio_bw = payload.get('radio_bw', self.radio_bw) - self.tx_power = payload.get('tx_power', self.tx_power) - self.adv_lat = payload.get('adv_lat', self.adv_lat) - self.adv_lon = payload.get('adv_lon', self.adv_lon) - self.device_updated = True - debug_print(f"Device info updated: {self.name}") - - def update_from_device_query(self, payload: Dict) -> None: - """ - Update firmware version from send_device_query response. - - Args: - payload: Response payload from send_device_query command - """ - with self.lock: - self.firmware_version = payload.get('ver', self.firmware_version) - self.device_updated = True - debug_print(f"Firmware version: {self.firmware_version}") - - def set_status(self, status: str) -> None: - """ - Update status text. - - Args: - status: New status text - """ - with self.lock: - self.status = status - - def set_contacts(self, contacts_dict: Dict) -> None: - """ - Update contacts dictionary. - - Args: - contacts_dict: Dictionary with contacts {key: contact_data} - """ - 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: - """ - Update channels list. - - Args: - channels: List of channel dictionaries [{idx, name}, ...] - """ - with self.lock: - self.channels = channels.copy() - self.channels_updated = True - debug_print(f"Channels updated: {[c['name'] for c in channels]}") - - def add_message(self, msg: Dict) -> None: - """ - Add a message to the messages list. - - Args: - msg: Message dictionary with time, sender, text, channel, direction - """ - with self.lock: - self.messages.append(msg) - # Limit to last 100 messages - if len(self.messages) > 100: - self.messages.pop(0) - debug_print(f"Message added: {msg.get('sender', '?')}: {msg.get('text', '')[:30]}") - - def add_rx_log(self, entry: Dict) -> None: - """ - Add an RX log entry. - - Args: - entry: RX log entry with time, snr, rssi, payload_type - """ - with self.lock: - self.rx_log.insert(0, entry) - # Limit to last 50 entries - if len(self.rx_log) > 50: - self.rx_log.pop() - self.rxlog_updated = True - - def get_snapshot(self) -> Dict: - """ - Create a snapshot of all data for the GUI. - - Returns: - Dictionary with copies of all data and update flags - """ - with self.lock: - return { - 'name': self.name, - 'public_key': self.public_key, - 'radio_freq': self.radio_freq, - 'radio_sf': self.radio_sf, - 'radio_bw': self.radio_bw, - 'tx_power': self.tx_power, - 'adv_lat': self.adv_lat, - 'adv_lon': self.adv_lon, - 'firmware_version': self.firmware_version, - 'connected': self.connected, - 'status': self.status, - 'contacts': self.contacts.copy(), - 'channels': self.channels.copy(), - 'messages': self.messages.copy(), - 'rx_log': self.rx_log.copy(), - 'device_updated': self.device_updated, - 'contacts_updated': self.contacts_updated, - 'channels_updated': self.channels_updated, - 'rxlog_updated': self.rxlog_updated, - 'gui_initialized': self.gui_initialized, - } - - def clear_update_flags(self) -> None: - """Reset all update flags to False.""" - with self.lock: - self.device_updated = False - self.contacts_updated = False - self.channels_updated = False - self.rxlog_updated = False - - def mark_gui_initialized(self) -> None: - """Mark that the GUI has completed its first render.""" - with self.lock: - self.gui_initialized = True - debug_print("GUI marked as initialized") - - -# ============================================================================== -# BLE WORKER - Runs in separate thread -# ============================================================================== - -class BLEWorker: - """ - BLE communication worker that runs in a separate thread. - - This class handles all Bluetooth Low Energy communication with the - MeshCore device. It runs in a separate thread with its own asyncio - event loop to avoid conflicts with NiceGUI's event loop. - - Attributes: - address: BLE MAC address of the device - shared: SharedData instance for thread-safe communication - mc: MeshCore instance after connection - running: Boolean to control the worker loop - """ - - def __init__(self, address: str, shared: SharedData): - """ - Initialize the BLE worker. - - Args: - address: BLE MAC address (e.g. "literal:AA:BB:CC:DD:EE:FF") - shared: SharedData instance for data exchange - """ - self.address = address - self.shared = shared - self.mc: Optional[MeshCore] = None - self.running = True - - def start(self) -> None: - """Start the worker in a new daemon thread.""" - thread = threading.Thread(target=self._run, daemon=True) - thread.start() - debug_print("BLE worker thread started") - - def _run(self) -> None: - """Entry point for the worker thread. Starts asyncio event loop.""" - asyncio.run(self._async_main()) - - async def _async_main(self) -> None: - """ - Main async loop of the worker. - - Connects to the device and then continuously processes commands - from the GUI via the command queue. - """ - await self._connect() - - if self.mc: - # Process commands from GUI in infinite loop - while self.running: - await self._process_commands() - await asyncio.sleep(0.1) - - async def _connect(self) -> None: - """ - Connect to the BLE device and load initial data. - - Also subscribes to events for incoming messages and RX log. - """ - 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!") - - # Wait for device to be ready - await asyncio.sleep(1) - - # Subscribe to events - self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._on_channel_msg) - self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._on_contact_msg) - self.mc.subscribe(EventType.RX_LOG_DATA, self._on_rx_log) - - # Load initial data - await self._load_data() - - # Start automatic message fetching - await self.mc.start_auto_message_fetching() - - self.shared.connected = True - self.shared.set_status("✅ Connected") - print("BLE: Ready!") - - except Exception as e: - print(f"BLE: Connection error: {e}") - self.shared.set_status(f"❌ {e}") - - async def _load_data(self) -> None: - """ - Load device data with retry mechanism. - - Tries send_appstart and send_device_query each up to 5 times - with 0.3 second pause between attempts. Channels are loaded from - the hardcoded configuration. - """ - # send_appstart with 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 with 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 from hardcoded config (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]}") - - # Fetch 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 _process_commands(self) -> None: - """Process all commands in the queue from the GUI.""" - try: - while not self.shared.cmd_queue.empty(): - cmd = self.shared.cmd_queue.get_nowait() - await self._handle_command(cmd) - except queue.Empty: - pass - - async def _handle_command(self, cmd: Dict) -> None: - """ - Process a single command from the GUI. - - Args: - cmd: Command dictionary with 'action' and optional parameters - - Supported actions: - - send_message: Send channel message - - send_advert: Send advertisement - - refresh: Reload all data - """ - action = cmd.get('action') - - if action == 'send_message': - channel = cmd.get('channel', 0) - text = cmd.get('text', '') - if text and self.mc: - await self.mc.commands.send_chan_msg(channel, text) - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': 'Me', - 'text': text, - 'channel': channel, - 'direction': 'out' - }) - debug_print(f"Sent message to channel {channel}: {text[:30]}") - - elif action == 'send_advert': - if self.mc: - await self.mc.commands.send_advert(flood=True) - self.shared.set_status("📢 Advert sent") - debug_print("Advert sent") - - elif action == 'send_dm': - pubkey = cmd.get('pubkey', '') - text = cmd.get('text', '') - contact_name = cmd.get('contact_name', pubkey[:8]) - if text and pubkey and self.mc: - await self.mc.commands.send_msg(pubkey, text) - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': 'Me', - 'text': text, - 'channel': None, # None = DM - 'direction': 'out' - }) - debug_print(f"Sent DM to {contact_name}: {text[:30]}") - - elif action == 'refresh': - if self.mc: - debug_print("Refresh requested") - await self._load_data() - - def _on_channel_msg(self, event) -> None: - """ - Callback for received channel messages. - - Args: - event: MeshCore event with payload - """ - payload = event.payload - sender = payload.get('sender_name') or payload.get('sender') or '' - - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': sender[:15] if sender else '', - 'text': payload.get('text', ''), - 'channel': payload.get('channel_idx'), - 'direction': 'in', - 'snr': payload.get('snr') - }) - - def _on_contact_msg(self, event) -> None: - """ - Callback for received DM (direct message) messages. - - Looks up the sender name in the contacts list via pubkey_prefix. - - Args: - event: MeshCore event with payload - """ - payload = event.payload - pubkey = payload.get('pubkey_prefix', '') - sender = '' - - # Look up contact name based on pubkey prefix - if pubkey: - with self.shared.lock: - for key, contact in self.shared.contacts.items(): - if key.startswith(pubkey): - sender = contact.get('adv_name', '') - break - - # Fallback to pubkey prefix - if not sender: - sender = pubkey[:8] if pubkey else '' - - self.shared.add_message({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'sender': sender[:15] if sender else '', - 'text': payload.get('text', ''), - 'channel': None, # None = DM - 'direction': 'in', - 'snr': payload.get('SNR') # Note: uppercase in DM payload - }) - - debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") - - def _on_rx_log(self, event) -> None: - """ - Callback for RX log data. - - Args: - event: MeshCore event with payload - """ - payload = event.payload - self.shared.add_rx_log({ - 'time': datetime.now().strftime('%H:%M:%S'), - 'snr': payload.get('snr', 0), - 'rssi': payload.get('rssi', 0), - 'payload_type': payload.get('payload_type', '?'), - 'hops': payload.get('path_len', 0) - }) - - -# ============================================================================== -# GUI - NiceGUI Web Interface -# ============================================================================== - -class MeshCoreGUI: - """ - NiceGUI web interface for MeshCore. - - Provides a real-time dashboard with: - - Device information - - Contacts list - - Interactive map with markers - - Send/receive messages with filtering - - RX log - - Attributes: - shared: SharedData instance for data access - TYPE_ICONS: Mapping of contact type to emoji - TYPE_NAMES: Mapping of contact type to name - """ - - # Contact type mappings - TYPE_ICONS = {0: "○", 1: "📱", 2: "📡", 3: "🏠"} - TYPE_NAMES = {0: "-", 1: "CLI", 2: "REP", 3: "ROOM"} - - def __init__(self, shared: SharedData): - """ - Initialize the GUI. - - Args: - shared: SharedData instance for data access - """ - self.shared = shared - - # UI element references - self.status_label = None - self.device_label = None - self.channel_select = None - self.channels_filter_container = None - self.channel_filters: Dict = {} - self.contacts_container = None - self.map_widget = None - self.messages_container = None - self.rxlog_table = None - self.msg_input = None - - # Map markers tracking - self.markers: List = [] - - # Channel data for message display - self.last_channels: List[Dict] = [] - - def render(self) -> None: - """ - Render the complete UI. - - Builds the layout with header, three columns, and starts the - update timer for real-time data refresh. - """ - 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') - - # Main layout: three columns - with ui.row().classes('w-full h-full gap-2 p-2'): - # Left column: Device info and Contacts - with ui.column().classes('w-64 gap-2'): - self._render_device_panel() - self._render_contacts_panel() - - # Middle column: Map, Input, Filter, Messages - with ui.column().classes('flex-grow gap-2'): - self._render_map_panel() - self._render_input_panel() - self._render_channels_filter() - self._render_messages_panel() - - # Right column: Actions and RX Log - with ui.column().classes('w-64 gap-2'): - self._render_actions_panel() - self._render_rxlog_panel() - - # Start update timer (every 500ms) - ui.timer(0.5, self._update_ui) - - def _render_device_panel(self) -> None: - """Render the device info panel.""" - with ui.card().classes('w-full'): - ui.label('📡 Device').classes('font-bold text-gray-600') - self.device_label = ui.label('Connecting...').classes( - 'text-sm whitespace-pre-line' - ) - - def _render_contacts_panel(self) -> None: - """Render the contacts panel.""" - with ui.card().classes('w-full'): - ui.label('👥 Contacts').classes('font-bold text-gray-600') - self.contacts_container = ui.column().classes( - 'w-full gap-1 max-h-96 overflow-y-auto' - ) - - def _render_map_panel(self) -> None: - """Render the map panel with Leaflet.""" - with ui.card().classes('w-full'): - self.map_widget = ui.leaflet( - center=(52.5, 6.0), # Default: Netherlands - zoom=9 - ).classes('w-full h-72') - - def _render_input_panel(self) -> None: - """Render the message input panel.""" - with ui.card().classes('w-full'): - with ui.row().classes('w-full items-center gap-2'): - self.msg_input = ui.input( - placeholder='Message...' - ).classes('flex-grow') - - self.channel_select = ui.select( - options={0: '[0] Public'}, - value=0 - ).classes('w-32') - - ui.button( - 'Send', - on_click=self._send_message - ).classes('bg-blue-500 text-white') - - def _render_channels_filter(self) -> None: - """Render the channel filter panel with checkboxes.""" - with ui.card().classes('w-full'): - with ui.row().classes('w-full items-center gap-4 justify-center'): - ui.label('📻 Filter:').classes('text-sm text-gray-600') - self.channels_filter_container = ui.row().classes('gap-4') - - def _render_messages_panel(self) -> None: - """Render the messages panel.""" - with ui.card().classes('w-full'): - ui.label('💬 Messages').classes('font-bold text-gray-600') - self.messages_container = ui.column().classes( - 'w-full h-40 overflow-y-auto gap-0 text-sm font-mono ' - 'bg-gray-50 p-2 rounded' - ) - - def _render_actions_panel(self) -> None: - """Render the actions panel.""" - 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._send_advert) - - def _render_rxlog_panel(self) -> None: - """Render the RX log panel.""" - with ui.card().classes('w-full'): - ui.label('📊 RX Log').classes('font-bold text-gray-600') - self.rxlog_table = ui.table( - columns=[ - {'name': 'time', 'label': 'Time', 'field': 'time'}, - {'name': 'snr', 'label': 'SNR', 'field': 'snr'}, - {'name': 'type', 'label': 'Type', 'field': 'type'}, - ], - rows=[] - ).props('dense flat').classes('text-xs max-h-48 overflow-y-auto') - - def _update_ui(self) -> None: - """ - Periodic UI update from shared data. - - Called every 500ms by the timer. Fetches a snapshot - of the data and only updates UI elements that have changed. - """ - try: - # Check if UI elements exist - if not self.status_label or not self.device_label: - return - - # Get data snapshot - data = self.shared.get_snapshot() - - # Determine if this is the first GUI render - is_first_render = not data['gui_initialized'] - - # Always update status - self.status_label.text = data['status'] - - # Update device info if changed OR first render - if data['device_updated'] or is_first_render: - self._update_device_info(data) - - # Update channels if changed OR first render - if data['channels_updated'] or is_first_render: - self._update_channels(data) - - # Update contacts if changed OR first render - if data['contacts_updated'] or is_first_render: - self._update_contacts(data) - - # Update map if contacts changed OR no markers OR first render - if data['contacts'] and (data['contacts_updated'] or not self.markers or is_first_render): - self._update_map(data) - - # Always refresh messages (for filter functionality) - self._refresh_messages(data) - - # Update RX Log if changed - if data['rxlog_updated'] and self.rxlog_table: - self._update_rxlog(data) - - # Clear flags and mark GUI as initialized - self.shared.clear_update_flags() - - # Only mark GUI as initialized when there is actual data - if is_first_render and data['channels'] and data['contacts']: - self.shared.mark_gui_initialized() - - except Exception as e: - # Only log relevant errors - error_str = str(e).lower() - if "deleted" not in error_str and "client" not in error_str: - print(f"GUI update error: {e}") - - def _update_device_info(self, data: Dict) -> None: - """ - Update the device info panel. - - Args: - data: Snapshot dictionary from SharedData - """ - lines = [] - - if data['name']: - lines.append(f"📡 {data['name']}") - if data['public_key']: - lines.append(f"🔑 {data['public_key'][:16]}...") - if data['radio_freq']: - lines.append(f"📻 {data['radio_freq']:.3f} MHz") - lines.append(f"⚙️ SF{data['radio_sf']} / {data['radio_bw']} kHz") - if data['tx_power']: - lines.append(f"⚡ TX: {data['tx_power']} dBm") - if data['adv_lat'] and data['adv_lon']: - lines.append(f"📍 {data['adv_lat']:.4f}, {data['adv_lon']:.4f}") - if data['firmware_version']: - lines.append(f"🏷️ {data['firmware_version']}") - - self.device_label.text = "\n".join(lines) if lines else "Loading..." - - def _update_channels(self, data: Dict) -> None: - """ - Update the channel filter checkboxes and send select. - - Args: - data: Snapshot dictionary from SharedData - """ - if not self.channels_filter_container or not data['channels']: - return - - # Rebuild filter checkboxes - self.channels_filter_container.clear() - self.channel_filters = {} - - with self.channels_filter_container: - # DM filter checkbox - cb_dm = ui.checkbox('DM', value=True) - self.channel_filters['DM'] = cb_dm - - # Channel filter checkboxes - for ch in data['channels']: - idx = ch['idx'] - name = ch['name'] - cb = ui.checkbox(f"[{idx}] {name}", value=True) - self.channel_filters[idx] = cb - - # Save channels for message display - self.last_channels = data['channels'] - - # Update send channel select - if self.channel_select and data['channels']: - options = {ch['idx']: f"[{ch['idx']}] {ch['name']}" for ch in data['channels']} - self.channel_select.options = options - if self.channel_select.value not in options: - self.channel_select.value = list(options.keys())[0] - self.channel_select.update() - - def _update_contacts(self, data: Dict) -> None: - """ - Update the contacts list. - - Args: - data: Snapshot dictionary from SharedData - """ - if not self.contacts_container: - return - - self.contacts_container.clear() - - with self.contacts_container: - for key, contact in data['contacts'].items(): - ctype = contact.get('type', 0) - icon = self.TYPE_ICONS.get(ctype, '○') - name = contact.get('adv_name', key[:12]) - type_name = self.TYPE_NAMES.get(ctype, '-') - lat = contact.get('adv_lat', 0) - lon = contact.get('adv_lon', 0) - has_loc = lat != 0 or lon != 0 - - # Tooltip with details - tooltip = f"{name}\nType: {type_name}\nKey: {key[:16]}...\nClick to send DM" - if has_loc: - tooltip += f"\nLat: {lat:.4f}\nLon: {lon:.4f}" - - # Contact row - clickable for DM - with ui.row().classes( - 'w-full items-center gap-2 p-1 hover:bg-gray-100 rounded cursor-pointer' - ).on('click', lambda e, k=key, n=name: self._open_dm_dialog(k, n)): - ui.label(icon).classes('text-sm') - ui.label(name[:15]).classes( - 'text-sm flex-grow truncate' - ).tooltip(tooltip) - ui.label(type_name).classes('text-xs text-gray-500') - if has_loc: - ui.label('📍').classes('text-xs') - - def _open_dm_dialog(self, pubkey: str, contact_name: str) -> None: - """ - Open a dialog to send a DM to a contact. - - Args: - pubkey: Public key of the contact - contact_name: Name of the contact for display - """ - with ui.dialog() as dialog, ui.card().classes('w-96'): - ui.label(f'💬 DM to {contact_name}').classes('font-bold text-lg') - - msg_input = ui.input( - placeholder='Type your message...' - ).classes('w-full') - - with ui.row().classes('w-full justify-end gap-2 mt-4'): - ui.button('Cancel', on_click=dialog.close).props('flat') - - def send_dm(): - text = msg_input.value - if text: - self.shared.cmd_queue.put({ - '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() - - def _update_map(self, data: Dict) -> None: - """ - Update the map markers. - - Args: - data: Snapshot dictionary from SharedData - """ - if not self.map_widget: - return - - # Remove old markers - for marker in self.markers: - try: - self.map_widget.remove_layer(marker) - except: - pass - self.markers.clear() - - # Own position marker - if data['adv_lat'] and data['adv_lon']: - m = self.map_widget.marker(latlng=(data['adv_lat'], data['adv_lon'])) - self.markers.append(m) - self.map_widget.set_center((data['adv_lat'], data['adv_lon'])) - - # 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_widget.marker(latlng=(lat, lon)) - self.markers.append(m) - - def _update_rxlog(self, data: Dict) -> None: - """ - Update the RX log table. - - Args: - data: Snapshot dictionary from SharedData - """ - rows = [ - { - 'time': entry['time'], - 'snr': f"{entry['snr']:.1f}", - 'type': entry['payload_type'] - } - for entry in data['rx_log'][:20] - ] - self.rxlog_table.rows = rows - self.rxlog_table.update() - - def _refresh_messages(self, data: Dict) -> None: - """ - Refresh the messages container with filter application. - - Shows messages filtered based on channel checkboxes. - Most recent messages are shown at the top. - - Args: - data: Snapshot dictionary from SharedData - """ - if not self.messages_container: - return - - # Channel name lookup - channel_names = {ch['idx']: ch['name'] for ch in self.last_channels} - - # Filter messages based on checkboxes - filtered_messages = [] - for msg in data['messages']: - ch_idx = msg['channel'] - - if ch_idx is None: - # DM message - check DM filter - if self.channel_filters.get('DM') and not self.channel_filters['DM'].value: - continue - else: - # Channel message - check channel filter - if ch_idx in self.channel_filters: - if not self.channel_filters[ch_idx].value: - continue - - filtered_messages.append(msg) - - # Rebuild messages container - self.messages_container.clear() - - with self.messages_container: - # Last 50 messages, newest at top - for msg in reversed(filtered_messages[-50:]): - direction = '→' if msg['direction'] == 'out' else '←' - ch_idx = msg['channel'] - - # Determine channel name - if ch_idx is not None: - ch_name = channel_names.get(ch_idx, f'ch{ch_idx}') - ch_label = f"[{ch_name}]" - else: - ch_label = '[DM]' - - # Format message line - sender = msg.get('sender', '') - if sender: - line = f"{msg['time']} {direction} {ch_label} {sender}: {msg['text']}" - else: - line = f"{msg['time']} {direction} {ch_label} {msg['text']}" - - ui.label(line).classes('text-xs leading-tight') - - def _send_message(self) -> None: - """Handle send button click - send message via command queue.""" - text = self.msg_input.value - channel = self.channel_select.value - - if text: - self.shared.cmd_queue.put({ - 'action': 'send_message', - 'channel': channel, - 'text': text - }) - self.msg_input.value = '' - - def _send_advert(self) -> None: - """Handle advert button click - send advertisement.""" - self.shared.cmd_queue.put({'action': 'send_advert'}) - - def _refresh(self) -> None: - """Handle refresh button click - reload all data.""" - self.shared.cmd_queue.put({'action': 'refresh'}) - - -# ============================================================================== -# MAIN ENTRY POINT -# ============================================================================== - -# Global instances -shared_data: Optional[SharedData] = None -gui: Optional[MeshCoreGUI] = None - - -@ui.page('/') -def main_page(): - """NiceGUI page handler - render the GUI.""" - global gui - if gui: - gui.render() - - -def main(): - """ - Main entry point. - - Parses command line arguments, initializes SharedData and GUI, - starts the BLE worker thread, and starts the NiceGUI server. - """ - global shared_data, gui - - # Parse command line arguments - if len(sys.argv) < 2: - print("MeshCore GUI - Threaded BLE Edition") - print("=" * 40) - print("Usage: python meshcore_gui_v2.py ") - print("Example: python meshcore_gui_v2.py literal:AA:BB:CC:DD:EE:FF") - print() - print("Tip: Use 'bluetoothctl scan on' to find devices") - sys.exit(1) - - ble_address = sys.argv[1] - - # Startup banner - print("=" * 50) - print("MeshCore GUI - Threaded BLE Edition") - print("=" * 50) - print(f"Device: {ble_address}") - print(f"Debug mode: {'ON' if DEBUG else 'OFF'}") - print("=" * 50) - - # Initialize shared data - shared_data = SharedData() - - # Initialize GUI - gui = MeshCoreGUI(shared_data) - - # Start BLE worker in separate thread - worker = BLEWorker(ble_address, shared_data) - worker.start() - - # Start NiceGUI server - ui.run( - title='MeshCore', - port=8080, - reload=False - ) - - -if __name__ == "__main__": - main() diff --git a/tools/ble_observe.py b/tools/ble_observe.py new file mode 100644 index 0000000..50dc4b0 --- /dev/null +++ b/tools/ble_observe.py @@ -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()) diff --git a/tools/ble_observe/__main__.py b/tools/ble_observe/__main__.py new file mode 100644 index 0000000..9ad4e8c --- /dev/null +++ b/tools/ble_observe/__main__.py @@ -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()) diff --git a/tools/ble_observe/cli.py b/tools/ble_observe/cli.py new file mode 100644 index 0000000..7cb6f83 --- /dev/null +++ b/tools/ble_observe/cli.py @@ -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