From e4b9947c1e5ff4eb4a605809407d245b0a570c80 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Tue, 3 Feb 2026 13:01:55 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 9 + LICENSE | 21 + README.md | 341 ++++++ docs/MeshCore_GUI_Design.docx | Bin 0 -> 11858 bytes docs/TROUBLESHOOTING.md | 182 +++ docs/ble_capture_workflow_t_1000_e_uitleg.md | 737 ++++++++++++ meshcore_gui.py | 1105 ++++++++++++++++++ 8 files changed, 2397 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/MeshCore_GUI_Design.docx create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/ble_capture_workflow_t_1000_e_uitleg.md create mode 100644 meshcore_gui.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dad5684 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +logs/*.log +logs/*.txt +.DS_Store +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f0356da --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 PE1HVH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f79c28e --- /dev/null +++ b/README.md @@ -0,0 +1,341 @@ +# MeshCore GUI + +A graphical user interface for MeshCore mesh network devices via Bluetooth Low Energy (BLE). + +![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) + +## 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). + +This project provides a **native desktop GUI** that connects to your MeshCore device over BLE — no firmware changes required. Your device stays on BLE Companion firmware and remains fully compatible with the mobile apps. The application is written in Python using cross-platform libraries and runs on **Linux, macOS and Windows**. + +> **Note:** This application has only been tested on Linux (Ubuntu 24.04). macOS and Windows should work since all dependencies (`bleak`, `nicegui`, `meshcore`) are cross-platform, but this has not been verified. Feedback and contributions for other platforms are welcome. + +Under the hood it uses `bleak` for Bluetooth Low Energy (which talks to BlueZ on Linux, CoreBluetooth on macOS, and WinRT on Windows), `meshcore` as the protocol layer, and `NiceGUI` for the web-based interface. + +> **Linux users:** BLE on Linux can be temperamental. BlueZ occasionally gets into a bad state, especially after repeated connect/disconnect cycles. If you run into connection issues, see the [Troubleshooting Guide](docs/TROUBLESHOOTING.md). On macOS and Windows, BLE is generally more stable out of the box. + +## TODO + +- **Message route visualization** — Display message paths on the map showing the route (hops) each message has taken through the mesh network +- **Message persistence** — Store sent and received messages to disk so chat history is preserved across sessions + +## Features + +- **Real-time Dashboard** - Device info, contacts, messages and RX log +- **Interactive Map** - Leaflet map with markers for own position and contacts +- **Channel Messages** - Send and receive messages on channels +- **Direct Messages** - Click on a contact to send a DM +- **Message Filtering** - Filter messages per channel via checkboxes +- **Threaded Architecture** - BLE communication in separate thread for stable UI + +## Screenshot + +Screenshot from 2026-02-03 10-23-25 + +## Requirements + +- Python 3.10+ +- Bluetooth Low Energy compatible adapter (built-in or USB) +- MeshCore device with BLE Companion firmware + +### Platform support + +| Platform | BLE Backend | Status | +|---|---|---| +| Linux (Ubuntu/Debian) | BlueZ/D-Bus | ✅ Tested | +| macOS | CoreBluetooth | ⬜ Untested | +| Windows 10/11 | WinRT | ⬜ Untested | + +## Installation + +### 1. System dependencies + +**Linux (Ubuntu/Debian):** +```bash +sudo apt update +sudo apt install python3-pip python3-venv bluetooth bluez +``` + +**macOS:** +```bash +# Python 3.10+ via Homebrew (if not already installed) +brew install python +``` +No additional Bluetooth packages needed — macOS has CoreBluetooth built in. + +**Windows:** +- Install [Python 3.10+](https://www.python.org/downloads/) (check "Add to PATH" during installation) +- No additional Bluetooth packages needed — Windows 10/11 has WinRT built in. + +### 2. Clone the repository + +```bash +git clone https://github.com/pe1hvh/meshcore-gui.git +cd meshcore-gui +``` + +### 3. Create virtual environment + +**Linux / macOS:** +```bash +python3 -m venv venv +source venv/bin/activate +``` + +**Windows:** +```cmd +python -m venv venv +venv\Scripts\activate +``` + +### 4. Install Python packages + +```bash +pip install nicegui meshcore bleak +``` + +## Usage + +### 1. Activate the virtual environment + +**Linux / macOS:** +```bash +cd meshcore-gui +source venv/bin/activate +``` + +**Windows:** +```cmd +cd meshcore-gui +venv\Scripts\activate +``` + +### 2. Find your BLE device address + +**Linux:** +```bash +bluetoothctl scan on +``` +Look for your MeshCore device and note the MAC address (e.g., `literal:AA:BB:CC:DD:EE:FF`). + +**macOS / Windows:** +```bash +python -c " +import asyncio +from bleak import BleakScanner +async def scan(): + devices = await BleakScanner.discover(5.0) + for d in devices: + if 'MeshCore' in (d.name or ''): + print(f'{d.address} {d.name}') +asyncio.run(scan()) +" +``` +On macOS the address will be a UUID (e.g., `12345678-ABCD-...`) rather than a MAC address. + +### 3. Configure channels + +Open `meshcore_gui.py` and adjust `CHANNELS_CONFIG` to your own channels: + +```python +CHANNELS_CONFIG = [ + {'idx': 0, 'name': 'Public'}, + {'idx': 1, 'name': '#test'}, + {'idx': 2, 'name': 'MyChannel'}, + {'idx': 3, 'name': '#local'}, +] +``` + +**Tip:** Use `meshcli` to determine your channels: + +```bash +meshcli -d literal:AA:BB:CC:DD:EE:FF +> get_channel 0 +> get_channel 1 +# etc. +``` + +### 4. Start the GUI + +```bash +python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF +``` + +Replace `literal:AA:BB:CC:DD:EE:FF` with the MAC address of your device. + +### 5. Open the interface + +The GUI opens automatically in your browser at `http://localhost:8080` + +## Configuration + +| Setting | Description | +|---------|-------------| +| `DEBUG` | Set to `True` for verbose logging | +| `CHANNELS_CONFIG` | List of channels (hardcoded due to BLE timing issues) | +| BLE Address | Command line argument | + +## Functionality + +### Device Info +- Name, frequency, SF/BW, TX power, location, firmware version + +### Contacts +- List of known nodes with type and location +- Click on a contact to send a DM + +### Map +- OpenStreetMap with markers for own position and contacts +- Shows your own position (blue marker) +- Automatically centers on your own position + +### Channel Messages +- Select a channel in the dropdown +- Type your message and click "Send" +- Received messages appear in the messages list +- Filter messages via the checkboxes + +### Direct Messages (DM) +- Click on a contact in the contacts list +- A dialog opens where you can type your message +- Click "Send" to send the DM + +### RX Log +- Received packets with SNR and type + +### Actions +- Refresh data +- Send advertisement + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Main Thread │ │ BLE Thread │ +│ (NiceGUI) │ │ (asyncio) │ +│ │ │ │ +│ ┌───────────┐ │ │ ┌───────────┐ │ +│ │ GUI │◄─┼──┬──┼─►│ BLEWorker │ │ +│ └───────────┘ │ │ │ └───────────┘ │ +│ │ │ │ │ │ │ +│ ▼ │ │ │ ▼ │ +│ ┌───────────┐ │ │ │ ┌───────────┐ │ +│ │ Timer │ │ │ │ │ MeshCore │ │ +│ │ (500ms) │ │ │ │ │ BLE │ │ +│ └───────────┘ │ │ │ └───────────┘ │ +└─────────────────┘ │ └─────────────────┘ + │ + ┌──────┴──────┐ + │ SharedData │ + │ (thread- │ + │ safe) │ + └─────────────┘ +``` + +- **BLEWorker**: Runs in separate thread with its own asyncio loop +- **SharedData**: Thread-safe data sharing between BLE and GUI +- **MeshCoreGUI**: NiceGUI interface in main thread +- **Communication**: Via queue (GUI→BLE) and shared state with flags (BLE→GUI) + +## Known Limitations + +1. **Channels hardcoded** - The `get_channel()` function in meshcore-py is unreliable via BLE +2. **send_appstart() sometimes fails** - Device info may remain empty with connection problems +3. **Initial load time** - GUI waits for BLE data before the first render is complete + +## Troubleshooting + +### Linux + +For comprehensive Linux BLE troubleshooting (including the `EOFError` / `start_notify` issue), see [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). + +#### Quick fixes + +##### GUI remains empty / BLE connection fails + +1. First disconnect any existing BLE connections: + ```bash + bluetoothctl disconnect literal:AA:BB:CC:DD:EE:FF + ``` +2. Wait 2 seconds: + ```bash + sleep 2 + ``` +3. Restart the GUI: + ```bash + python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF + ``` + +##### Bluetooth permissions + +```bash +sudo usermod -a -G bluetooth $USER +# Log out and back in +``` + +### macOS + +- Make sure Bluetooth is enabled in System Settings +- Grant your terminal app Bluetooth access when prompted +- Use the UUID address from BleakScanner, not a MAC address + +### Windows + +- Make sure Bluetooth is enabled in Settings → Bluetooth & devices +- Run the terminal as a regular user (not as Administrator — WinRT BLE can behave unexpectedly with elevated privileges) + +### All platforms + +#### Device not found + +Make sure the MeshCore device is powered on and in BLE Companion mode. Run the BleakScanner script from the Usage section to verify it is visible. + +#### Messages not arriving + +- Check if your channels are correctly configured +- Use `meshcli` to verify that messages are arriving + +## Development + +### Debug mode + +Set `DEBUG = True` in the script for verbose logging: + +```python +DEBUG = True +``` + +### Project structure + +``` +meshcore-gui/ +├── meshcore_gui.py # Main application +├── README.md # This file +└── docs/ + ├── TROUBLESHOOTING.md # BLE troubleshooting guide (Linux) + └── MeshCore_GUI_Design.docx # Design document +``` + +## 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. + +## License + +MIT License - see LICENSE file + +## Author + +**PE1HVH** - [GitHub](https://github.com/pe1hvh) + +## Acknowledgments + +- [MeshCore](https://github.com/meshcore-dev) - Mesh networking firmware and protocol +- [meshcore_py](https://github.com/meshcore-dev/meshcore_py) - Python bindings for MeshCore +- [meshcore-cli](https://github.com/meshcore-dev/meshcore-cli) - Command line interface +- [NiceGUI](https://nicegui.io/) - Python GUI framework +- [Bleak](https://github.com/hbldh/bleak) - Cross-platform Bluetooth Low Energy library diff --git a/docs/MeshCore_GUI_Design.docx b/docs/MeshCore_GUI_Design.docx new file mode 100644 index 0000000000000000000000000000000000000000..5e0ddda814bad9a28e2d322edae1521494a12ddc GIT binary patch literal 11858 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 get_channels +``` + +Confirm output matches `CHANNELS_CONFIG` in `meshcore_gui.py`, then: + +``` +> exit +``` + +### Step 6 - Start the GUI + +```bash +cd ~/Documents/Share/Web/meshcore-linux +source venv/bin/activate +python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF +``` + +## Things That Did NOT Help + +| Action | Result | +|---|---| +| `sudo systemctl restart bluetooth` | No effect | +| `sudo hciconfig hci0 down/up` | No effect | +| `sudo rmmod btusb && sudo modprobe btusb` | No effect | +| `sudo usbreset "8087:0026"` | No effect | +| `sudo reboot` | No effect | +| Clearing BlueZ cache (`/var/lib/bluetooth/*/cache`) | No effect | +| Recreating Python venv | No effect | +| Downgrading `dbus_fast` / `bleak` | No effect | +| Downgrading `linux-firmware` | No effect | + +## Key Takeaway + +When `start_notify` fails with `EOFError` but basic BLE connect works, the issue is almost always a stale BLE state between the host adapter and the peripheral device. The fix is: + +1. **Remove** the device from bluetoothctl +2. **Hard power cycle** the peripheral device +3. **Re-scan** and reconnect from scratch + +## Recommended Startup Sequence + +For the most reliable BLE connection, always follow this order: + +1. Ensure no other application holds the BLE connection (BT manager, bluetoothctl, meshcli) +2. Verify the device is visible: `bluetoothctl scan on` +3. Check channels: `meshcli -d ` → `get_channels` → `exit` +4. Start the GUI: `python meshcore_gui.py ` diff --git a/docs/ble_capture_workflow_t_1000_e_uitleg.md b/docs/ble_capture_workflow_t_1000_e_uitleg.md new file mode 100644 index 0000000..165632b --- /dev/null +++ b/docs/ble_capture_workflow_t_1000_e_uitleg.md @@ -0,0 +1,737 @@ +# BLE Capture Workflow T1000-e — Uitleg & Achtergrond + +> **Bron:** `ble_capture_workflow_t_1000_e.md` +> +> Dit document is een **verdiepingsdocument** bij het originele technische werkdocument. Het biedt: +> - Didactische uitleg van BLE-concepten en terminologie +> - Achtergrondkennis over GATT-services en hun werking +> - Context om toekomstige BLE-projecten beter te begrijpen +> +> **Doelgroep:** Mezelf, als referentie voor de lange termijn. + +--- + +## 1. Waar gaat dit document over? + +Het originele document beschrijft hoe je op een Linux-computer kunt "meeluisteren" naar de BLE-communicatie van een **MeshCore T1000-e** radio. Het legt uit: + +- Welke stappen nodig zijn om een verbinding op te zetten +- Waarom het vaak misgaat (en hoe je dat voorkomt) +- Hoe je ruwe protocol-data kunt opslaan voor analyse + +**De kernboodschap in één zin:** + +> Er mag maar **één luisteraar tegelijk** verbonden zijn met de T1000-e. Als iets anders al verbonden is, faalt jouw capture. + +--- + +## 2. Begrippen en afkortingen uitgelegd + +### 2.1 BLE — Bluetooth Low Energy + +BLE is een **zuinige variant van Bluetooth**, ontworpen voor apparaten die maanden of jaren op een batterij moeten werken. + +| Eigenschap | Klassiek Bluetooth | BLE | +|------------|-------------------|-----| +| Stroomverbruik | Hoog | Zeer laag | +| Datasnelheid | Hoog | Laag | +| Typisch gebruik | Audio, bestanden | Sensoren, IoT, MeshCore | + +**Analogie:** Klassiek Bluetooth is als een telefoongesprek (constant verbonden, veel energie). BLE is als SMS'jes sturen (kort contact wanneer nodig, weinig energie). + +--- + +### 2.2 GATT — Generic Attribute Profile + +GATT is de **structuur** waarmee BLE-apparaten hun data aanbieden. Zie het als een **digitaal prikbord** met een vaste indeling: + +``` +Service (categorie) + └── Characteristic (specifiek datapunt) + └── Descriptor (extra configuratie) +``` + +**Voorbeeld voor MeshCore:** + +``` +Nordic UART Service (NUS) + ├── RX Characteristic → berichten van radio naar computer + └── TX Characteristic → berichten van computer naar radio +``` + +--- + +### 2.3 NUS — Nordic UART Service + +NUS is een **standaard BLE-service** ontwikkeld door Nordic Semiconductor. Het simuleert een ouderwetse seriële poort (UART) over Bluetooth. + +- **RX** (Receive): Data die je **ontvangt** van het apparaat +- **TX** (Transmit): Data die je **verstuurt** naar het apparaat + +Let op: RX/TX zijn vanuit het perspectief van de computer, niet van de radio. + +#### Is NUS een protocol? + +**Nee.** NUS is een **servicespecificatie**, geen protocol. Dit is een belangrijk onderscheid: + +| Niveau | Wat is het | Voorbeeld | +|--------|-----------|-----------| +| **Protocol** | Regels voor communicatie | BLE, ATT, GATT | +| **Service** | Verzameling van gerelateerde characteristics | NUS, Heart Rate Service | +| **Characteristic** | Specifiek datapunt binnen een service | RX, TX | + +**Analogie met een restaurant:** + +| Concept | Restaurant-analogie | +|---------|---------------------| +| **Protocol (GATT)** | De regels: je bestelt bij de ober, eten komt uit de keuken | +| **Service (NUS)** | Een specifieke menukaart (bijv. "ontbijtmenu") | +| **Characteristics** | De individuele gerechten op dat menu | + +Mensen zeggen vaak "we gebruiken het NUS-protocol", maar strikt genomen is **GATT** het protocol en is **NUS** een service die via GATT wordt aangeboden. + +--- + +### 2.4 Andere GATT-services (officieel en custom) + +NUS is slechts één van vele BLE-services. De **Bluetooth SIG** (de organisatie achter Bluetooth) definieert tientallen officiële services. Daarnaast kunnen fabrikanten eigen (custom) services maken. + +#### Officiële services (Bluetooth SIG) + +Deze services hebben een **16-bit UUID** en zijn gestandaardiseerd voor interoperabiliteit: + +| Service | UUID | Toepassing | +|---------|------|------------| +| **Heart Rate Service** | 0x180D | Hartslagmeters, fitnessapparaten | +| **Battery Service** | 0x180F | Batterijniveau rapporteren | +| **Device Information** | 0x180A | Fabrikant, modelnummer, firmwareversie | +| **Blood Pressure** | 0x1810 | Bloeddrukmeters | +| **Health Thermometer** | 0x1809 | Medische thermometers | +| **Cycling Speed and Cadence** | 0x1816 | Fietssensoren | +| **Environmental Sensing** | 0x181A | Temperatuur, luchtvochtigheid, druk | +| **Glucose** | 0x1808 | Bloedglucosemeters | +| **HID over GATT** | 0x1812 | Toetsenborden, muizen, gamepads | +| **Proximity** | 0x1802 | "Find My"-functionaliteit | +| **Generic Access** | 0x1800 | **Verplicht** — apparaatnaam en uiterlijk | + +#### Custom/vendor-specific services + +Fabrikanten kunnen eigen services definiëren met een **128-bit UUID**. Voorbeelden: + +| Service | Fabrikant | Toepassing | +|---------|-----------|------------| +| **Nordic UART Service (NUS)** | Nordic Semiconductor | Seriële poort over BLE | +| **Apple Notification Center** | Apple | iPhone notificaties naar wearables | +| **Xiaomi Mi Band Service** | Xiaomi | Fitnesstracker communicatie | +| **MeshCore Companion** | MeshCore | Radio-communicatie (gebruikt NUS) | + +#### Het verschil: 16-bit vs. 128-bit UUID + +| Type | Lengte | Voorbeeld | Wie mag het maken? | +|------|--------|-----------|-------------------| +| **Officieel (SIG)** | 16-bit | `0x180D` | Alleen Bluetooth SIG | +| **Custom** | 128-bit | `6e400001-b5a3-f393-e0a9-e50e24dcca9e` | Iedereen | + +De NUS-service gebruikt bijvoorbeeld deze 128-bit UUID: +``` +6e400001-b5a3-f393-e0a9-e50e24dcca9e +``` + +#### Waarom dit relevant is + +In het MeshCore-project gebruiken we **NUS** (een custom service) voor de communicatie. Maar als je met andere BLE-apparaten werkt — zoals een hartslagmeter of een slimme thermostaat — dan gebruiken die vaak **officiële SIG-services**. + +Het principe blijft hetzelfde: +1. Ontdek welke services het apparaat aanbiedt +2. Zoek de juiste characteristic +3. Lees, schrijf, of abonneer op notify + +--- + +### 2.5 Notify vs. Read + +Er zijn twee manieren om data van een BLE-apparaat te krijgen: + +| Methode | Werking | Wanneer gebruiken | +|---------|---------|-------------------| +| **Read** | Jij vraagt actief om data | Eenmalige waarden (bijv. batterijstatus) | +| **Notify** | Apparaat stuurt automatisch bij nieuwe data | Continue datastroom (bijv. berichten) | + +**Analogie:** +- **Read** = Je belt iemand en vraagt "hoe gaat het?" +- **Notify** = Je krijgt automatisch een WhatsApp-bericht als er nieuws is + +Voor MeshCore-captures gebruik je **Notify** — je wilt immers weten wanneer er een bericht binnenkomt. + +--- + +### 2.6 CCCD — Client Characteristic Configuration Descriptor + +De CCCD is de **aan/uit-schakelaar voor Notify**. Technisch gezien: + +1. Jouw computer schrijft een `1` naar de CCCD +2. Het apparaat weet nu: "deze client wil notificaties" +3. Bij nieuwe data stuurt het apparaat automatisch een bericht + +**Het cruciale punt:** Slechts **één client tegelijk** kan de CCCD activeren. Een tweede client krijgt de foutmelding: + +``` +Notify acquired +``` + +Dit betekent: "iemand anders heeft notify al ingeschakeld." + +--- + +### 2.7 Pairing, Bonding en Trust + +Dit zijn drie afzonderlijke stappen in het BLE-beveiligingsproces: + +| Stap | Wat gebeurt er | Analogie | +|------|----------------|----------| +| **Pairing** | Apparaten wisselen cryptografische sleutels uit | Je maakt kennis en wisselt telefoonnummers | +| **Bonding** | De sleutels worden permanent opgeslagen | Je slaat het nummer op in je contacten | +| **Trust** | Het systeem vertrouwt het apparaat automatisch | Je zet iemand in je favorieten | + +Na deze drie stappen hoef je niet elke keer opnieuw de pincode in te voeren. + +**Controle in Linux:** + +```bash +bluetoothctl info literal:AA:BB:CC:DD:EE:FF | egrep -i "Paired|Bonded|Trusted" +``` + +Verwachte output: + +``` +Paired: yes +Bonded: yes +Trusted: yes +``` + +--- + +### 2.8 Ownership — Het kernprobleem + +**Ownership** is een informele term die aangeeft: "welke client heeft op dit moment de actieve GATT-sessie met notify?" + +**Analogie:** Denk aan een walkietalkie waarbij maar één persoon tegelijk kan luisteren: + +- Als GNOME Bluetooth Manager al verbonden is → die is de "eigenaar" +- Als jouw Python-script daarna probeert te verbinden → krijgt het geen toegang + +**Typische "eigenaren" die problemen veroorzaken:** + +- GNOME Bluetooth GUI (draait vaak op de achtergrond) +- `bluetoothctl connect` (maakt bluetoothctl de eigenaar) +- Telefoon met Bluetooth aan +- Andere BLE-apps + +--- + +### 2.9 BlueZ + +**BlueZ** is de officiële Bluetooth-stack voor Linux. Het is de software die alle Bluetooth-communicatie afhandelt tussen je applicaties en de hardware. + +--- + +### 2.10 Bleak + +**Bleak** is een Python-bibliotheek voor BLE-communicatie. Het bouwt voort op BlueZ (Linux), Core Bluetooth (macOS) of WinRT (Windows). + +--- + +## 3. BLE versus Classic Bluetooth + +Een veelvoorkomende vraag: zijn BLE en "gewone" Bluetooth hetzelfde? Het antwoord is **nee** — het zijn verschillende technologieën die wel dezelfde naam en frequentieband delen. + +### 3.1 Twee smaken van Bluetooth + +Sinds Bluetooth 4.0 (2010) zijn er **twee afzonderlijke radiosystemen** binnen de Bluetooth-standaard: + +| Naam | Technische term | Kenmerken | +|------|-----------------|-----------| +| **Classic Bluetooth** | BR/EDR (Basic Rate / Enhanced Data Rate) | Hoge datasnelheid, continue verbinding, meer stroom | +| **Bluetooth Low Energy** | BLE (ook: Bluetooth Smart) | Lage datasnelheid, korte bursts, zeer zuinig | + +**Cruciaal:** Dit zijn **verschillende radioprotocollen** die niet rechtstreeks met elkaar kunnen communiceren. + +### 3.2 Protocol én hardware + +Bluetooth (zowel Classic als BLE) omvat **meerdere lagen** — het is niet alleen een protocol, maar ook hardware: + +``` +┌─────────────────────────────────────────┐ +│ SOFTWARE │ +│ ┌───────────────────────────────────┐ │ +│ │ Applicatie (jouw code) │ │ +│ ├───────────────────────────────────┤ │ +│ │ Profielen / GATT Services │ │ +│ ├───────────────────────────────────┤ │ +│ │ Protocollen (ATT, L2CAP, etc.) │ │ +│ ├───────────────────────────────────┤ │ +│ │ Host Controller Interface (HCI) │ │ ← Grens software/firmware +│ └───────────────────────────────────┘ │ +├─────────────────────────────────────────┤ +│ FIRMWARE │ +│ ┌───────────────────────────────────┐ │ +│ │ Link Layer / Controller │ │ +│ └───────────────────────────────────┘ │ +├─────────────────────────────────────────┤ +│ HARDWARE │ +│ ┌───────────────────────────────────┐ │ +│ │ Radio (2.4 GHz transceiver) │ │ +│ ├───────────────────────────────────┤ │ +│ │ Antenne │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +### 3.3 Waar zit het verschil? + +Het verschil zit op **meerdere lagen**, niet alleen protocol: + +| Laag | Classic (BR/EDR) | BLE | Verschil in hardware? | +|------|------------------|-----|----------------------| +| **Radio** | GFSK, π/4-DQPSK, 8DPSK | GFSK | **Ja** — andere modulatie | +| **Kanalen** | 79 kanalen, 1 MHz breed | 40 kanalen, 2 MHz breed | **Ja** — andere indeling | +| **Link Layer** | LMP (Link Manager Protocol) | LL (Link Layer) | **Ja** — andere state machine | +| **Protocollen** | L2CAP, RFCOMM, SDP | L2CAP, ATT, GATT | Nee — software | + +### 3.4 Dual-mode apparaten + +De overlap zit in apparaten die **beide** ondersteunen: + +| Apparaattype | Ondersteunt | Voorbeeld | +|--------------|-------------|-----------| +| **Classic-only** | Alleen BR/EDR | Oude headsets, auto-audio | +| **BLE-only** (Bluetooth Smart) | Alleen BLE | Fitnesstrackers, sensoren, T1000-e | +| **Dual-Mode** (Bluetooth Smart Ready) | Beide | Smartphones, laptops, ESP32 | + +**Jouw smartphone** is dual-mode: hij kan praten met je klassieke Bluetooth-koptelefoon (BR/EDR) én met je MeshCore T1000-e (BLE). + +### 3.5 Praktijkvoorbeelden + +| Scenario | Wat wordt gebruikt | +|----------|-------------------| +| Muziek naar je koptelefoon | **Classic** (A2DP profiel) | +| Hartslag van je smartwatch | **BLE** (Heart Rate Service) | +| Bestand naar laptop sturen | **Classic** (OBEX/FTP profiel) | +| MeshCore T1000-e uitlezen | **BLE** (NUS service) | +| Handsfree bellen in auto | **Classic** (HFP profiel) | +| Slimme lamp bedienen | **BLE** (eigen GATT service) | + +--- + +## 4. BLE kanaalindeling en frequency hopping + +### 4.1 De 40 BLE-kanalen + +De 2.4 GHz ISM-band loopt van **2400 MHz tot 2483.5 MHz** (83.5 MHz breed). + +BLE verdeelt dit in **40 kanalen van elk 2 MHz**: + +``` +2400 MHz 2480 MHz + │ │ + ▼ ▼ + ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐ + │00│01│02│03│04│05│06│07│08│09│10│11│12│13│14│15│16│17│18│19│...→ 39 + └──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘ + └──────────────────────────────────────────────────────┘ + 2 MHz per kanaal +``` + +**Totaal:** 40 × 2 MHz = **80 MHz** gebruikt + +### 4.2 Advertising vs. data kanalen + +De 40 kanalen zijn niet allemaal gelijk: + +| Type | Kanalen | Functie | +|------|---------|---------| +| **Advertising** | 3 (nrs. 37, 38, 39) | Apparaten vinden, verbinding starten | +| **Data** | 37 (nrs. 0-36) | Daadwerkelijke communicatie na verbinding | + +De advertising-kanalen zijn strategisch gekozen om **Wi-Fi-interferentie** te vermijden: + +``` +Wi-Fi kanaal 1 Wi-Fi kanaal 6 Wi-Fi kanaal 11 + │ │ │ + ▼ ▼ ▼ +────████████─────────────█████████────────────████████──── + +BLE: ▲ ▲ ▲ + Ch.37 Ch.38 Ch.39 + + (advertising kanalen zitten tússen de Wi-Fi kanalen) +``` + +### 4.3 Vergelijking met Classic Bluetooth + +| Aspect | Classic (BR/EDR) | BLE | +|--------|------------------|-----| +| **Aantal kanalen** | 79 | 40 | +| **Kanaalbreedte** | 1 MHz | 2 MHz | +| **Totale bandbreedte** | 79 MHz | 80 MHz | +| **Frequency hopping** | Ja, alle 79 | Ja, 37 datakanalen | + +Classic heeft **meer maar smallere** kanalen, BLE heeft **minder maar bredere** kanalen. + +### 4.4 Frequency hopping: één kanaal tegelijk + +**Belangrijk inzicht:** Je gebruikt altijd maar **één kanaal tegelijk**. De 40 kanalen zijn er voor **frequency hopping** — het afwisselend wisselen van kanaal om interferentie te vermijden: + +``` +Tijd → + ┌───┐ ┌───┐ ┌───┐ ┌───┐ +Ch. 12 │ ▓ │ │ │ │ │ │ ▓ │ + └───┘ └───┘ └───┘ └───┘ + ┌───┐ ┌───┐ ┌───┐ ┌───┐ +Ch. 07 │ │ │ ▓ │ │ │ │ │ + └───┘ └───┘ └───┘ └───┘ + ┌───┐ ┌───┐ ┌───┐ ┌───┐ +Ch. 31 │ │ │ │ │ ▓ │ │ │ + └───┘ └───┘ └───┘ └───┘ + ↑ ↑ ↑ ↑ + Pakket 1 Pakket 2 Pakket 3 Pakket 4 +``` + +Dit is **geen parallelle communicatie** — het is serieel met wisselende frequentie. + +--- + +## 5. Twee betekenissen van "serieel" + +Wanneer we zeggen "NUS is serieel", kan dit verwarring veroorzaken. Het woord "serieel" heeft namelijk **twee verschillende betekenissen** in deze context. + +### 5.1 Radio-niveau: altijd serieel + +**Alle** draadloze communicatie is serieel op fysiek niveau — je hebt maar **één radiokanaal tegelijk** en bits gaan **na elkaar** de lucht in: + +``` +Radiogolf: ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▂▃▄▅▆▇█▇▆▅▄▃▂▁ + +Bits: 0 1 1 0 1 0 0 1 1 1 0 1 0 0 1 0 → één voor één +``` + +De 40 kanalen zijn voor **frequency hopping**, niet voor parallel versturen. Dit geldt voor **alle** BLE-services — NUS, Heart Rate, Battery, allemaal. + +### 5.2 Data-niveau: NUS simuleert een seriële poort + +Wanneer we zeggen "NUS is een seriële service", bedoelen we iets anders: + +**NUS simuleert een oude seriële poort (RS-232/UART):** + +``` +Historisch (jaren '80-'00): + + Computer Apparaat + ┌──────┐ Seriële kabel ┌──────┐ + │ COM1 │←────────────────→│ UART │ + └──────┘ (RS-232) └──────┘ + + Bytes: 0x48 0x65 0x6C 0x6C 0x6F ("Hello") + └─────────────────────┘ + Geen structuur, gewoon een stroom bytes +``` + +**NUS bootst dit na over BLE:** + +``` +Vandaag: + + Computer Apparaat + ┌──────┐ BLE (NUS) ┌──────┐ + │ App │←~~~~~~~~~~~~~~~~~~~~→│ MCU │ + └──────┘ (draadloos) └──────┘ + + Gedraagt zich alsof er een seriële kabel zit +``` + +### 5.3 Vergelijking: serieel vs. gestructureerd + +| Aspect | NUS (serieel) | Heart Rate (gestructureerd) | +|--------|---------------|----------------------------| +| **Radio** | Serieel, frequency hopping | Serieel, frequency hopping | +| **Data** | Ongestructureerde bytestroom | Vaste velden met betekenis | +| **Wie bepaalt formaat?** | Jij (eigen protocol) | Bluetooth SIG (specificatie) | + +### 5.4 Analogie: snelweg met rijstroken + +Denk aan een **snelweg met 40 rijstroken** (de kanalen): + +- Je mag maar **één rijstrook tegelijk** gebruiken +- Je wisselt regelmatig van rijstrook (frequency hopping, om botsingen te vermijden) +- De **vracht** die je vervoert kan verschillen: + +| Service | Vracht-analogie | +|---------|-----------------| +| **NUS** | Losse spullen door elkaar (flexibel, maar jij moet uitzoeken wat wat is) | +| **Heart Rate** | Gestandaardiseerde pallets (iedereen weet wat waar zit) | + +De **snelweg werkt hetzelfde** — het verschil zit in hoe je de vracht organiseert. + +--- + +## 6. Seriële vs. gestructureerde services (verdieping) + +Een belangrijk onderscheid dat vaak over het hoofd wordt gezien: **niet alle BLE-services werken hetzelfde**. Er zijn fundamenteel twee benaderingen. + +### 6.1 Seriële services (stream-gebaseerd) + +**NUS (Nordic UART Service)** is ontworpen om een **seriële poort te simuleren**: + +- Continue datastroom van ruwe bytes +- Geen opgelegde structuur +- Jij bepaalt zelf het formaat en de betekenis + +**Analogie:** Een seriële service is als een **telefoonlijn** — je kunt alles zeggen wat je wilt, in elke taal, zonder vaste regels. + +``` +Voorbeeld NUS-data (MeshCore): +0x01 0x0A 0x48 0x65 0x6C 0x6C 0x6F ... + └── Betekenis bepaald door MeshCore protocol, niet door BLE +``` + +### 6.2 Gestructureerde services (veld-gebaseerd) + +De meeste officiële SIG-services werken **anders** — ze definiëren **exact** welke bytes wat betekenen: + +**Analogie:** Een gestructureerde service is als een **belastingformulier** — elk vakje heeft een vaste betekenis en een voorgeschreven formaat. + +#### Voorbeeld: Heart Rate Measurement + +``` +Byte 0: Flags (bitfield) + ├── Bit 0: 0 = hartslag in 1 byte, 1 = hartslag in 2 bytes + ├── Bit 1-2: Sensor contact status + ├── Bit 3: Energy expended aanwezig? + └── Bit 4: RR-interval aanwezig? + +Byte 1(-2): Heart rate waarde +Byte N...: Optionele extra velden (afhankelijk van flags) +``` + +**Concreet voorbeeld:** + +``` +Ontvangen bytes: 0x00 0x73 + +0x00 = Flags: 8-bit formaat, geen extra velden +0x73 = 115 decimaal → hartslag is 115 bpm +``` + +Je krijgt dus niet de tekst "115", maar een binair pakket dat je moet **parsen** volgens de specificatie. + +#### Voorbeeld: Battery Level + +Eenvoudiger — slechts **1 byte**: + +``` +Ontvangen byte: 0x5A + +0x5A = 90 decimaal → batterij is 90% +``` + +#### Voorbeeld: Environmental Sensing (temperatuur) + +``` +Ontvangen bytes: 0x9C 0x08 + +Little-endian 16-bit signed integer: 0x089C = 2204 +Resolutie: 0.01°C +Temperatuur: 2204 × 0.01 = 22.04°C +``` + +### 6.3 Vergelijkingstabel + +| Aspect | Serieel (NUS) | Gestructureerd (SIG) | +|--------|---------------|----------------------| +| **Data-indeling** | Vrij, zelf bepalen | Vast, door specificatie | +| **Wie definieert het formaat?** | Jij / de fabrikant | Bluetooth SIG | +| **Waar vind je de specificatie?** | Eigen documentatie / broncode | bluetooth.com/specifications | +| **Parsing** | Eigen parser bouwen | Standaard parser mogelijk | +| **Interoperabiliteit** | Alleen eigen software | Elke conforme app/device | +| **Flexibiliteit** | Maximaal | Beperkt tot spec | +| **Complexiteit** | Eenvoudig te starten | Spec lezen vereist | + +### 6.4 Voorbeelden van gestructureerde services + +| Service | Characteristic | Data-formaat | +|---------|----------------|--------------| +| **Battery Service** | Battery Level | 1 byte: 0-100 (percentage) | +| **Heart Rate** | Heart Rate Measurement | Flags + 8/16-bit HR + optionele velden | +| **Health Thermometer** | Temperature Measurement | IEEE-11073 FLOAT (4 bytes) | +| **Blood Pressure** | Blood Pressure Measurement | Compound: systolisch, diastolisch, MAP, pulse | +| **Cycling Speed & Cadence** | CSC Measurement | 32-bit tellers + 16-bit tijd | +| **Environmental Sensing** | Temperature | 16-bit signed, resolutie 0.01°C | +| **Environmental Sensing** | Humidity | 16-bit unsigned, resolutie 0.01% | +| **Environmental Sensing** | Pressure | 32-bit unsigned, resolutie 0.1 Pa | + +### 6.5 Wanneer welke aanpak? + +| Situatie | Aanbevolen aanpak | +|----------|-------------------| +| Eigen protocol (MeshCore, custom IoT) | **Serieel** (NUS of eigen service) | +| Standaard use-case (hartslag, batterij) | **Gestructureerd** (SIG-service) | +| Interoperabiliteit met bestaande apps vereist | **Gestructureerd** (SIG-service) | +| Complexe, variabele datastructuren | **Serieel** met eigen protocol | +| Snel prototype zonder spec-studie | **Serieel** (NUS) | + +### 6.6 Waarom MeshCore NUS gebruikt + +MeshCore koos voor NUS (serieel) omdat: + +1. **Flexibiliteit** — Het Companion Protocol heeft eigen framing nodig +2. **Geen passende SIG-service** — Er is geen "Mesh Radio Service" standaard +3. **Bidirectionele communicatie** — NUS biedt RX én TX characteristics +4. **Eenvoud** — Geen complexe SIG-specificatie implementeren + +Het nadeel: je kunt niet zomaar een willekeurige BLE-app gebruiken om met MeshCore te praten — je hebt software nodig die het MeshCore Companion Protocol begrijpt. + +--- + +## 7. Het OSI-model in context + +Het document plaatst het probleem in een **lagenmodel**. Dit helpt begrijpen *waar* het probleem zit: + +| Laag | Naam | In dit project | Probleem hier? | +|------|------|----------------|----------------| +| 7 | Applicatie | MeshCore Companion Protocol | Nee | +| 6 | Presentatie | Frame-encoding (hex) | Nee | +| **5** | **Sessie** | **GATT client ↔ server sessie** | **★ JA** | +| 4 | Transport | ATT / GATT | Nee | +| 2 | Data Link | BLE Link Layer | Nee | +| 1 | Fysiek | 2.4 GHz radio | Nee | + +**Conclusie:** Het ownership-probleem zit op **laag 5 (sessie)**. De firmware en het protocol zijn niet het probleem — het gaat om wie de sessie "bezit". + +--- + +## 8. De workflow samengevat + +### 8.1 Eenmalige voorbereiding + +```bash +# Start bluetoothctl +bluetoothctl + +# Activeer Bluetooth +power on +agent on +default-agent +scan on + +# Wacht tot device zichtbaar is, dan pairen +pair literal:AA:BB:CC:DD:EE:FF +# Voer pincode in (bijv. 123456) + +# Vertrouw het apparaat +trust literal:AA:BB:CC:DD:EE:FF +quit +``` + +### 8.2 Vóór elke capture + +1. **Telefoon Bluetooth UIT** +2. **GNOME Bluetooth GUI SLUITEN** +3. **Expliciet disconnecten:** + +```bash +bluetoothctl disconnect literal:AA:BB:CC:DD:EE:FF +``` + +4. **Controleer dat niemand verbonden is:** + +```bash +bluetoothctl info literal:AA:BB:CC:DD:EE:FF | egrep -i "Connected" +# Verwacht: Connected: no +``` + +### 8.3 Capture starten + +```bash +python tools/ble_observe.py \ + --address literal:AA:BB:CC:DD:EE:FF \ + --pre-scan-seconds 10 \ + --connect-timeout 30 \ + --notify \ + --notify-seconds 30 \ + --notify-start-timeout 30 \ + --disconnect-timeout 15 \ + --capture captures/session_$(date -u +%Y-%m-%d_%H%M%S).raw +``` + +--- + +## 9. Veelgemaakte fouten + +| Fout | Gevolg | Oplossing | +|------|--------|-----------| +| GNOME Bluetooth Manager open | `Notify acquired` | GUI sluiten | +| `bluetoothctl connect` gedaan | Tool faalt bij notify | Altijd `disconnect` eerst | +| Telefoon Bluetooth aan | Telefoon claimt verbinding | Telefoon BT uit | +| Meerdere scripts tegelijk | Eerste wint, rest faalt | Één tool tegelijk | + +--- + +## 10. Visueel overzicht (sequentiediagram) + +``` +┌──────┐ ┌───────┐ ┌───────┐ ┌───────┐ +│ User │ │ Linux │ │ BlueZ │ │ Radio │ +└──┬───┘ └───┬───┘ └───┬───┘ └───┬───┘ + │ │ │ │ + │ pair/trust │ │ │ + │────────────>│────────────────────────-->│ (eenmalig) + │ │ │ │ + │ │ Geen actieve verbinding│ + │ │ │ │ + │ start tool │ │ │ + │────────────>│ │ │ + │ │ connect() │ │ + │ │────────────>│────────────>│ + │ │ │ │ + │ │start_notify │ │ + │ │────────────>│────────────>│ + │ │ │ │ + │ │ notify (data frames) │ + │ │<────────────│<────────────│ + │ │ │ │ + │ │ stop_notify │ │ + │ │────────────>│────────────>│ + │ │ │ │ + │ │ disconnect │ │ + │ │────────────>│────────────>│ + │ │ │ │ +``` + +--- + +## 11. Conclusie + +Het document bewijst dat: + +- ✅ MeshCore BLE companion **werkt correct** op Linux +- ✅ De firmware **blokkeert notify niet** +- ✅ Het enige vereiste is: **exact één actieve BLE client per radio** + +Wanneer je deze workflow volgt, zijn captures reproduceerbaar en is verdere protocol-analyse mogelijk. + +--- + +## 12. Referenties + +- MeshCore Companion Radio Protocol: [GitHub Wiki](https://github.com/meshcore-dev/MeshCore/wiki/Companion-Radio-Protocol) +- Bluetooth SIG Assigned Numbers (officiële services): [bluetooth.com/specifications/assigned-numbers](https://www.bluetooth.com/specifications/assigned-numbers/) +- Bluetooth SIG GATT Specifications: [bluetooth.com/specifications/specs](https://www.bluetooth.com/specifications/specs/) +- Nordic Bluetooth Numbers Database: [GitHub](https://github.com/NordicSemiconductor/bluetooth-numbers-database) +- GATT Uitleg (Adafruit): [learn.adafruit.com](https://learn.adafruit.com/introduction-to-bluetooth-low-energy/gatt) +- Bleak documentatie: [bleak.readthedocs.io](https://bleak.readthedocs.io/) +- BlueZ: [bluez.org](http://www.bluez.org/) + +--- + +> **Document:** `ble_capture_workflow_t_1000_e_uitleg.md` +> **Gebaseerd op:** `ble_capture_workflow_t_1000_e.md` diff --git a/meshcore_gui.py b/meshcore_gui.py new file mode 100644 index 0000000..ce073ed --- /dev/null +++ b/meshcore_gui.py @@ -0,0 +1,1105 @@ +#!/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()