feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax

This commit is contained in:
pe1hvh
2026-03-14 20:58:56 +01:00
parent d8a7947c6b
commit 2963e1c855
29 changed files with 6832 additions and 282 deletions

View File

@@ -1,12 +1,4 @@
## [1.13.1] - 2026-03-09
### Fixed
- Route map markers now use the same JS-rendered node icons as the main MAP instead of NiceGUI default blue markers.
- Route detail pages now bootstrap their Leaflet assets explicitly so the shared map icon runtime is available there too.
### Changed
- Route maps are now rendered browser-side through the shared Leaflet JS runtime for icon consistency with MAP, Messages, and Archive.
# CHANGELOG
@@ -16,43 +8,181 @@
All notable changes to MeshCore GUI are documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
---
## [1.13.1] - 2026-03-09 — Message Icon Consistency
> **📈 Performance note — v1.13.1 through v1.13.4**
> Although versions 1.13.11.13.4 were released as targeted bugfix releases, the
> cumulative effect of the fixes delivered a significant performance improvement:
>
> - **v1.13.1** — Bot non-response fix eliminated a silent failure path that caused
> repeated dedup-marked command re-evaluation on every message tick.
> - **v1.13.2** — Map display fixes prevented Leaflet from being initialized on hidden
> zero-size containers, removing a source of repeated failed bootstrap retries and
> associated DOM churn.
> - **v1.13.3** — Active panel timer gating reduced the 500 ms dashboard update work to
> only the currently visible panel, cutting unnecessary UI updates and background
> redraw load substantially — especially noticeable over VPN or on slower hardware.
> - **v1.13.4** — Room Server event classification fix and sender name resolution removed
> redundant fallback processing paths and reduced per-tick contact lookup overhead.
>
> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching,
> lower CPU usage during idle operation, and more stable map rendering.
---
## [1.14.0] - 2026-03-14 — BBS (Bulletin Board System)
### Added
- 🆕 **BBS — Bulletin Board System** — offline berichtenbord voor mesh-netwerken.
- **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel.
- Korte syntax: `!p <cat> <tekst>` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`).
- Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`.
- Optioneel regio-filter en handmatige allowed-keys override in Advanced.
- Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieën, retentie, Advanced voor regio's en handmatige keys.
- Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode).
### Changed
- 🔄 `meshcore_gui/gui/constants.py` — Added shared helper functions to resolve node-type icons and labels from the same contact type mapping used by the map and contacts panel
- 🔄 `meshcore_gui/core/models.py``Message.format_line()` now supports an optional sender prefix so message-related views can prepend the same node icon set without changing existing formatting logic
- 🔄 `meshcore_gui/gui/panels/messages_panel.py` — Message rows now prepend the sender with the same node icon mapping as the map/contact views
- 🔄 `meshcore_gui/gui/archive_page.py`Archive rows now use the same sender icon mapping as the live messages panel and map/contact views
- 🔄 `meshcore_gui/gui/route_page.py`Route header and route detail table now show node-type icons derived from the shared contact type mapping instead of generic hardcoded role icons
- 🔄 **`ble/events.py`**`on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden volledig los van `MeshBot`.
- 🔄 **`services/bot.py`**`MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd.
- 🔄 **`services/bbs_config_store.py`**`configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`.
- 🔄 **`gui/dashboard.py`**`BbsPanel` geregistreerd, `📋 BBS` drawer-item toegevoegd.
### Storage
```
~/.meshcore-gui/bbs/bbs_config.json — board configuratie
~/.meshcore-gui/bbs/bbs_messages.db — SQLite berichtenopslag
```
---
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
### Fixed
- 🛠 **Route page back-button navigated to main menu regardless of origin** — the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page.
- 🛠 **Map marker popup flickered on every 500 ms update tick** — the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible.
- 🛠 **Map marker popup appeared with a flicker/flash on first click (main map and route map)** — Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts.
### Changed
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`.
- 🔄 `meshcore_gui/static/leaflet_map_panel.js``applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`.
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.5`.
### Impact
- Message-driven views now use one consistent icon language across map, contacts, messages, archive and route detail
- Existing map runtime and panel behavior remain unchanged
- No breaking changes outside icon rendering
- Back navigation from the route page now always returns to the correct origin screen.
- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed.
- Popup opening is instant on both maps; no animation artefacts on low-power hardware.
---
## [1.13.4] - 2026-03-12 — Room Server message classification fix
### Fixed
- 🛠 **Incoming room messages from other participants could be misclassified as normal DMs**`CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`.
- 🛠 **Incoming room traffic could be attached to the wrong key** — room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`.
- 🛠 **Room login UI could stay out of sync with the actual server-confirmed state**`LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key.
- 🛠 **Room Server panel showed hex codes instead of sender names** — when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known.
### Changed
- 🔄 `meshcore_gui/ble/events.py` — Broadened room payload parsing and added payload-key debug logging for incoming room traffic.
- 🔄 `meshcore_gui/ble/worker.py``LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history.
- 🔄 `meshcore_gui/config.py` — Version kept at `1.13.4`.
### Impact
- Keeps the existing Room Server panel logic intact.
- Fix is limited to room event classification and room login confirmation handling.
- No intended behavioural change for ordinary DMs or channel messages.
---
---
## [1.13.3] - 2026-03-12 — Active Panel Timer Gating
### Changed
- 🔄 `meshcore_gui/gui/dashboard.py` — The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only
- 🔄 `meshcore_gui/gui/dashboard.py` — Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick
- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.3`
### Fixed
- 🛠 **Hidden panels still refreshed every 500 ms** — Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active
- 🛠 **Map bootstrap activity while panel is not visible** — Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic
- 🛠 **Slow navigation over VPN** — Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel
### Impact
- Faster panel switching because the selected panel gets one direct refresh immediately
- Lower background UI/update load on dashboard level, especially when the map panel is not active
- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage
- No intended functional regression for route maps or visible panel behaviour
---
## [1.13.2] - 2026-03-11 — Map Display Bugfix
### Fixed
- 🛠 **MAP panel blank when contacts list is empty at startup** — dashboard update loop
had two separate conditional map-update blocks that both silently stopped firing after
tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and
remained blank indefinitely.
- 🛠 **Leaflet map initialized on hidden (zero-size) container**`processPending` in
the browser runtime called `L.map()` on the host element while it was still
`display:none` (Vue v-show, panel not yet visible). This produced a broken 0×0 map
that never recovered because `ensureMap` returned the cached broken state on all
subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`:
initialization is deferred until the host has real dimensions.
- 🛠 **Route map container had no height**`route_page.py` used the Tailwind class
`h-96` for the Leaflet host `<div>`. NiceGUI/Quasar does not include Tailwind CSS,
so `h-96` had no effect and the container rendered at height 0. Leaflet initialized
on a zero-height element and produced a blank map.
- 🛠 **Route map not rendered when no node has GPS coordinates**`_render_map`
returned early before creating the Leaflet container when `payload['nodes']` was
empty. Fixed: container is always created; a notice label is shown instead.
### Changed
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Added size guard in `ensureMap`:
returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map
state exists yet. `processPending` retries on the next tick once the panel is visible.
- 🔄 `meshcore_gui/gui/dashboard.py` — Consolidated two conditional map-update blocks
into a single unconditional update while the MAP panel is active. Added `h-96` to the
DOMCA CSS height overrides for consistency with the route page map container.
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced `h-96` Tailwind class on the route
map host `<div>` with an explicit inline `style` (height: 24rem). Removed early
`return` guard so the Leaflet container is always created.
### Impact
- MAP panel now renders reliably on first open regardless of contact/GPS availability
- Route map now always shows with correct height even when route nodes have no GPS
- No breaking changes outside the three files listed above
---
## [1.13.0] - 2026-03-09 — Leaflet Map Runtime Stabilization
### Added
-`meshcore_gui/static/leaflet_map_panel.js` — Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry and theme handling independent from NiceGUI redraw cycles
-`meshcore_gui/static/leaflet_map_panel.css` — Styling for browser-side node markers and map container
-`meshcore_gui/static/leaflet_map_panel.js` — Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry, clustering and theme handling independent from NiceGUI redraw cycles
-`meshcore_gui/static/leaflet_map_panel.css` — Styling for browser-side node markers, cluster icons and map container
-`meshcore_gui/services/map_snapshot_service.py` — Snapshot service that normalizes device/contact map data into a compact payload for the browser runtime
- ✅ Browser-side map state management for center, zoom and theme
- ✅ Theme persistence across reconnect events via browser storage fallback
- ✅ Browser-side contact clustering via `Leaflet.markercluster`
- ✅ Separate non-clustered device marker layer so the own device remains individually visible
### Changed
- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Replaced NiceGUI Leaflet wrapper usage with a pure browser-managed Leaflet container while preserving the existing card layout, theme toggle and center-on-device control
- 🔄 Leaflet bootstrap moved out of inline Python into a dedicated browser runtime loaded from `/static`
- 🔄 Asset loading order is now explicit: Leaflet first, then `Leaflet.markercluster`, then the MeshCore panel runtime
- 🔄 Map initialization now occurs only once per container; NiceGUI refresh cycles no longer recreate the map
- 🔄 Dashboard update loop now sends compact map snapshots instead of triggering redraws
- 🔄 Snapshot processing in the browser is coalesced so only the newest payload is applied
- 🔄 Map markers are managed in separate device/contact layers and updated incrementally by stable node id
- 🔄 Contact markers are rendered inside a persistent cluster layer while the device marker remains outside clustering
- 🔄 Theme switching moved to a dedicated theme channel instead of being embedded in snapshot data
### Fixed
- 🛠 **Map disappearing during dashboard refresh cycles** — prevented repeated map reinitialization caused by the 500 ms NiceGUI update loop
- 🛠 **Markers disappearing between refreshes** — marker updates are now incremental and keyed by node id
- 🛠 **Blank map container on load** — browser bootstrap now waits for DOM host, Leaflet runtime and panel runtime before initialization
- 🛠 **Leaflet clustering bootstrap failure (`L is not defined`)** — resolved by enforcing correct script dependency order before the panel runtime starts
- 🛠 **MarkerClusterGroup failure (`Map has no maxZoom specified`)** — the map now defines `maxZoom` during initial creation before the cluster layer is attached
- 🛠 **Half-initialized map retry cascade (`Map container is already initialized`)** — map state is now registered safely during initialization so a failed attempt cannot trigger a second `L.map(...)` on the same container
- 🛠 **Race condition between queued snapshot and theme selection** — explicit theme changes can no longer be overwritten by stale snapshot payloads
- 🛠 **Viewport jumping back to default center/zoom** — stored viewport is no longer reapplied on each snapshot update
- 🛠 **Theme reverting to default during reconnect** — effective map theme is restored before snapshot processing resumes
@@ -60,6 +190,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
### Impact
- Leaflet map is now managed entirely in the browser and is no longer recreated on each dashboard refresh
- Node markers remain stable and no longer flicker or disappear during the 500 ms update cycle
- Dense contact sets can now be rendered with clustering without violating the browser-owned map lifecycle
- Theme switching and viewport state persist reliably across reconnect events
- No breaking changes outside the map subsystem
---
@@ -787,20 +918,3 @@ overwriting all historical data with only the new buffered messages.
- explicit theme changes are now handled only via the dedicated theme channel
- initial map render now sends an ensure_map command plus an immediate theme sync
- added no-op ensure_map handling in the Leaflet runtime to avoid accidental fallback behaviour
## [1.13.0] - 2026-03-09
### Added
- Leaflet marker clustering using Leaflet.markercluster for contact nodes.
- Browser-side cluster rendering with the device marker kept outside the cluster layer.
- Cluster performance tuning with `chunkedLoading: true`.
- Spiderfy support at max zoom for overlapping markers.
### Fixed
- Wrong asset load order causing `L is not defined` in MarkerClusterGroup.
- Cluster initialization failure caused by missing `maxZoom` on map startup.
- Retry cascade causing `Map container is already initialized`.
### Changed
- Map lifecycle is browser-owned: NiceGUI hosts the container, Leaflet owns map state.
- Contact markers are updated incrementally in the existing cluster layer.

103
README.md
View File

@@ -7,6 +7,8 @@
![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-orange.svg)
![Transport](https://img.shields.io/badge/Transport-USB%20Serial%20%7C%20BLE-blueviolet.svg)
![Bridge](https://img.shields.io/badge/Bridge-Cross--Frequency%20LoRa%20↔%20LoRa-ff6600.svg)
<img width="920" height="769" alt="image" src="https://github.com/user-attachments/assets/ff38b97f-557b-4217-abce-aaec68a90c35" />
A graphical user interface for MeshCore mesh network devices with native USB serial and Bluetooth Low Energy (BLE) support, for on your desktop or as a headless service on your local network.
@@ -131,6 +133,8 @@ Under the hood it uses `meshcore` as the protocol layer, `meshcoredecoder` for r
<img width="1000" height="873" alt="a_Screenshots" src="https://github.com/user-attachments/assets/bd19de9a-05f7-43fd-8acd-3b92cdf6c7fa" />
<img width="944" height="877" alt="Screenshot from 2026-02-18 09-27-59" src="https://github.com/user-attachments/assets/6b0b19f4-9886-4cca-bd36-50b4c3797e02" />
<img width="944" height="877" alt="Screenshot from 2026-02-18 09-28-27" src="https://github.com/user-attachments/assets/374694fa-ab2d-4b96-b81f-6a351af7710a" />
<img width="920" height="769" alt="image" src="https://github.com/user-attachments/assets/78b8112a-1ad8-460c-99ae-a16d15b14b77" />
## 4. Requirements
@@ -1180,11 +1184,108 @@ meshcore-gui/
└── README.md
```
## 15. Roadmap
## 15. BBS — Bulletin Board System
MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging.
### Access model
The operator links one or more channels to the BBS. Anyone who sends a message on a configured BBS channel is automatically added to the whitelist. After that, they can send commands via **Direct Message** to the BBS node — the channel itself stays clean.
```
First contact: !bbs help on the configured channel
→ node sees the public key → whitelists it
After that: !p U need assistance as DM to the node
→ processed, reply sent back via DM
```
Anyone who has never sent a message on a configured channel is not on the whitelist and is silently ignored.
### Settings
Open via the gear icon (⚙) in the BBS panel, or navigate to `/bbs-settings`.
```
BBS Settings
──────────────────────────────────────────
Channels: ☑ [1] NoodNet Zwolle
☑ [2] NoodNet Dalfsen
☐ [3] NoodNet OV
Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL
Retain: 48 hours
[Save]
▶ Advanced
Regions (comma-separated)
Allowed keys (empty = auto-learned from channel activity)
```
- **Channels** — check all channels whose participants should have access to the BBS. Multiple channels can be selected.
- **Categories** — comma-separated list of valid category tags.
- **Retain** — message retention in hours (default 48).
- **Advanced → Regions** — optional region tags for geographic filtering.
- **Advanced → Allowed keys** — manual whitelist override; leave empty to rely on auto-learned keys only.
### Command syntax
#### Short syntax
| Command | Description |
|---|---|
| `!p <cat> <text>` | Post a message |
| `!p <region> <cat> <text>` | Post with region |
| `!r` | Read 5 most recent messages (all categories) |
| `!r <cat>` | Read filtered by category |
| `!r <region> <cat>` | Read filtered by region and category |
Category abbreviations are computed automatically as the shortest unique prefix within the configured list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`:
```
U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
```
If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), longer prefixes are calculated automatically: `ME` and `MI`. The `!r` (without arguments) and `!bbs help` replies always include the current abbreviation table.
#### Full syntax
| Command | Description |
|---|---|
| `!bbs help` | Show commands and abbreviation table |
| `!bbs post <category> <text>` | Post a message |
| `!bbs post <region> <category> <text>` | Post with region |
| `!bbs read` | Read 5 most recent messages |
| `!bbs read <category>` | Read filtered by category |
| `!bbs read <region> <category>` | Read filtered by region and category |
#### Example help reply
```
BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL
```
### Error handling
| Situation | Reply |
|---|---|
| Unknown category | Lists valid categories and abbreviations |
| Ambiguous abbreviation | Lists all matching categories |
| Sender not on whitelist | Silent drop — no reply |
### Storage
```
~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store (WAL mode)
~/.meshcore-gui/bbs/bbs_config.json — Board configuration
```
---
## 16. Roadmap
This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include:
- [x] **Cross-frequency bridge** — standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge))
- [x] **BBS — Bulletin Board System** — offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system))
- [ ] **Observer mode** — passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging
- [ ] **Room Server administration** — authenticate as admin to manage Room Server settings and users directly from the GUI
- [ ] **Repeater management** — connect to repeater nodes to view status and adjust configuration

View File

@@ -1,3 +1,5 @@
# CHANGELOG
<!-- CHANGED: Title changed from "CHANGELOG: Message & Metadata Persistence" to "CHANGELOG" —
@@ -6,6 +8,153 @@
All notable changes to MeshCore GUI are documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
---
> **📈 Performance note — v1.13.1 through v1.13.4**
> Although versions 1.13.11.13.4 were released as targeted bugfix releases, the
> cumulative effect of the fixes delivered a significant performance improvement:
>
> - **v1.13.1** — Bot non-response fix eliminated a silent failure path that caused
> repeated dedup-marked command re-evaluation on every message tick.
> - **v1.13.2** — Map display fixes prevented Leaflet from being initialized on hidden
> zero-size containers, removing a source of repeated failed bootstrap retries and
> associated DOM churn.
> - **v1.13.3** — Active panel timer gating reduced the 500 ms dashboard update work to
> only the currently visible panel, cutting unnecessary UI updates and background
> redraw load substantially — especially noticeable over VPN or on slower hardware.
> - **v1.13.4** — Room Server event classification fix and sender name resolution removed
> redundant fallback processing paths and reduced per-tick contact lookup overhead.
>
> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching,
> lower CPU usage during idle operation, and more stable map rendering.
---
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
### Added
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`.
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`.
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel voor het dashboard.
- Board-selector (knoppen per geconfigureerd board).
- Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft).
- Scrollbare berichtenlijst over alle channels van het actieve board.
- Post-formulier: post op het eerste channel van het board.
- **Settings-sectie**: boards aanmaken (naam → Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieën, regio's, retentie, whitelist, Save en Delete.
### Changed
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`.
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd; versie `1.14.0`.
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `📋 BBS` drawer-item.
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
### Storage
```
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
```
### Not changed
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
---
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
### Fixed
- 🛠 **Route page back-button navigated to main menu regardless of origin** — the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page.
- 🛠 **Map marker popup flickered on every 500 ms update tick** — the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible.
- 🛠 **Map marker popup appeared with a flicker/flash on first click (main map and route map)** — Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts.
### Changed
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`.
- 🔄 `meshcore_gui/static/leaflet_map_panel.js``applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`.
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.5`.
### Impact
- Back navigation from the route page now always returns to the correct origin screen.
- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed.
- Popup opening is instant on both maps; no animation artefacts on low-power hardware.
---
## [1.13.4] - 2026-03-12 — Room Server message classification fix
### Fixed
- 🛠 **Incoming room messages from other participants could be misclassified as normal DMs**`CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`.
- 🛠 **Incoming room traffic could be attached to the wrong key** — room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`.
- 🛠 **Room login UI could stay out of sync with the actual server-confirmed state**`LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key.
- 🛠 **Room Server panel showed hex codes instead of sender names** — when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known.
### Changed
- 🔄 `meshcore_gui/ble/events.py` — Broadened room payload parsing and added payload-key debug logging for incoming room traffic.
- 🔄 `meshcore_gui/ble/worker.py``LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history.
- 🔄 `meshcore_gui/config.py` — Version kept at `1.13.4`.
### Impact
- Keeps the existing Room Server panel logic intact.
- Fix is limited to room event classification and room login confirmation handling.
- No intended behavioural change for ordinary DMs or channel messages.
---
---
## [1.13.3] - 2026-03-12 — Active Panel Timer Gating
### Changed
- 🔄 `meshcore_gui/gui/dashboard.py` — The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only
- 🔄 `meshcore_gui/gui/dashboard.py` — Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick
- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.3`
### Fixed
- 🛠 **Hidden panels still refreshed every 500 ms** — Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active
- 🛠 **Map bootstrap activity while panel is not visible** — Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic
- 🛠 **Slow navigation over VPN** — Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel
### Impact
- Faster panel switching because the selected panel gets one direct refresh immediately
- Lower background UI/update load on dashboard level, especially when the map panel is not active
- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage
- No intended functional regression for route maps or visible panel behaviour
---
## [1.13.2] - 2026-03-11 — Map Display Bugfix
### Fixed
- 🛠 **MAP panel blank when contacts list is empty at startup** — dashboard update loop
had two separate conditional map-update blocks that both silently stopped firing after
tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and
remained blank indefinitely.
- 🛠 **Leaflet map initialized on hidden (zero-size) container**`processPending` in
the browser runtime called `L.map()` on the host element while it was still
`display:none` (Vue v-show, panel not yet visible). This produced a broken 0×0 map
that never recovered because `ensureMap` returned the cached broken state on all
subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`:
initialization is deferred until the host has real dimensions.
- 🛠 **Route map container had no height**`route_page.py` used the Tailwind class
`h-96` for the Leaflet host `<div>`. NiceGUI/Quasar does not include Tailwind CSS,
so `h-96` had no effect and the container rendered at height 0. Leaflet initialized
on a zero-height element and produced a blank map.
- 🛠 **Route map not rendered when no node has GPS coordinates**`_render_map`
returned early before creating the Leaflet container when `payload['nodes']` was
empty. Fixed: container is always created; a notice label is shown instead.
### Changed
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Added size guard in `ensureMap`:
returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map
state exists yet. `processPending` retries on the next tick once the panel is visible.
- 🔄 `meshcore_gui/gui/dashboard.py` — Consolidated two conditional map-update blocks
into a single unconditional update while the MAP panel is active. Added `h-96` to the
DOMCA CSS height overrides for consistency with the route page map container.
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced `h-96` Tailwind class on the route
map host `<div>` with an explicit inline `style` (height: 24rem). Removed early
`return` guard so the Leaflet container is always created.
### Impact
- MAP panel now renders reliably on first open regardless of contact/GPS availability
- Route map now always shows with correct height even when route nodes have no GPS
- No breaking changes outside the three files listed above
---
## [1.13.0] - 2026-03-09 — Leaflet Map Runtime Stabilization

View File

@@ -45,6 +45,7 @@ from meshcore_gui.ble.worker import create_worker
from meshcore_gui.core.shared_data import SharedData
from meshcore_gui.gui.dashboard import DashboardPage
from meshcore_gui.gui.route_page import RoutePage
from meshcore_gui.gui.panels.bbs_panel import BbsSettingsPage
from meshcore_gui.gui.archive_page import ArchivePage
from meshcore_gui.services.pin_store import PinStore
from meshcore_gui.services.room_password_store import RoomPasswordStore
@@ -54,6 +55,8 @@ from meshcore_gui.services.room_password_store import RoomPasswordStore
_shared = None
_dashboard = None
_route_page = None
_bbs_settings_page = None
_bbs_config_store_main = None
_archive_page = None
_pin_store = None
_room_password_store = None
@@ -73,6 +76,13 @@ def _page_route(msg_key: str):
_route_page.render(msg_key)
@ui.page('/bbs-settings')
def _page_bbs_settings():
"""NiceGUI page handler — BBS settings."""
if _bbs_settings_page:
_bbs_settings_page.render()
@ui.page('/archive')
def _page_archive():
"""NiceGUI page handler — message archive."""
@@ -155,7 +165,7 @@ def main():
Parses CLI arguments, auto-detects the transport, initialises all
components and starts the NiceGUI server.
"""
global _shared, _dashboard, _route_page, _archive_page, _pin_store, _room_password_store
global _shared, _dashboard, _route_page, _bbs_settings_page, _archive_page, _pin_store, _room_password_store
args, flags = _parse_flags(sys.argv[1:])
@@ -259,6 +269,8 @@ def main():
_dashboard = DashboardPage(_shared, _pin_store, _room_password_store)
_route_page = RoutePage(_shared)
_archive_page = ArchivePage(_shared)
from meshcore_gui.services.bbs_config_store import BbsConfigStore as _BCS
_bbs_settings_page = BbsSettingsPage(_shared, _BCS())
# ── Start worker ──
worker = create_worker(

View File

@@ -330,13 +330,17 @@ class CommandHandler:
debug_print(f"set_device_name exception: {exc}")
async def _cmd_login_room(self, cmd: Dict) -> None:
"""Login to a Room Server.
"""Send a Room Server login request.
Follows the reference implementation (meshcore-cli):
1. ``send_login()`` → wait for ``MSG_SENT`` (companion radio sent LoRa packet)
2. ``wait_for_event(LOGIN_SUCCESS)`` → wait for room server confirmation
3. After LOGIN_SUCCESS, the room server starts pushing historical
messages over RF. ``auto_message_fetching`` handles those.
This command handler owns only the *send* side of the login flow:
it queues archived room history for immediate UI display, marks the
room state as ``pending`` and sends the login packet to the companion
radio.
The definitive ``LOGIN_SUCCESS`` handling is intentionally centralised
in :mod:`meshcore_gui.ble.worker`, which already subscribes to the
MeshCore event stream. Keeping the success path in one place avoids a
second competing wait/timeout path here in the command layer.
Expected command dict::
@@ -355,20 +359,28 @@ class CommandHandler:
self._shared.set_status("⚠️ Room login: no pubkey")
return
# Load archived room messages so the panel shows history
# while we wait for the LoRa login handshake.
# Show archived room messages immediately while the radio/login path
# continues asynchronously.
self._shared.load_room_history(pubkey)
# Mark pending in SharedData so the panel can update
self._shared.set_room_login_state(pubkey, 'pending', 'Sending login…')
try:
# Step 1: Send login request to companion radio
self._shared.set_status(
f"🔄 Sending login to {room_name}"
)
self._shared.set_status(f"🔄 Sending login to {room_name}")
r = await self._mc.commands.send_login(pubkey, password)
if r is None:
self._shared.set_room_login_state(
pubkey, 'fail', 'Login send returned no response',
)
self._shared.set_status(
f"⚠️ Room login failed: {room_name}"
)
debug_print(
f"login_room: send_login returned None for {room_name} "
f"({pubkey[:16]})"
)
return
if r.type == EventType.ERROR:
self._shared.set_room_login_state(
pubkey, 'fail', 'Login send failed',
@@ -382,70 +394,20 @@ class CommandHandler:
)
return
# Step 2: Wait for LOGIN_SUCCESS from room server via LoRa
# Use suggested_timeout from companion radio if available,
# otherwise default to 120 seconds (LoRa can be slow).
suggested = (r.payload or {}).get('suggested_timeout', 96000)
timeout_secs = max(suggested / 800, 30.0)
self._shared.set_status(
f"⏳ Waiting for room server response ({room_name})…"
)
debug_print(
f"login_room: MSG_SENT OK, waiting for LOGIN_SUCCESS "
f"(timeout={timeout_secs:.0f}s)"
f"login_room: login packet accepted for {room_name}; "
f"worker owns LOGIN_SUCCESS handling "
f"(suggested timeout {timeout_secs:.0f}s)"
)
login_event = await self._mc.wait_for_event(
EventType.LOGIN_SUCCESS, timeout=timeout_secs,
)
if login_event and login_event.type == EventType.LOGIN_SUCCESS:
is_admin = (login_event.payload or {}).get('is_admin', False)
self._shared.set_room_login_state(
pubkey, 'ok',
f"admin={is_admin}",
)
self._shared.set_status(
f"✅ Room login OK: {room_name}"
f"history arriving over RF…"
)
debug_print(
f"login_room: LOGIN_SUCCESS for {room_name} "
f"(admin={is_admin})"
)
# Defensive: trigger one get_msg() to check for any
# messages already waiting in the companion radio's
# offline queue. auto_message_fetching handles the
# rest via MESSAGES_WAITING events.
try:
await self._mc.commands.get_msg()
debug_print("login_room: defensive get_msg() done")
except Exception as exc:
debug_print(f"login_room: defensive get_msg() error: {exc}")
else:
self._shared.set_room_login_state(
pubkey, 'fail',
'Timeout — no response from room server',
)
self._shared.set_status(
f"⚠️ Room login timeout: {room_name} "
f"(no response after {timeout_secs:.0f}s)"
)
debug_print(
f"login_room: LOGIN_SUCCESS timeout for "
f"{room_name} ({pubkey[:16]})"
)
except Exception as exc:
self._shared.set_room_login_state(
pubkey, 'fail', str(exc),
)
self._shared.set_status(
f"⚠️ Room login error: {exc}"
)
self._shared.set_room_login_state(pubkey, 'fail', str(exc))
self._shared.set_status(f"⚠️ Room login error: {exc}")
debug_print(f"login_room exception: {exc}")
async def _cmd_logout_room(self, cmd: Dict) -> None:

View File

@@ -4,9 +4,16 @@ Device event callbacks for MeshCore GUI.
Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA``
events from the MeshCore library. Extracted from ``SerialWorker`` so the
worker only deals with connection lifecycle.
BBS routing
~~~~~~~~~~~
Direct Messages (``CONTACT_MSG_RECV``) whose text starts with ``!`` are
forwarded to :class:`~meshcore_gui.services.bbs_service.BbsCommandHandler`
**before** any other DM processing. This path is completely independent of
:class:`~meshcore_gui.services.bot.MeshBot`.
"""
from typing import Dict, Optional
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RxLogEntry
@@ -15,6 +22,9 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType
from meshcore_gui.services.bot import MeshBot
from meshcore_gui.services.dedup import DualDeduplicator
if TYPE_CHECKING:
from meshcore_gui.services.bbs_service import BbsCommandHandler
class EventHandler:
"""Processes device events and writes results to shared data.
@@ -35,11 +45,15 @@ class EventHandler:
decoder: PacketDecoder,
dedup: DualDeduplicator,
bot: MeshBot,
bbs_handler: Optional["BbsCommandHandler"] = None,
command_sink: Optional[Callable[[Dict], None]] = None,
) -> None:
self._shared = shared
self._decoder = decoder
self._dedup = dedup
self._bot = bot
self._bbs_handler = bbs_handler
self._command_sink = command_sink
# Cache: message_hash → path_hashes (from RX_LOG decode).
# Used by on_channel_msg fallback to recover hashes that the
@@ -77,6 +91,16 @@ class EventHandler:
names.append(h.upper())
return names
@staticmethod
def _looks_like_hex_identifier(value: str) -> bool:
"""Return True when *value* looks like a pubkey/hash prefix."""
if not value:
return False
probe = str(value).strip()
if len(probe) < 6:
return False
return all(ch in '0123456789abcdefABCDEF' for ch in probe)
# ------------------------------------------------------------------
# RX_LOG_DATA — the single source of truth for path info
# ------------------------------------------------------------------
@@ -277,6 +301,23 @@ class EventHandler:
message_hash=msg_hash,
))
# BBS channel hook: auto-whitelist sender + bootstrap reply for !-commands.
# Runs on every message on a configured BBS channel, independent of the bot.
if self._bbs_handler is not None and self._command_sink is not None:
bbs_reply = self._bbs_handler.handle_channel_msg(
channel_idx=ch_idx,
sender=sender,
sender_key=sender_pubkey,
text=msg_text,
)
if bbs_reply is not None:
debug_print(f"BBS channel reply on ch{ch_idx} to {sender!r}: {bbs_reply[:60]}")
self._command_sink({
"action": "send_message",
"channel": ch_idx,
"text": bbs_reply,
})
self._bot.check_and_reply(
sender=sender,
text=msg_text,
@@ -292,38 +333,83 @@ class EventHandler:
def on_contact_msg(self, event) -> None:
"""Handle direct message and room message events.
Room Server messages arrive as ``CONTACT_MSG_RECV`` with
``txt_type == 2``. The ``pubkey_prefix`` is the Room Server's
key and the ``signature`` field contains the original author's
pubkey prefix. We resolve the author name from ``signature``
so the UI shows who actually wrote the message.
Room Server traffic also arrives as ``CONTACT_MSG_RECV``.
In practice the payload is not stable enough to rely only on
``signature`` + ``pubkey_prefix``. Incoming room messages from
*other* participants may omit ``signature`` and may carry the
room key in receiver-style fields instead of ``pubkey_prefix``.
To keep the rest of the GUI unchanged, room messages are stored
with ``sender`` = actual author name and ``sender_pubkey`` = room
public key. The Room Server panel already filters on
``sender_pubkey`` to decide to which room a message belongs.
"""
payload = event.payload
payload = event.payload or {}
pubkey = payload.get('pubkey_prefix', '')
txt_type = payload.get('txt_type', 0)
signature = payload.get('signature', '')
debug_print(f"DM payload keys: {list(payload.keys())}")
debug_print(
"DM payload keys: "
f"{list(payload.keys())}; txt_type={txt_type}; "
f"pubkey_prefix={pubkey[:12]}; "
f"receiver={(payload.get('receiver') or '')[:12]}; "
f"room_pubkey={(payload.get('room_pubkey') or '')[:12]}; "
f"signature={(signature or '')[:12]}"
)
# Common fields for both Room and DM messages
msg_hash = payload.get('message_hash', '')
path_hashes = self._path_cache.pop(msg_hash, []) if msg_hash else []
path_names = self._resolve_path_names(path_hashes)
# DM payloads may report path_len=255 (0xFF) meaning "unknown";
# treat as 0 when no actual path data is available.
raw_path_len = payload.get('path_len', 0)
path_len = raw_path_len if raw_path_len < 255 else 0
if path_hashes:
# Trust actual decoded hashes over the raw header value
path_len = len(path_hashes)
# --- Room Server message (txt_type 2) ---
if txt_type == 2 and signature:
# Resolve actual author from signature (author pubkey prefix)
author = self._shared.get_contact_name_by_prefix(signature)
room_pubkey = (
payload.get('room_pubkey')
or payload.get('receiver')
or payload.get('receiver_pubkey')
or payload.get('receiver_pubkey_prefix')
or pubkey
or ''
)
is_room_message = txt_type == 2
if is_room_message:
author = ''
explicit_name = (
payload.get('author')
or payload.get('sender_name')
or payload.get('name')
or ''
)
if explicit_name and not self._looks_like_hex_identifier(explicit_name):
author = explicit_name
sender_field = str(payload.get('sender') or '').strip()
if not author and sender_field and not self._looks_like_hex_identifier(sender_field):
author = sender_field
author_key = (
signature
or payload.get('sender_pubkey')
or payload.get('author_pubkey')
or (sender_field if self._looks_like_hex_identifier(sender_field) else '')
or ''
)
if not author and author_key:
author = self._shared.get_contact_name_by_prefix(author_key)
if not author:
author = signature[:8] if signature else '?'
author = (
explicit_name
or sender_field
or (author_key[:8] if author_key else '')
or '?'
)
self._shared.add_message(Message.incoming(
author,
@@ -331,14 +417,14 @@ class EventHandler:
None,
snr=self._extract_snr(payload),
path_len=path_len,
sender_pubkey=pubkey,
sender_pubkey=room_pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
debug_print(
f"Room msg from {author} (sig={signature}) "
f"via room {pubkey[:12]}: "
f"Room msg from {author} via room {room_pubkey[:12]} "
f"(sig={signature[:12] if signature else '-'}): "
f"{payload.get('text', '')[:30]}"
)
return
@@ -348,11 +434,51 @@ class EventHandler:
if pubkey:
sender = self._shared.get_contact_name_by_prefix(pubkey)
if not sender:
sender = pubkey[:8] if pubkey else ''
sender = (
payload.get('name')
or payload.get('sender')
or (pubkey[:8] if pubkey else '')
)
dm_text = payload.get('text', '')
# BBS routing: DMs starting with '!' go directly to BbsCommandHandler.
# This path is independent of the bot (MeshBot is for channel messages only).
if (
self._bbs_handler is not None
and self._command_sink is not None
and dm_text.strip().startswith("!")
):
bbs_reply = self._bbs_handler.handle_dm(
sender=sender,
sender_key=pubkey,
text=dm_text,
)
if bbs_reply is not None:
debug_print(f"BBS DM reply to {sender} ({pubkey[:8]}): {bbs_reply[:60]}")
self._command_sink({
"action": "send_dm",
"pubkey": pubkey,
"text": bbs_reply,
})
# Always store the incoming DM in the message archive too
self._shared.add_message(Message.incoming(
sender,
dm_text,
None,
snr=self._extract_snr(payload),
path_len=path_len,
sender_pubkey=pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
debug_print(f"BBS DM stored from {sender}: {dm_text[:30]}")
return
self._shared.add_message(Message.incoming(
sender,
payload.get('text', ''),
dm_text,
None,
snr=self._extract_snr(payload),
path_len=path_len,
@@ -361,7 +487,7 @@ class EventHandler:
path_names=path_names,
message_hash=msg_hash,
))
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
debug_print(f"DM received from {sender}: {dm_text[:30]}")
# ------------------------------------------------------------------
# Helpers

View File

@@ -0,0 +1,379 @@
"""
Device event callbacks for MeshCore GUI.
Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA``
events from the MeshCore library. Extracted from ``SerialWorker`` so the
worker only deals with connection lifecycle.
"""
from typing import Dict, Optional
from meshcore_gui.config import debug_print
from meshcore_gui.core.models import Message, RxLogEntry
from meshcore_gui.core.protocols import SharedDataWriter
from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType
from meshcore_gui.services.bot import MeshBot
from meshcore_gui.services.dedup import DualDeduplicator
class EventHandler:
"""Processes device events and writes results to shared data.
Args:
shared: SharedDataWriter for storing messages and RX log.
decoder: PacketDecoder for raw LoRa packet decryption.
dedup: DualDeduplicator for message deduplication.
bot: MeshBot for auto-reply logic.
"""
# Maximum entries in the path cache before oldest are evicted.
_PATH_CACHE_MAX = 200
def __init__(
self,
shared: SharedDataWriter,
decoder: PacketDecoder,
dedup: DualDeduplicator,
bot: MeshBot,
) -> None:
self._shared = shared
self._decoder = decoder
self._dedup = dedup
self._bot = bot
# Cache: message_hash → path_hashes (from RX_LOG decode).
# Used by on_channel_msg fallback to recover hashes that the
# CHANNEL_MSG_RECV event does not provide.
self._path_cache: Dict[str, list] = {}
# ------------------------------------------------------------------
# Helpers — resolve names at receive time
# ------------------------------------------------------------------
def _resolve_path_names(self, path_hashes: list) -> list:
"""Resolve 2-char path hashes to display names.
Performs a contact lookup for each hash *now* so the names are
captured at receive time and stored in the archive.
Args:
path_hashes: List of 2-char hex strings.
Returns:
List of display names (same length as *path_hashes*).
Unknown hashes become their uppercase hex value.
"""
names = []
for h in path_hashes:
if not h or len(h) < 2:
names.append('-')
continue
name = self._shared.get_contact_name_by_prefix(h)
# get_contact_name_by_prefix returns h[:8] as fallback,
# normalise to uppercase hex for 2-char hashes.
if name and name != h[:8]:
names.append(name)
else:
names.append(h.upper())
return names
# ------------------------------------------------------------------
# RX_LOG_DATA — the single source of truth for path info
# ------------------------------------------------------------------
def on_rx_log(self, event) -> None:
"""Handle RX log data events."""
payload = event.payload
# Extract basic RX log info
time_str = Message.now_timestamp()
snr = payload.get('snr', 0)
rssi = payload.get('rssi', 0)
payload_type = '?'
hops = payload.get('path_len', 0)
# Try to decode payload to get message_hash
message_hash = ""
rx_path_hashes: list = []
rx_path_names: list = []
rx_sender: str = ""
rx_receiver: str = self._shared.get_device_name() or ""
payload_hex = payload.get('payload', '')
decoded = None
if payload_hex:
decoded = self._decoder.decode(payload_hex)
if decoded is not None:
message_hash = decoded.message_hash
payload_type = self._decoder.get_payload_type_text(decoded.payload_type)
# Capture path info for all packet types
if decoded.path_hashes:
rx_path_hashes = decoded.path_hashes
rx_path_names = self._resolve_path_names(decoded.path_hashes)
# Use decoded path_length (from packet body) — more
# reliable than the frame-header path_len which can be 0.
if decoded.path_length:
hops = decoded.path_length
# Capture sender name when available (GroupText only)
if decoded.sender:
rx_sender = decoded.sender
# Cache path_hashes for correlation with on_channel_msg
if decoded.path_hashes and message_hash:
self._path_cache[message_hash] = decoded.path_hashes
# Evict oldest entries if cache is too large
if len(self._path_cache) > self._PATH_CACHE_MAX:
oldest = next(iter(self._path_cache))
del self._path_cache[oldest]
# Process decoded message if it's a group text
if decoded.payload_type == PayloadType.GroupText and decoded.is_decrypted:
if decoded.channel_idx is None:
# The channel hash could not be resolved to a channel index
# (PacketDecoder._hash_to_idx lookup returned None).
# Marking dedup here would suppress on_channel_msg, which
# carries a valid channel_idx from the device event — the only
# path through which the bot can pass Guard 2 and respond.
# Skip the entire block; on_channel_msg handles message + bot.
# Path info is already in _path_cache for on_channel_msg to use.
debug_print(
f"RX_LOG → GroupText decrypted but channel_idx unresolved "
f"(hash={decoded.message_hash}); deferring to on_channel_msg"
)
else:
self._dedup.mark_hash(decoded.message_hash)
self._dedup.mark_content(
decoded.sender, decoded.channel_idx, decoded.text,
)
sender_pubkey = ''
if decoded.sender:
match = self._shared.get_contact_by_name(decoded.sender)
if match:
sender_pubkey, _contact = match
snr_msg = self._extract_snr(payload)
self._shared.add_message(Message.incoming(
decoded.sender,
decoded.text,
decoded.channel_idx,
time=time_str,
snr=snr_msg,
path_len=decoded.path_length,
sender_pubkey=sender_pubkey,
path_hashes=decoded.path_hashes,
path_names=rx_path_names,
message_hash=decoded.message_hash,
))
debug_print(
f"RX_LOG → message: hash={decoded.message_hash}, "
f"sender={decoded.sender!r}, ch={decoded.channel_idx}, "
f"path={decoded.path_hashes}, "
f"path_names={rx_path_names}"
)
self._bot.check_and_reply(
sender=decoded.sender,
text=decoded.text,
channel_idx=decoded.channel_idx,
snr=snr_msg,
path_len=decoded.path_length,
path_hashes=decoded.path_hashes,
)
# Add RX log entry with message_hash and path info (if available)
# ── Fase 1 Observer: raw packet metadata ──
raw_packet_len = len(payload_hex) // 2 if payload_hex else 0
raw_payload_len = max(0, raw_packet_len - 1 - hops) if payload_hex else 0
raw_route_type = "D" if hops > 0 else ("F" if payload_hex else "")
raw_packet_type_num = -1
if payload_hex and decoded is not None:
try:
raw_packet_type_num = decoded.payload_type.value
except (AttributeError, ValueError):
pass
self._shared.add_rx_log(RxLogEntry(
time=time_str,
snr=snr,
rssi=rssi,
payload_type=payload_type,
hops=hops,
message_hash=message_hash,
path_hashes=rx_path_hashes,
path_names=rx_path_names,
sender=rx_sender,
receiver=rx_receiver,
raw_payload=payload_hex,
packet_len=raw_packet_len,
payload_len=raw_payload_len,
route_type=raw_route_type,
packet_type_num=raw_packet_type_num,
))
# ------------------------------------------------------------------
# CHANNEL_MSG_RECV — fallback when RX_LOG decode missed it
# ------------------------------------------------------------------
def on_channel_msg(self, event) -> None:
"""Handle channel message events."""
payload = event.payload
debug_print(f"Channel msg payload keys: {list(payload.keys())}")
# Dedup via hash
msg_hash = payload.get('message_hash', '')
if msg_hash and self._dedup.is_hash_seen(msg_hash):
debug_print(f"Channel msg suppressed (hash): {msg_hash}")
return
# Parse sender from "SenderName: message body" format
raw_text = payload.get('text', '')
sender, msg_text = '', raw_text
if ': ' in raw_text:
name_part, body_part = raw_text.split(': ', 1)
sender = name_part.strip()
msg_text = body_part
elif raw_text:
msg_text = raw_text
# Dedup via content
ch_idx = payload.get('channel_idx')
if self._dedup.is_content_seen(sender, ch_idx, msg_text):
debug_print(f"Channel msg suppressed (content): {sender!r}")
return
debug_print(
f"Channel msg (fallback): sender={sender!r}, "
f"text={msg_text[:40]!r}"
)
sender_pubkey = ''
if sender:
match = self._shared.get_contact_by_name(sender)
if match:
sender_pubkey, _contact = match
snr = self._extract_snr(payload)
# Recover path_hashes from RX_LOG cache (CHANNEL_MSG_RECV
# does not carry them, but the preceding RX_LOG decode does).
path_hashes = self._path_cache.pop(msg_hash, []) if msg_hash else []
path_names = self._resolve_path_names(path_hashes)
self._shared.add_message(Message.incoming(
sender,
msg_text,
ch_idx,
snr=snr,
path_len=payload.get('path_len', 0),
sender_pubkey=sender_pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
self._bot.check_and_reply(
sender=sender,
text=msg_text,
channel_idx=ch_idx,
snr=snr,
path_len=payload.get('path_len', 0),
)
# ------------------------------------------------------------------
# CONTACT_MSG_RECV — DMs
# ------------------------------------------------------------------
def on_contact_msg(self, event) -> None:
"""Handle direct message and room message events.
Room Server messages arrive as ``CONTACT_MSG_RECV`` with
``txt_type == 2``. The ``pubkey_prefix`` is the Room Server's
key and the ``signature`` field contains the original author's
pubkey prefix. We resolve the author name from ``signature``
so the UI shows who actually wrote the message.
"""
payload = event.payload
pubkey = payload.get('pubkey_prefix', '')
txt_type = payload.get('txt_type', 0)
signature = payload.get('signature', '')
debug_print(f"DM payload keys: {list(payload.keys())}")
# Common fields for both Room and DM messages
msg_hash = payload.get('message_hash', '')
path_hashes = self._path_cache.pop(msg_hash, []) if msg_hash else []
path_names = self._resolve_path_names(path_hashes)
# DM payloads may report path_len=255 (0xFF) meaning "unknown";
# treat as 0 when no actual path data is available.
raw_path_len = payload.get('path_len', 0)
path_len = raw_path_len if raw_path_len < 255 else 0
if path_hashes:
# Trust actual decoded hashes over the raw header value
path_len = len(path_hashes)
# --- Room Server message (txt_type 2) ---
if txt_type == 2 and signature:
# Resolve actual author from signature (author pubkey prefix)
author = self._shared.get_contact_name_by_prefix(signature)
if not author:
author = signature[:8] if signature else '?'
self._shared.add_message(Message.incoming(
author,
payload.get('text', ''),
None,
snr=self._extract_snr(payload),
path_len=path_len,
sender_pubkey=pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
debug_print(
f"Room msg from {author} (sig={signature}) "
f"via room {pubkey[:12]}: "
f"{payload.get('text', '')[:30]}"
)
return
# --- Regular DM ---
sender = ''
if pubkey:
sender = self._shared.get_contact_name_by_prefix(pubkey)
if not sender:
sender = pubkey[:8] if pubkey else ''
self._shared.add_message(Message.incoming(
sender,
payload.get('text', ''),
None,
snr=self._extract_snr(payload),
path_len=path_len,
sender_pubkey=pubkey,
path_hashes=path_hashes,
path_names=path_names,
message_hash=msg_hash,
))
debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_snr(payload: Dict) -> Optional[float]:
"""Extract SNR from a payload dict (handles 'SNR' and 'snr' keys)."""
raw = payload.get('SNR') or payload.get('snr')
if raw is not None:
try:
return float(raw)
except (ValueError, TypeError):
pass
return None

View File

@@ -54,6 +54,8 @@ from meshcore_gui.ble.commands import CommandHandler
from meshcore_gui.ble.events import EventHandler
from meshcore_gui.ble.packet_decoder import PacketDecoder
from meshcore_gui.services.bot import BotConfig, MeshBot
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.bbs_config_store import BbsConfigStore
from meshcore_gui.services.cache import DeviceCache
from meshcore_gui.services.dedup import DualDeduplicator
from meshcore_gui.services.device_identity import write_device_identity
@@ -124,6 +126,12 @@ class _BaseWorker(abc.ABC):
enabled_check=shared.is_bot_enabled,
)
# BBS handler — wired directly into EventHandler for DM routing.
# Independent of the bot; uses a shared config store and service.
_bbs_config = BbsConfigStore()
_bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(service=_bbs_service, config_store=_bbs_config)
# Channel indices that still need keys from device
self._pending_keys: Set[int] = set()
@@ -244,6 +252,8 @@ class _BaseWorker(abc.ABC):
decoder=self._decoder,
dedup=self._dedup,
bot=self._bot,
bbs_handler=self._bbs_handler,
command_sink=self.shared.put_command,
)
self._cmd_handler = CommandHandler(
mc=self.mc, shared=self.shared, cache=self._cache,
@@ -258,11 +268,38 @@ class _BaseWorker(abc.ABC):
# ── LOGIN_SUCCESS handler (Room Server) ───────────────────────
def _on_login_success(self, event) -> None:
"""Handle Room Server login confirmation.
This worker callback is the *only* definitive success path for room
login. The command layer sends the login request and leaves the final
transition to ``ok`` to this subscriber so there is no competing
timeout/success logic elsewhere.
The device event may expose the room key under different fields.
Update both the generic status line and the per-room login state,
then refresh archived room history for the matched room.
"""
payload = event.payload or {}
pubkey = payload.get("pubkey_prefix", "")
pubkey = (
payload.get("room_pubkey")
or payload.get("receiver")
or payload.get("receiver_pubkey")
or payload.get("pubkey_prefix")
or ""
)
is_admin = payload.get("is_admin", False)
debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}")
debug_print(
f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}, "
f"keys={list(payload.keys())}"
)
self.shared.set_status("✅ Room login OK — messages arriving over RF…")
if pubkey:
self.shared.set_room_login_state(
pubkey, 'ok', f'Server confirmed login (admin={is_admin})',
)
self.shared.load_room_history(pubkey)
else:
debug_print('LOGIN_SUCCESS received without identifiable room pubkey')
# ── apply cache ───────────────────────────────────────────────

View File

@@ -0,0 +1,964 @@
"""
Communication worker for MeshCore GUI (Serial + BLE).
Runs in a separate thread with its own asyncio event loop. Connects
to the MeshCore device, wires up collaborators, and runs the command
processing loop.
Transport selection
~~~~~~~~~~~~~~~~~~~~
The :func:`create_worker` factory returns the appropriate worker class
based on the device identifier:
- ``/dev/ttyACM0`` → :class:`SerialWorker` (USB serial)
- ``literal:AA:BB:CC:DD:EE:FF`` → :class:`BLEWorker` (Bluetooth LE)
Both workers share the same base class (:class:`_BaseWorker`) which
implements the main loop, event wiring, data loading and caching.
Command execution → :mod:`meshcore_gui.ble.commands`
Event handling → :mod:`meshcore_gui.ble.events`
Packet decoding → :mod:`meshcore_gui.ble.packet_decoder`
PIN agent (BLE) → :mod:`meshcore_gui.ble.ble_agent`
Reconnect (BLE) → :mod:`meshcore_gui.ble.ble_reconnect`
Bot logic → :mod:`meshcore_gui.services.bot`
Deduplication → :mod:`meshcore_gui.services.dedup`
Cache → :mod:`meshcore_gui.services.cache`
Author: PE1HVH
SPDX-License-Identifier: MIT
"""
import abc
import asyncio
import threading
import time
from typing import Dict, List, Optional, Set
from meshcore import MeshCore, EventType
import meshcore_gui.config as _config
from meshcore_gui.config import (
DEFAULT_TIMEOUT,
CHANNEL_CACHE_ENABLED,
CONTACT_REFRESH_SECONDS,
MAX_CHANNELS,
RECONNECT_BASE_DELAY,
RECONNECT_MAX_RETRIES,
debug_data,
debug_print,
pp,
)
from meshcore_gui.core.protocols import SharedDataWriter
from meshcore_gui.ble.commands import CommandHandler
from meshcore_gui.ble.events import EventHandler
from meshcore_gui.ble.packet_decoder import PacketDecoder
from meshcore_gui.services.bot import BotConfig, MeshBot
from meshcore_gui.services.cache import DeviceCache
from meshcore_gui.services.dedup import DualDeduplicator
from meshcore_gui.services.device_identity import write_device_identity
# Seconds between background retry attempts for missing channel keys.
KEY_RETRY_INTERVAL: float = 30.0
# Seconds between periodic cleanup of old archived data (24 hours).
CLEANUP_INTERVAL: float = 86400.0
# ======================================================================
# Factory
# ======================================================================
def create_worker(device_id: str, shared: SharedDataWriter, **kwargs):
"""Return the appropriate worker for *device_id*.
Keyword arguments are forwarded to the worker constructor
(e.g. ``baudrate``, ``cx_dly`` for serial).
"""
from meshcore_gui.config import is_ble_address
if is_ble_address(device_id):
return BLEWorker(device_id, shared)
return SerialWorker(
device_id,
shared,
baudrate=kwargs.get("baudrate", _config.SERIAL_BAUDRATE),
cx_dly=kwargs.get("cx_dly", _config.SERIAL_CX_DELAY),
)
# ======================================================================
# Base worker (shared by BLE and Serial)
# ======================================================================
class _BaseWorker(abc.ABC):
"""Abstract base for transport-specific workers.
Subclasses must implement:
- :pyattr:`_log_prefix` — ``"BLE"`` or ``"SERIAL"``
- :meth:`_async_main` — transport-specific startup + main loop
- :meth:`_connect` — create the :class:`MeshCore` connection
- :meth:`_reconnect` — re-establish after a disconnect
- :pyattr:`_disconnect_keywords` — error substrings that signal
a broken connection
"""
def __init__(self, device_id: str, shared: SharedDataWriter) -> None:
self.device_id = device_id
self.shared = shared
self.mc: Optional[MeshCore] = None
self.running = True
self._disconnected = False
# Local cache (one file per device)
self._cache = DeviceCache(device_id)
# Collaborators (created eagerly, wired after connection)
self._decoder = PacketDecoder()
self._dedup = DualDeduplicator(max_size=200)
self._bot = MeshBot(
config=BotConfig(),
command_sink=shared.put_command,
enabled_check=shared.is_bot_enabled,
)
# Channel indices that still need keys from device
self._pending_keys: Set[int] = set()
# Dynamically discovered channels from device
self._channels: List[Dict] = []
# ── abstract properties / methods ─────────────────────────────
@property
@abc.abstractmethod
def _log_prefix(self) -> str:
"""Short label for log messages, e.g. ``"BLE"`` or ``"SERIAL"``."""
@property
@abc.abstractmethod
def _disconnect_keywords(self) -> tuple:
"""Lowercase substrings that indicate a transport disconnect."""
@abc.abstractmethod
async def _async_main(self) -> None:
"""Transport-specific startup + main loop."""
@abc.abstractmethod
async def _connect(self) -> None:
"""Create a fresh connection and wire collaborators."""
@abc.abstractmethod
async def _reconnect(self) -> Optional[MeshCore]:
"""Attempt to re-establish the connection after a disconnect."""
# ── 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(f"{self._log_prefix} worker thread started")
def _run(self) -> None:
asyncio.run(self._async_main())
# ── shared main loop (called from subclass _async_main) ───────
async def _main_loop(self) -> None:
"""Command processing + periodic tasks.
Runs until ``self.running`` is cleared or a disconnect is
detected. Subclasses call this from their ``_async_main``.
"""
last_contact_refresh = time.time()
last_key_retry = time.time()
last_cleanup = time.time()
while self.running and not self._disconnected:
try:
await self._cmd_handler.process_all()
except Exception as e:
error_str = str(e).lower()
if any(kw in error_str for kw in self._disconnect_keywords):
print(f"{self._log_prefix}: ⚠️ Connection error detected: {e}")
self._disconnected = True
break
debug_print(f"Command processing error: {e}")
now = time.time()
if now - last_contact_refresh > CONTACT_REFRESH_SECONDS:
await self._refresh_contacts()
last_contact_refresh = now
if self._pending_keys and now - last_key_retry > KEY_RETRY_INTERVAL:
await self._retry_missing_keys()
last_key_retry = now
if now - last_cleanup > CLEANUP_INTERVAL:
await self._cleanup_old_data()
last_cleanup = now
await asyncio.sleep(0.1)
async def _handle_reconnect(self) -> bool:
"""Shared reconnect logic after a disconnect.
Returns True if reconnection succeeded, False otherwise.
"""
self.shared.set_connected(False)
self.shared.set_status("🔄 Verbinding verloren — herverbinden...")
print(f"{self._log_prefix}: Verbinding verloren, start reconnect...")
self.mc = None
new_mc = await self._reconnect()
if new_mc:
self.mc = new_mc
await asyncio.sleep(1)
self._wire_collaborators()
await self._load_data()
await self.mc.start_auto_message_fetching()
self._seed_dedup_from_messages()
self.shared.set_connected(True)
self.shared.set_status("✅ Herverbonden")
print(f"{self._log_prefix}: ✅ Herverbonden en operationeel")
return True
self.shared.set_status("❌ Herverbinding mislukt — herstart nodig")
print(
f"{self._log_prefix}: ❌ Kan niet herverbinden — "
"wacht 60s en probeer opnieuw..."
)
return False
# ── collaborator wiring ───────────────────────────────────────
def _wire_collaborators(self) -> None:
"""(Re-)create handlers and subscribe to MeshCore events."""
self._evt_handler = EventHandler(
shared=self.shared,
decoder=self._decoder,
dedup=self._dedup,
bot=self._bot,
)
self._cmd_handler = CommandHandler(
mc=self.mc, shared=self.shared, cache=self._cache,
)
self._cmd_handler.set_load_data_callback(self._load_data)
self.mc.subscribe(EventType.CHANNEL_MSG_RECV, self._evt_handler.on_channel_msg)
self.mc.subscribe(EventType.CONTACT_MSG_RECV, self._evt_handler.on_contact_msg)
self.mc.subscribe(EventType.RX_LOG_DATA, self._evt_handler.on_rx_log)
self.mc.subscribe(EventType.LOGIN_SUCCESS, self._on_login_success)
# ── LOGIN_SUCCESS handler (Room Server) ───────────────────────
def _on_login_success(self, event) -> None:
payload = event.payload or {}
pubkey = payload.get("pubkey_prefix", "")
is_admin = payload.get("is_admin", False)
debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}")
self.shared.set_status("✅ Room login OK — messages arriving over RF…")
# ── apply cache ───────────────────────────────────────────────
def _apply_cache(self) -> None:
"""Push cached data to SharedData so GUI renders immediately."""
device = self._cache.get_device()
if device:
self.shared.update_from_appstart(device)
fw = device.get("firmware_version") or device.get("ver")
if fw:
self.shared.update_from_device_query({"ver": fw})
self.shared.set_status("📦 Loaded from cache")
debug_print(f"Cache → device info: {device.get('name', '?')}")
if CHANNEL_CACHE_ENABLED:
channels = self._cache.get_channels()
if channels:
self._channels = channels
self.shared.set_channels(channels)
debug_print(f"Cache → channels: {[c['name'] for c in channels]}")
else:
debug_print("Channel cache disabled — skipping cached channels")
contacts = self._cache.get_contacts()
if contacts:
self.shared.set_contacts(contacts)
debug_print(f"Cache → contacts: {len(contacts)}")
cached_keys = self._cache.get_channel_keys()
for idx_str, secret_hex in cached_keys.items():
try:
idx = int(idx_str)
secret_bytes = bytes.fromhex(secret_hex)
if len(secret_bytes) >= 16:
self._decoder.add_channel_key(idx, secret_bytes[:16], source="cache")
debug_print(f"Cache → channel key [{idx}]")
except (ValueError, TypeError) as exc:
debug_print(f"Cache → bad channel key [{idx_str}]: {exc}")
cached_orig_name = self._cache.get_original_device_name()
if cached_orig_name:
self.shared.set_original_device_name(cached_orig_name)
debug_print(f"Cache → original device name: {cached_orig_name}")
count = self.shared.load_recent_from_archive(limit=100)
if count:
debug_print(f"Cache → {count} recent messages from archive")
self._seed_dedup_from_messages()
# ── initial data loading ──────────────────────────────────────
async def _export_device_identity(self) -> None:
"""Export device keys and write identity file for Observer.
Calls ``export_private_key()`` on the device and writes the
result to ``~/.meshcore-gui/device_identity.json`` so the
MeshCore Observer can authenticate to the MQTT broker without
manual key configuration.
"""
pfx = self._log_prefix
try:
r = await self.mc.commands.export_private_key()
if r is None:
debug_print(f"{pfx}: export_private_key returned None")
return
if r.type == EventType.PRIVATE_KEY:
prv_bytes = r.payload.get("private_key", b"")
if len(prv_bytes) == 64:
# Gather device info for the identity file
pub_key = ""
dev_name = ""
fw_ver = ""
with self.shared.lock:
pub_key = self.shared.device.public_key
dev_name = self.shared.device.name
fw_ver = self.shared.device.firmware_version
write_device_identity(
public_key=pub_key,
private_key_bytes=prv_bytes,
device_name=dev_name,
firmware_version=fw_ver,
source_device=self.device_id,
)
else:
debug_print(
f"{pfx}: export_private_key: unexpected "
f"length {len(prv_bytes)} bytes"
)
elif r.type == EventType.DISABLED:
print(
f"{pfx}: Private key export is disabled on device "
f"— manual key setup required for Observer MQTT"
)
else:
debug_print(
f"{pfx}: export_private_key: unexpected "
f"response type {r.type}"
)
except Exception as exc:
debug_print(f"{pfx}: export_private_key failed: {exc}")
async def _load_data(self) -> None:
"""Load device info, channels and contacts from device."""
pfx = self._log_prefix
# send_appstart — reuse result from MeshCore.connect()
self.shared.set_status("🔄 Device info...")
cached_info = self.mc.self_info
if cached_info and cached_info.get("name"):
print(f"{pfx}: send_appstart OK (from connect): {cached_info.get('name')}")
self.shared.update_from_appstart(cached_info)
self._cache.set_device(cached_info)
else:
debug_print("self_info empty after connect(), falling back to manual send_appstart")
appstart_ok = False
for i in range(3):
debug_print(f"send_appstart fallback attempt {i + 1}/3")
try:
r = await self.mc.commands.send_appstart()
if r is None:
debug_print(f"send_appstart fallback {i + 1}: received None, retrying")
await asyncio.sleep(2.0)
continue
if r.type != EventType.ERROR:
print(f"{pfx}: send_appstart OK: {r.payload.get('name')} (fallback attempt {i + 1})")
self.shared.update_from_appstart(r.payload)
self._cache.set_device(r.payload)
appstart_ok = True
break
else:
debug_print(f"send_appstart fallback {i + 1}: ERROR — payload={pp(r.payload)}")
except Exception as exc:
debug_print(f"send_appstart fallback {i + 1} exception: {exc}")
await asyncio.sleep(2.0)
if not appstart_ok:
print(f"{pfx}: ⚠️ send_appstart failed after 3 fallback attempts")
# send_device_query
for i in range(5):
debug_print(f"send_device_query attempt {i + 1}/5")
try:
r = await self.mc.commands.send_device_query()
if r is None:
debug_print(f"send_device_query attempt {i + 1}: received None response, retrying")
await asyncio.sleep(2.0)
continue
if r.type != EventType.ERROR:
fw = r.payload.get("ver", "")
print(f"{pfx}: send_device_query OK: {fw} (attempt {i + 1})")
self.shared.update_from_device_query(r.payload)
if fw:
self._cache.set_firmware_version(fw)
break
else:
debug_print(f"send_device_query attempt {i + 1}: ERROR response — payload={pp(r.payload)}")
except Exception as exc:
debug_print(f"send_device_query attempt {i + 1} exception: {exc}")
await asyncio.sleep(2.0)
# Export device identity for MeshCore Observer
await self._export_device_identity()
# Channels
await self._discover_channels()
# Contacts
self.shared.set_status("🔄 Contacts...")
debug_print("get_contacts starting")
try:
r = await self._get_contacts_with_timeout()
debug_print(f"get_contacts result: type={r.type if r else None}")
if r and r.payload:
try:
payload_len = len(r.payload)
except Exception:
payload_len = None
if payload_len is not None and payload_len > 10:
debug_print(f"get_contacts payload size={payload_len} (omitted)")
else:
debug_data("get_contacts payload", r.payload)
if r is None:
debug_print(f"{pfx}: get_contacts returned None, keeping cached contacts")
elif r.type != EventType.ERROR:
merged = self._cache.merge_contacts(r.payload)
self.shared.set_contacts(merged)
print(f"{pfx}: Contacts — {len(r.payload)} from device, {len(merged)} total (with cache)")
else:
debug_print(f"{pfx}: get_contacts failed — payload={pp(r.payload)}, keeping cached contacts")
except Exception as exc:
debug_print(f"{pfx}: get_contacts exception: {exc}")
async def _get_contacts_with_timeout(self):
"""Fetch contacts with a bounded timeout to avoid hanging refresh."""
timeout = max(DEFAULT_TIMEOUT * 2, 10.0)
try:
return await asyncio.wait_for(
self.mc.commands.get_contacts(), timeout=timeout,
)
except asyncio.TimeoutError:
self.shared.set_status("⚠️ Contacts timeout — using cached contacts")
debug_print(f"get_contacts timeout after {timeout:.0f}s")
return None
# ── channel discovery ─────────────────────────────────────────
async def _discover_channels(self) -> None:
"""Discover channels and load their keys from the device."""
pfx = self._log_prefix
self.shared.set_status("🔄 Discovering channels...")
discovered: List[Dict] = []
cached_keys = self._cache.get_channel_keys()
confirmed: list[str] = []
from_cache: list[str] = []
derived: list[str] = []
consecutive_errors = 0
for idx in range(MAX_CHANNELS):
payload = await self._try_get_channel_info(idx, max_attempts=2, delay=1.0)
if payload is None:
consecutive_errors += 1
if consecutive_errors >= 3:
debug_print(
f"Channel discovery: {consecutive_errors} consecutive "
f"empty slots at idx {idx}, stopping"
)
break
continue
consecutive_errors = 0
name = payload.get("name") or payload.get("channel_name") or ""
if not name.strip():
debug_print(f"Channel [{idx}]: response OK but no name — skipping (undefined slot)")
continue
discovered.append({"idx": idx, "name": name})
secret = payload.get("channel_secret")
secret_bytes = self._extract_secret(secret)
if secret_bytes:
self._decoder.add_channel_key(idx, secret_bytes, source="device")
self._cache.set_channel_key(idx, secret_bytes.hex())
self._pending_keys.discard(idx)
confirmed.append(f"[{idx}] {name}")
elif str(idx) in cached_keys:
from_cache.append(f"[{idx}] {name}")
print(f"{pfx}: 📦 Channel [{idx}] '{name}' — using cached key")
else:
self._decoder.add_channel_key_from_name(idx, name)
self._pending_keys.add(idx)
derived.append(f"[{idx}] {name}")
print(f"{pfx}: ⚠️ Channel [{idx}] '{name}' — name-derived key (will retry)")
await asyncio.sleep(0.3)
if not discovered:
discovered = [{"idx": 0, "name": "Public"}]
print(f"{pfx}: ⚠️ No channels discovered, using default Public channel")
self._channels = discovered
self.shared.set_channels(discovered)
if CHANNEL_CACHE_ENABLED:
self._cache.set_channels(discovered)
debug_print("Channel list cached to disk")
print(f"{pfx}: Channels discovered: {[c['name'] for c in discovered]}")
print(f"{pfx}: PacketDecoder ready — has_keys={self._decoder.has_keys}")
if confirmed:
print(f"{pfx}: ✅ Keys from device: {', '.join(confirmed)}")
if from_cache:
print(f"{pfx}: 📦 Keys from cache: {', '.join(from_cache)}")
if derived:
print(f"{pfx}: ⚠️ Name-derived keys: {', '.join(derived)}")
async def _try_get_channel_info(
self, idx: int, max_attempts: int, delay: float,
) -> Optional[Dict]:
for attempt in range(max_attempts):
try:
r = await self.mc.commands.get_channel(idx)
if r is None:
debug_print(f"get_channel({idx}) attempt {attempt + 1}/{max_attempts}: received None response, retrying")
await asyncio.sleep(delay)
continue
if r.type == EventType.ERROR:
debug_print(f"get_channel({idx}) attempt {attempt + 1}/{max_attempts}: ERROR response — payload={pp(r.payload)}")
await asyncio.sleep(delay)
continue
debug_print(f"get_channel({idx}) attempt {attempt + 1}/{max_attempts}: OK — keys={list(r.payload.keys())}")
return r.payload
except Exception as exc:
debug_print(f"get_channel({idx}) attempt {attempt + 1}/{max_attempts} error: {exc}")
await asyncio.sleep(delay)
return None
async def _try_load_channel_key(
self, idx: int, name: str, max_attempts: int, delay: float,
) -> bool:
payload = await self._try_get_channel_info(idx, max_attempts, delay)
if payload is None:
return False
secret = payload.get("channel_secret")
secret_bytes = self._extract_secret(secret)
if secret_bytes:
self._decoder.add_channel_key(idx, secret_bytes, source="device")
self._cache.set_channel_key(idx, secret_bytes.hex())
print(f"{self._log_prefix}: ✅ Channel [{idx}] '{name}' — key from device (background retry)")
self._pending_keys.discard(idx)
return True
debug_print(f"get_channel({idx}): response OK but secret unusable")
return False
async def _retry_missing_keys(self) -> None:
if not self._pending_keys:
return
pending_copy = set(self._pending_keys)
ch_map = {ch["idx"]: ch["name"] for ch in self._channels}
debug_print(f"Background key retry: trying {len(pending_copy)} channels")
for idx in pending_copy:
name = ch_map.get(idx, f"ch{idx}")
loaded = await self._try_load_channel_key(idx, name, max_attempts=1, delay=0.5)
if loaded:
self._pending_keys.discard(idx)
await asyncio.sleep(1.0)
if not self._pending_keys:
print(f"{self._log_prefix}: ✅ All channel keys now loaded!")
else:
remaining = [f"[{idx}] {ch_map.get(idx, '?')}" for idx in sorted(self._pending_keys)]
debug_print(f"Background retry: still pending: {', '.join(remaining)}")
# ── helpers ────────────────────────────────────────────────────
def _seed_dedup_from_messages(self) -> None:
"""Seed the deduplicator with messages already in SharedData."""
snapshot = self.shared.get_snapshot()
messages = snapshot.get("messages", [])
seeded = 0
for msg in messages:
if msg.message_hash:
self._dedup.mark_hash(msg.message_hash)
seeded += 1
if msg.sender and msg.text:
self._dedup.mark_content(msg.sender, msg.channel, msg.text)
seeded += 1
debug_print(f"Dedup seeded with {seeded} entries from {len(messages)} messages")
@staticmethod
def _extract_secret(secret) -> Optional[bytes]:
if secret and isinstance(secret, bytes) and len(secret) >= 16:
return secret[:16]
if secret and isinstance(secret, str) and len(secret) >= 32:
try:
raw = bytes.fromhex(secret)
if len(raw) >= 16:
return raw[:16]
except ValueError:
pass
return None
# ── periodic tasks ────────────────────────────────────────────
async def _refresh_contacts(self) -> None:
try:
r = await self._get_contacts_with_timeout()
if r is None:
debug_print("Periodic refresh: get_contacts returned None, skipping")
return
if r.type != EventType.ERROR:
merged = self._cache.merge_contacts(r.payload)
self.shared.set_contacts(merged)
debug_print(
f"Periodic refresh: {len(r.payload)} from device, "
f"{len(merged)} total"
)
except Exception as exc:
debug_print(f"Periodic contact refresh failed: {exc}")
async def _cleanup_old_data(self) -> None:
try:
if self.shared.archive:
self.shared.archive.cleanup_old_data()
stats = self.shared.archive.get_stats()
debug_print(
f"Cleanup: archive now has {stats['total_messages']} messages, "
f"{stats['total_rxlog']} rxlog entries"
)
removed = self._cache.prune_old_contacts()
if removed > 0:
contacts = self._cache.get_contacts()
self.shared.set_contacts(contacts)
debug_print(f"Cleanup: pruned {removed} old contacts")
except Exception as exc:
debug_print(f"Periodic cleanup failed: {exc}")
# ======================================================================
# Serial worker
# ======================================================================
class SerialWorker(_BaseWorker):
"""Serial communication worker (USB/UART).
Args:
port: Serial device path (e.g. ``"/dev/ttyUSB0"``).
shared: SharedDataWriter for thread-safe communication.
baudrate: Serial baudrate (default from config).
cx_dly: Connection delay for meshcore serial transport.
"""
def __init__(
self,
port: str,
shared: SharedDataWriter,
baudrate: int = _config.SERIAL_BAUDRATE,
cx_dly: float = _config.SERIAL_CX_DELAY,
) -> None:
super().__init__(port, shared)
self.port = port
self.baudrate = baudrate
self.cx_dly = cx_dly
@property
def _log_prefix(self) -> str:
return "SERIAL"
@property
def _disconnect_keywords(self) -> tuple:
return (
"not connected", "disconnected", "connection reset",
"broken pipe", "i/o error", "read failed", "write failed",
"port is closed", "port closed",
)
async def _async_main(self) -> None:
try:
while self.running:
# ── Outer loop: (re)establish a fresh serial connection ──
self._disconnected = False
await self._connect()
if not self.mc:
print("SERIAL: Initial connection failed, retrying in 30s...")
self.shared.set_status("⚠️ Connection failed — retrying...")
await asyncio.sleep(30)
continue
# ── Inner loop: run + reconnect without calling _connect() again ──
# _handle_reconnect() already creates a fresh MeshCore and loads
# data — calling _connect() on top of that would attempt to open
# the serial port a second time, causing an immediate disconnect.
while self.running:
await self._main_loop()
if not self._disconnected or not self.running:
break
ok = await self._handle_reconnect()
if ok:
# Reconnected — reset flag and go back to _main_loop,
# NOT to the outer while (which would call _connect() again).
self._disconnected = False
else:
# All reconnect attempts exhausted — wait, then let the
# outer loop call _connect() for a clean fresh start.
await asyncio.sleep(60)
break
finally:
return
async def _connect(self) -> None:
if self._cache.load():
self._apply_cache()
print("SERIAL: Cache loaded — GUI populated from disk")
else:
print("SERIAL: No cache found — waiting for device data")
self.shared.set_status(f"🔄 Connecting to {self.port}...")
try:
print(f"SERIAL: Connecting to {self.port}...")
self.mc = await MeshCore.create_serial(
self.port,
baudrate=self.baudrate,
auto_reconnect=False,
default_timeout=DEFAULT_TIMEOUT,
debug=_config.MESHCORE_LIB_DEBUG,
cx_dly=self.cx_dly,
)
if self.mc is None:
raise RuntimeError("No response from device over serial")
print("SERIAL: Connected!")
await asyncio.sleep(1)
debug_print("Post-connection sleep done, wiring collaborators")
self._wire_collaborators()
await self._load_data()
await self.mc.start_auto_message_fetching()
self.shared.set_connected(True)
self.shared.set_status("✅ Connected")
print("SERIAL: Ready!")
if self._pending_keys:
pending_names = [
f"[{ch['idx']}] {ch['name']}"
for ch in self._channels
if ch["idx"] in self._pending_keys
]
print(
f"SERIAL: ⏳ Background retry active for: "
f"{', '.join(pending_names)} (every {KEY_RETRY_INTERVAL:.0f}s)"
)
except Exception as e:
print(f"SERIAL: Connection error: {e}")
self.mc = None # ensure _async_main sees connection as failed
if self._cache.has_cache:
self.shared.set_status(f"⚠️ Offline — using cached data ({e})")
else:
self.shared.set_status(f"{e}")
async def _reconnect(self) -> Optional[MeshCore]:
for attempt in range(1, RECONNECT_MAX_RETRIES + 1):
delay = RECONNECT_BASE_DELAY * attempt
print(
f"SERIAL: 🔄 Reconnect attempt {attempt}/{RECONNECT_MAX_RETRIES} "
f"in {delay:.0f}s..."
)
await asyncio.sleep(delay)
try:
mc = await MeshCore.create_serial(
self.port,
baudrate=self.baudrate,
auto_reconnect=False,
default_timeout=DEFAULT_TIMEOUT,
debug=_config.MESHCORE_LIB_DEBUG,
cx_dly=self.cx_dly,
)
if mc is None:
raise RuntimeError("No response from device over serial")
return mc
except Exception as exc:
print(f"SERIAL: ❌ Reconnect attempt {attempt} failed: {exc}")
print(f"SERIAL: ❌ Reconnect failed after {RECONNECT_MAX_RETRIES} attempts")
return None
# ======================================================================
# BLE worker
# ======================================================================
class BLEWorker(_BaseWorker):
"""BLE communication worker (Bluetooth Low Energy).
Args:
address: BLE MAC address (e.g. ``"literal:AA:BB:CC:DD:EE:FF"``).
shared: SharedDataWriter for thread-safe communication.
"""
def __init__(self, address: str, shared: SharedDataWriter) -> None:
super().__init__(address, shared)
self.address = address
# BLE PIN agent — imported lazily so serial-only installs
# don't need dbus_fast / bleak.
from meshcore_gui.ble.ble_agent import BleAgentManager
self._agent = BleAgentManager(pin=_config.BLE_PIN)
@property
def _log_prefix(self) -> str:
return "BLE"
@property
def _disconnect_keywords(self) -> tuple:
return (
"not connected", "disconnected", "dbus",
"pin or key missing", "connection reset", "broken pipe",
"failed to discover", "service discovery",
)
async def _async_main(self) -> None:
from meshcore_gui.ble.ble_reconnect import remove_bond
# Step 1: Start PIN agent BEFORE any BLE connection
await self._agent.start()
# Step 2: Remove stale bond (clean slate)
await remove_bond(self.address)
await asyncio.sleep(1)
# Step 3: Connect + main loop
try:
while self.running:
# ── Outer loop: (re)establish a fresh BLE connection ──
self._disconnected = False
await self._connect()
if not self.mc:
print("BLE: Initial connection failed, retrying in 30s...")
self.shared.set_status("⚠️ Connection failed — retrying...")
await asyncio.sleep(30)
await remove_bond(self.address)
await asyncio.sleep(1)
continue
# ── Inner loop: run + reconnect without calling _connect() again ──
# _handle_reconnect() already creates a fresh MeshCore and loads
# data — calling _connect() on top would open a second BLE session,
# causing an immediate disconnect.
while self.running:
await self._main_loop()
if not self._disconnected or not self.running:
break
ok = await self._handle_reconnect()
if ok:
# Reconnected — reset flag and go back to _main_loop,
# NOT to the outer while (which would call _connect() again).
self._disconnected = False
else:
await asyncio.sleep(60)
await remove_bond(self.address)
await asyncio.sleep(1)
break
finally:
await self._agent.stop()
async def _connect(self) -> None:
if self._cache.load():
self._apply_cache()
print("BLE: Cache loaded — GUI populated from disk")
else:
print("BLE: No cache found — waiting for BLE 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,
auto_reconnect=False,
default_timeout=DEFAULT_TIMEOUT,
debug=_config.MESHCORE_LIB_DEBUG,
)
print("BLE: Connected!")
await asyncio.sleep(1)
debug_print("Post-connection sleep done, wiring collaborators")
self._wire_collaborators()
await self._load_data()
await self.mc.start_auto_message_fetching()
self.shared.set_connected(True)
self.shared.set_status("✅ Connected")
print("BLE: Ready!")
if self._pending_keys:
pending_names = [
f"[{ch['idx']}] {ch['name']}"
for ch in self._channels
if ch["idx"] in self._pending_keys
]
print(
f"BLE: ⏳ Background retry active for: "
f"{', '.join(pending_names)} (every {KEY_RETRY_INTERVAL:.0f}s)"
)
except Exception as e:
print(f"BLE: Connection error: {e}")
self.mc = None # ensure _async_main sees connection as failed
if self._cache.has_cache:
self.shared.set_status(f"⚠️ Offline — using cached data ({e})")
else:
self.shared.set_status(f"{e}")
async def _reconnect(self) -> Optional[MeshCore]:
from meshcore_gui.ble.ble_reconnect import reconnect_loop
async def _create_fresh_connection() -> MeshCore:
return await MeshCore.create_ble(
self.address,
auto_reconnect=False,
default_timeout=DEFAULT_TIMEOUT,
debug=_config.MESHCORE_LIB_DEBUG,
)
return await reconnect_loop(
_create_fresh_connection,
self.address,
max_retries=RECONNECT_MAX_RETRIES,
base_delay=RECONNECT_BASE_DELAY,
)

View File

@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
VERSION: str = "1.12.0"
VERSION: str = "1.14.0"
# ==============================================================================
@@ -293,7 +293,7 @@ CHANNEL_CACHE_ENABLED: bool = False
# Fixed device name applied when the BOT checkbox is enabled.
# The original device name is saved and restored when BOT is disabled.
BOT_DEVICE_NAME: str = "NL-OV-ZWL-STDSHGN-WKC Bot"
BOT_DEVICE_NAME: str = "ZwolsBotje"
# Default device name used as fallback when restoring from BOT mode
# and no original name was saved (e.g. after a restart).
@@ -388,3 +388,8 @@ RXLOG_RETENTION_DAYS: int = 7
# Retention period for contacts (in days).
# Contacts not seen for longer than this are removed from cache.
CONTACT_RETENTION_DAYS: int = 90
# BBS channel configuration is managed at runtime via BbsConfigStore.
# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json
# and edited through the BBS Settings panel in the GUI.

View File

@@ -593,14 +593,58 @@ class SharedData:
return None
def get_contact_name_by_prefix(self, pubkey_prefix: str) -> str:
"""Resolve a pubkey/prefix to the best available display name.
The room server may report the author using different key fields:
a short prefix, a full public key, or a value copied into another
payload field. To keep sender display stable, match against both
the contact dict key and common pubkey-like fields stored inside
each contact record.
"""
if not pubkey_prefix:
return ""
probe = str(pubkey_prefix).strip().lower()
if not probe:
return ""
def _candidate_keys(contact_key: str, contact: Dict) -> List[str]:
values = [contact_key]
for field in (
'public_key',
'pubkey',
'pub_key',
'publicKey',
'sender_pubkey',
'author_pubkey',
'receiver_pubkey',
'pubkey_prefix',
'signature',
):
value = contact.get(field)
if isinstance(value, str) and value.strip():
values.append(value.strip())
return values
with self.lock:
device_key = (self.device.public_key or '').strip().lower()
if device_key and (
device_key.startswith(probe)
or probe.startswith(device_key)
):
return self.device.name or 'Me'
for key, contact in self.contacts.items():
if key.lower().startswith(pubkey_prefix.lower()):
name = contact.get('adv_name', '')
if name:
return name
for candidate in _candidate_keys(key, contact):
candidate_lower = candidate.lower()
if (
candidate_lower.startswith(probe)
or probe.startswith(candidate_lower)
):
name = str(contact.get('adv_name', '') or '').strip()
if name:
return name
return pubkey_prefix[:8]
def get_contact_by_name(self, name: str) -> Optional[Tuple[str, Dict]]:

View File

@@ -16,6 +16,7 @@ from meshcore_gui import config
from meshcore_gui.core.protocols import SharedDataReader
from meshcore_gui.gui.panels import (
ActionsPanel,
BbsPanel,
ContactsPanel,
DevicePanel,
MapPanel,
@@ -24,6 +25,8 @@ from meshcore_gui.gui.panels import (
RxLogPanel,
)
from meshcore_gui.gui.archive_page import ArchivePage
from meshcore_gui.services.bbs_config_store import BbsConfigStore
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.pin_store import PinStore
from meshcore_gui.services.room_password_store import RoomPasswordStore
@@ -193,6 +196,7 @@ body.body--light .domca-drawer .q-item { color: #3d6380 !important; }
.domca-panel .h-40 { height: calc(100vh - 20rem) !important; min-height: 10rem; }
.domca-panel .h-32 { height: calc(100vh - 24rem) !important; min-height: 8rem; }
.domca-panel .h-72 { height: calc(100vh - 12rem) !important; min-height: 14rem; }
.domca-panel .h-96 { height: calc(100vh - 8rem) !important; min-height: 16rem; }
.domca-panel .max-h-48 { max-height: calc(100vh - 16rem) !important; min-height: 6rem; }
/* ── Allow narrow viewports down to 320px ── */
@@ -263,6 +267,7 @@ _STANDALONE_ITEMS = [
('\U0001f4e1', 'DEVICE', 'device'),
('\u26a1', 'ACTIONS', 'actions'),
('\U0001f4ca', 'RX LOG', 'rxlog'),
('\U0001f4cb', 'BBS', 'bbs'),
]
_EXT_LINKS = config.EXT_LINKS
@@ -294,6 +299,13 @@ class DashboardPage:
self._pin_store = pin_store
self._room_password_store = room_password_store
# BBS service and config store (singletons shared with bot routing)
self._bbs_config_store = BbsConfigStore()
self._bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(
self._bbs_service, self._bbs_config_store
)
# Panels (created fresh on each render)
self._device: DevicePanel | None = None
self._contacts: ContactsPanel | None = None
@@ -302,6 +314,7 @@ class DashboardPage:
self._actions: ActionsPanel | None = None
self._rxlog: RxLogPanel | None = None
self._room_server: RoomServerPanel | None = None
self._bbs: BbsPanel | None = None
# Header status label
self._status_label = None
@@ -348,6 +361,7 @@ class DashboardPage:
self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled)
self._rxlog = RxLogPanel()
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
# Inject DOMCA theme (fonts + CSS variables)
ui.add_head_html(_DOMCA_HEAD)
@@ -508,6 +522,7 @@ class DashboardPage:
('actions', self._actions),
('rxlog', self._rxlog),
('rooms', self._room_server),
('bbs', self._bbs),
]
for panel_id, panel_obj in panel_defs:
@@ -679,30 +694,12 @@ class DashboardPage:
# Apply channel filter to messages panel
if panel_id == 'messages' and self._messages:
self._messages.set_active_channel(channel)
# Force immediate rebuild so the panel is populated the
# moment it becomes visible, instead of waiting for the
# next 500 ms timer tick (which caused the "empty on first
# click, populated on second click" symptom).
data = self._shared.get_snapshot()
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
# Apply channel filter to archive panel
if panel_id == 'archive' and self._archive_page:
self._archive_page.set_channel_filter(channel)
# Force map recenter when opening map panel (Leaflet may be hidden on load)
if panel_id == 'map' and self._map:
data = self._shared.get_snapshot()
data['force_center'] = True
self._map.update(data)
self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
# Update active menu highlight (standalone buttons only)
for pid, btn in self._menu_buttons.items():
@@ -715,6 +712,47 @@ class DashboardPage:
if self._drawer:
self._drawer.hide()
def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
"""Refresh only the currently visible panel.
This is used directly after a panel switch so the user does not
need to wait for the next 500 ms dashboard tick.
"""
data = self._shared.get_snapshot()
if data.get('channels'):
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
if self._active_panel == 'device':
self._device.update(data)
elif self._active_panel == 'map':
if force_map_center:
data['force_center'] = True
self._map.update(data)
elif self._active_panel == 'actions':
self._actions.update(data)
elif self._active_panel == 'contacts':
self._contacts.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# ------------------------------------------------------------------
# Room Server callback (from ContactsPanel)
# ------------------------------------------------------------------
@@ -752,70 +790,53 @@ class DashboardPage:
# Always update status
self._status_label.text = data['status']
# Device info
if data['device_updated'] or is_first:
self._device.update(data)
# Map updates are intentionally limited to when the map panel
# is visible. Updating Leaflet every 500 ms while hidden can
# trigger excessive tile/layer work in the browser and make the
# rest of the UI feel unresponsive (for example the hamburger
# menu appearing to do nothing). The explicit update in
# _show_panel('map') still refreshes and recenters the map when
# the user opens it.
if self._active_panel == 'map' and (
data['device_updated'] or is_first
):
self._map.update(data)
# Channel-dependent UI: always ensure consistency when
# channels exist. Because a single DashboardPage instance
# is shared across browser sessions (render() is called on
# each new connection), the old session's timer can steal
# the is_first flag before the new timer fires. Running
# these unconditionally is safe because each method has an
# internal fingerprint/equality check that prevents
# unnecessary DOM updates.
# Channel-dependent drawer/submenu state may stay global.
# The helpers below already contain equality checks, so this
# remains cheap while keeping navigation consistent.
if data['channels']:
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
# BOT checkbox state (only on actual change or first render
# to avoid overwriting user interaction mid-toggle)
if data['channels_updated'] or is_first:
self._actions.update(data)
if self._active_panel == 'device':
if data['device_updated'] or is_first:
self._device.update(data)
# Contacts
if data['contacts_updated'] or is_first:
self._contacts.update(data)
# Map
if (
self._active_panel == 'map'
and data['contacts']
and (
data['contacts_updated']
or not self._map.has_markers
or is_first
)
):
elif self._active_panel == 'map':
# Keep sending snapshots while the map panel is active.
# The browser runtime coalesces pending payloads, so only
# the newest snapshot is applied.
self._map.update(data)
# Messages (always — for live filter changes)
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=self._room_server.get_room_pubkeys() if self._room_server else None,
)
elif self._active_panel == 'actions':
if data['channels_updated'] or is_first:
self._actions.update(data)
# Room Server panels (always — for live messages + contact changes)
self._room_server.update(data)
elif self._active_panel == 'contacts':
if data['contacts_updated'] or is_first:
self._contacts.update(data)
# RX Log
if data['rxlog_updated']:
self._rxlog.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
if data['rxlog_updated'] or is_first:
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# Signal worker that GUI is ready for data
if is_first and data['channels'] and data['contacts']:

View File

@@ -15,3 +15,4 @@ from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401
from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401
from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401
from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401
from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401

View File

@@ -0,0 +1,543 @@
"""BBS panel -- board-based Bulletin Board System viewer and configuration."""
import re
from typing import Callable, Dict, List, Optional
from nicegui import ui
from meshcore_gui.config import debug_print
from meshcore_gui.services.bbs_config_store import (
BbsBoard,
BbsConfigStore,
DEFAULT_CATEGORIES,
DEFAULT_RETENTION_HOURS,
)
from meshcore_gui.services.bbs_service import BbsMessage, BbsService
from meshcore_gui.core.protocols import SharedDataReadAndLookup
def _slug(name: str) -> str:
"""Convert a board name to a safe id slug.
Args:
name: Human-readable board name.
Returns:
Lowercase alphanumeric + underscore string.
"""
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board"
# ---------------------------------------------------------------------------
# Main BBS panel (message view only — settings live on /bbs-settings)
# ---------------------------------------------------------------------------
class BbsPanel:
"""BBS panel: board selector, category buttons, message list and post form.
Settings are on a separate page (/bbs-settings), reachable via the
gear icon in the panel header.
Args:
put_command: Callable to enqueue a command dict for the worker.
bbs_service: Shared BbsService instance.
config_store: Shared BbsConfigStore instance.
"""
def __init__(
self,
put_command: Callable[[Dict], None],
bbs_service: BbsService,
config_store: BbsConfigStore,
) -> None:
self._put_command = put_command
self._service = bbs_service
self._config_store = config_store
# Active view state
self._active_board: Optional[BbsBoard] = None
self._active_category: Optional[str] = None
# UI refs
self._board_btn_row = None
self._category_btn_row = None
self._msg_list_container = None
self._post_region_row = None
self._post_region_select = None
self._post_category_select = None
self._text_input = None
# Button refs for active highlight
self._board_buttons: Dict[str, object] = {}
self._category_buttons: Dict[str, object] = {}
# Cached device channels (updated by update())
self._device_channels: List[Dict] = []
self._last_ch_fingerprint: tuple = ()
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the BBS message view panel layout."""
with ui.card().classes('w-full'):
# Header row with gear icon
with ui.row().classes('w-full items-center justify-between'):
ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600')
ui.button(
icon='settings',
on_click=lambda: ui.navigate.to('/bbs-settings'),
).props('flat round dense').tooltip('BBS Settings')
# Board selector row
self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
with self._board_btn_row:
ui.label('No active boards — open Settings to enable a channel.').classes(
'text-xs text-gray-400 italic'
)
ui.separator()
# Category filter row (clickable buttons, replaces dropdown)
self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
with self._category_btn_row:
ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
ui.separator()
# Message list
self._msg_list_container = ui.column().classes(
'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2'
).style('max-height: calc(100vh - 24rem); min-height: 8rem')
ui.separator()
# Post row — keep selects for sending
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
ui.label('Post:').classes('text-sm text-gray-600')
self._post_region_row = ui.row().classes('items-center gap-1')
with self._post_region_row:
self._post_region_select = ui.select(
options=[], label='Region',
).classes('text-xs').style('min-width: 110px')
self._post_category_select = ui.select(
options=[], label='Category',
).classes('text-xs').style('min-width: 110px')
self._text_input = ui.input(
placeholder='Message text...',
).classes('flex-grow text-sm min-w-0')
ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
# Initial render
self._rebuild_board_buttons()
# ------------------------------------------------------------------
# Board selector
# ------------------------------------------------------------------
def _rebuild_board_buttons(self) -> None:
"""Rebuild board selector buttons from current config."""
if not self._board_btn_row:
return
self._board_btn_row.clear()
self._board_buttons = {}
boards = self._config_store.get_boards()
with self._board_btn_row:
if not boards:
ui.label('No active boards — open Settings to enable a channel.').classes(
'text-xs text-gray-400 italic'
)
return
for board in boards:
btn = ui.button(
board.name,
on_click=lambda b=board: self._select_board(b),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._board_buttons[board.id] = btn
ids = [b.id for b in boards]
if boards and (self._active_board is None or self._active_board.id not in ids):
self._select_board(boards[0])
elif self._active_board and self._active_board.id in self._board_buttons:
self._board_buttons[self._active_board.id].classes('domca-menu-active')
def _select_board(self, board: BbsBoard) -> None:
"""Activate a board and rebuild category buttons.
Args:
board: Board to activate.
"""
self._active_board = board
self._active_category = None
# Update board button highlights
for bid, btn in self._board_buttons.items():
if bid == board.id:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# Update post selects
if self._post_region_row:
self._post_region_row.set_visibility(bool(board.regions))
if self._post_region_select:
self._post_region_select.options = board.regions
self._post_region_select.value = board.regions[0] if board.regions else None
if self._post_category_select:
self._post_category_select.options = board.categories
self._post_category_select.value = board.categories[0] if board.categories else None
self._rebuild_category_buttons()
self._refresh_messages()
# ------------------------------------------------------------------
# Category buttons
# ------------------------------------------------------------------
def _rebuild_category_buttons(self) -> None:
"""Rebuild clickable category filter buttons for the active board."""
if not self._category_btn_row:
return
self._category_btn_row.clear()
self._category_buttons = {}
if self._active_board is None:
with self._category_btn_row:
ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
return
with self._category_btn_row:
# "All" button
all_btn = ui.button(
'ALL',
on_click=lambda: self._on_category_filter(None),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._category_buttons['__all__'] = all_btn
for cat in self._active_board.categories:
btn = ui.button(
cat,
on_click=lambda c=cat: self._on_category_filter(c),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._category_buttons[cat] = btn
# Highlight the current active category
self._update_category_highlight()
def _on_category_filter(self, category: Optional[str]) -> None:
"""Handle category button click.
Args:
category: Category string, or None for all.
"""
self._active_category = category
self._update_category_highlight()
self._refresh_messages()
def _update_category_highlight(self) -> None:
"""Apply domca-menu-active to the currently selected category button."""
active_key = self._active_category if self._active_category else '__all__'
for key, btn in self._category_buttons.items():
if key == active_key:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# ------------------------------------------------------------------
# Message list
# ------------------------------------------------------------------
def _refresh_messages(self) -> None:
if not self._msg_list_container:
return
self._msg_list_container.clear()
with self._msg_list_container:
if self._active_board is None:
ui.label('Select a board above.').classes('text-xs text-gray-400 italic')
return
if not self._active_board.channels:
ui.label('No channels assigned to this board.').classes(
'text-xs text-gray-400 italic'
)
return
messages = self._service.get_all_messages(
channels=self._active_board.channels,
region=None,
category=self._active_category,
)
if not messages:
ui.label('No messages.').classes('text-xs text-gray-400 italic')
return
for msg in messages:
self._render_message_row(msg)
def _render_message_row(self, msg: BbsMessage) -> None:
ts = msg.timestamp[:16].replace('T', ' ')
region_label = f' [{msg.region}]' if msg.region else ''
header = f'{ts} {msg.sender} [{msg.category}]{region_label}'
with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'):
ui.label(header).classes('text-xs text-gray-500').style(
'word-break: break-all; overflow-wrap: break-word'
)
ui.label(msg.text).classes('text-sm').style(
'word-break: break-word; overflow-wrap: break-word'
)
# ------------------------------------------------------------------
# Post
# ------------------------------------------------------------------
def _on_post(self) -> None:
if self._active_board is None:
ui.notify('Select a board first.', type='warning')
return
if not self._active_board.channels:
ui.notify('No channels assigned to this board.', type='warning')
return
text = (self._text_input.value or '').strip() if self._text_input else ''
if not text:
ui.notify('Message text cannot be empty.', type='warning')
return
category = (
self._post_category_select.value if self._post_category_select
else (self._active_board.categories[0] if self._active_board.categories else '')
)
if not category:
ui.notify('Please select a category.', type='warning')
return
region = ''
if self._active_board.regions and self._post_region_select:
region = self._post_region_select.value or ''
target_channel = self._active_board.channels[0]
msg = BbsMessage(
channel=target_channel,
region=region, category=category,
sender='Me', sender_key='', text=text,
)
self._service.post_message(msg)
region_part = f'{region} ' if region else ''
mesh_text = f'!bbs post {region_part}{category} {text}'
self._put_command({
'action': 'send_message',
'channel': target_channel,
'text': mesh_text,
})
debug_print(
f'BBS panel: posted to board={self._active_board.id} '
f'ch={target_channel} {mesh_text[:60]}'
)
if self._text_input:
self._text_input.value = ''
self._refresh_messages()
ui.notify('Message posted.', type='positive')
# ------------------------------------------------------------------
# External update hook
# ------------------------------------------------------------------
def update(self, data: Dict) -> None:
"""Called by the dashboard timer with the SharedData snapshot.
Args:
data: SharedData snapshot dict.
"""
device_channels = data.get('channels', [])
fingerprint = tuple(
(ch.get('idx', 0), ch.get('name', '')) for ch in device_channels
)
if fingerprint != self._last_ch_fingerprint:
self._last_ch_fingerprint = fingerprint
self._device_channels = device_channels
self._rebuild_board_buttons()
# ---------------------------------------------------------------------------
# Separate settings page (/bbs-settings)
# ---------------------------------------------------------------------------
class BbsSettingsPage:
"""Standalone BBS settings page, registered at /bbs-settings.
One node = one board. The page shows a single channel selector
populated from the active device channels, plus a categories field,
a retention field, and a collapsible Advanced section for regions
and allowed keys. There is no board creation or deletion UI.
Args:
shared: SharedData instance (for device channel list).
config_store: BbsConfigStore instance.
"""
def __init__(
self,
shared: SharedDataReadAndLookup,
config_store: BbsConfigStore,
) -> None:
self._shared = shared
self._config_store = config_store
self._device_channels: List[Dict] = []
self._container = None
def render(self) -> None:
"""Render the BBS settings page."""
from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy — avoids circular import
data = self._shared.get_snapshot()
self._device_channels = data.get('channels', [])
ui.page_title('BBS Settings')
ui.add_head_html(_DOMCA_HEAD)
ui.dark_mode(True)
with ui.header().classes('items-center px-4 py-2 shadow-md'):
ui.button(
icon='arrow_back',
on_click=lambda: ui.run_javascript('window.history.back()'),
).props('flat round dense color=white').tooltip('Back')
ui.label('📋 BBS Settings').classes(
'text-lg font-bold domca-header-text'
).style("font-family: 'JetBrains Mono', monospace")
ui.space()
with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'):
with ui.card().classes('w-full'):
ui.label('BBS Settings').classes('font-bold text-gray-600')
ui.separator()
self._container = ui.column().classes('w-full gap-3')
with self._container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_settings()
# ------------------------------------------------------------------
# Settings rendering
# ------------------------------------------------------------------
def _render_settings(self) -> None:
"""Render the board settings block."""
board = self._config_store.get_single_board()
active_channels = set(board.channels) if board else set()
cats_value = (
', '.join(board.categories) if board
else ', '.join(DEFAULT_CATEGORIES)
)
retention_value = (
str(board.retention_hours) if board
else str(DEFAULT_RETENTION_HOURS)
)
adv_regions_value = ', '.join(board.regions) if board else ''
adv_keys_value = ', '.join(board.allowed_keys) if board else ''
# ── Channel checkboxes ───────────────────────────────────────
ch_checks: Dict[int, object] = {}
with ui.column().classes('w-full gap-1'):
ui.label('Channels:').classes('text-xs text-gray-600')
with ui.column().classes('w-full gap-1 pl-2'):
for ch in self._device_channels:
idx = ch.get('idx', ch.get('index', 0))
name = ch.get('name', f'Ch {idx}')
cb = ui.checkbox(
f'[{idx}] {name}',
value=idx in active_channels,
).classes('text-xs')
ch_checks[idx] = cb
# ── Categories + retention ───────────────────────────────────
with ui.row().classes('w-full items-center gap-2 mt-1'):
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
with ui.row().classes('w-full items-center gap-2'):
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
retention_input = ui.input(
value=retention_value,
).classes('text-xs').style('max-width: 80px')
ui.label('hours').classes('text-xs text-gray-600')
# ── Advanced (collapsed) ─────────────────────────────────────
with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'):
ui.label('Regions and allowed keys').classes('text-xs text-gray-500 pb-1')
regions_input = ui.input(
label='Regions (comma-separated)',
value=adv_regions_value,
).classes('w-full text-xs')
keys_input = ui.input(
label='Allowed keys (empty = auto-learned from channel activity)',
value=adv_keys_value,
).classes('w-full text-xs')
# ── Save ─────────────────────────────────────────────────────
def _save(
cc=ch_checks,
ci=cats_input,
ri=retention_input,
rgi=regions_input,
ki=keys_input,
) -> None:
selected = [idx for idx, cb in cc.items() if cb.value]
if not selected:
ui.notify('Select at least one channel.', type='warning')
return
ch_names = {
ch.get('idx', ch.get('index', 0)): ch.get('name', '?')
for ch in self._device_channels
}
categories = [
c.strip().upper()
for c in (ci.value or '').split(',') if c.strip()
] or list(DEFAULT_CATEGORIES)
try:
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
except ValueError:
ret_hours = DEFAULT_RETENTION_HOURS
regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()]
# Only pass allowed_keys if the field was explicitly filled;
# empty field means "keep auto-learned keys"
raw_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()]
allowed_keys = raw_keys if raw_keys else None
self._config_store.configure_board(
channel_indices=selected,
channel_names=ch_names,
categories=categories,
retention_hours=ret_hours,
regions=regions,
allowed_keys=allowed_keys,
)
ch_labels = ', '.join(f"[{i}] {ch_names.get(i, '?')}" for i in sorted(selected))
debug_print(f'BBS settings: configured channels {ch_labels}')
ui.notify(f'BBS saved — {ch_labels}.', type='positive')
self._rebuild()
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2')
def _rebuild(self) -> None:
"""Clear and re-render the settings container in-place."""
if not self._container:
return
data = self._shared.get_snapshot()
self._device_channels = data.get('channels', [])
self._container.clear()
with self._container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_settings()

View File

@@ -45,10 +45,9 @@ class MapPanel:
on_change=lambda e: self._set_map_theme_mode(e.value),
).props('dense')
ui.button('Center on Device', on_click=self._center_on_device)
ui.html(
f'<div id="{self._container_id}" class="meshcore-leaflet-host w-full h-72"></div>'
).classes('w-full h-72')
self._dispatch_to_browser(snapshot={'__command__': 'ensure_map'})
ui.element('div').props(f'id={self._container_id}').classes(
'meshcore-leaflet-host w-full h-72'
)
self._apply_theme_only()
def set_ui_dark_mode(self, value: bool | None) -> None:
@@ -189,10 +188,10 @@ class MapPanel:
'meshcore-leaflet-vendor-js',
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
function () {
ensurePanelRuntime();
ensureScript(
'meshcore-leaflet-markercluster-js',
'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js',
ensurePanelRuntime
'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js'
);
}
);

View File

@@ -1,6 +1,6 @@
"""Messages panel — filtered message display with channel selection and message input."""
from typing import Callable, Dict, List, Set
from typing import Callable, Dict, Iterable, List, Set
from nicegui import ui
@@ -153,12 +153,30 @@ class MessagesPanel:
# -- Message display -----------------------------------------------
@staticmethod
def _merge_room_pubkeys(
ui_room_pubkeys: Set[str] | None,
known_room_pubkeys: Iterable[str] | None,
) -> Set[str]:
"""Merge UI-tracked and centrally known Room Server keys.
The RoomServerPanel may not yet be fully restored when archived
messages are first shown. The SharedData registry provides a
second, UI-independent source of truth for room key prefixes.
"""
merged: Set[str] = set()
if ui_room_pubkeys:
merged.update(pk for pk in ui_room_pubkeys if pk)
if known_room_pubkeys:
merged.update(pk for pk in known_room_pubkeys if pk)
return merged
@staticmethod
def _is_room_message(msg: Message, room_pubkeys: Set[str]) -> bool:
"""Return True if *msg* belongs to a Room Server.
Matches when the message's ``sender_pubkey`` prefix-matches
any tracked room pubkey (same logic as RoomServerPanel).
any tracked or centrally known room pubkey.
"""
if not msg.sender_pubkey or not room_pubkeys:
return False
@@ -195,7 +213,10 @@ class MessagesPanel:
if not self._container:
return
room_pks = room_pubkeys or set()
room_pks = self._merge_room_pubkeys(
room_pubkeys,
data.get('known_room_pubkeys'),
)
channel_names = {ch['idx']: ch['name'] for ch in last_channels}
contacts = data.get('contacts', {})
messages: List[Message] = data['messages']

View File

@@ -116,10 +116,12 @@ class RoomServerPanel:
room_messages: Dict = data.get('room_messages', {})
# Live messages from current session's rolling buffer
live_messages: List[Message] = data.get('messages', [])
# Contact dict for live sender-name resolution
contacts: Dict = data.get('contacts', {})
for pubkey, card_state in self._room_cards.items():
self._update_room_messages(
pubkey, card_state, room_messages, live_messages,
pubkey, card_state, room_messages, live_messages, contacts,
)
# ------------------------------------------------------------------
@@ -389,6 +391,41 @@ class RoomServerPanel:
if card_state and card_state.get('card'):
self._container.remove(card_state['card'])
# ------------------------------------------------------------------
# Internal — sender name resolution
# ------------------------------------------------------------------
@staticmethod
def _resolve_sender_name(sender: str, contacts: Dict) -> str:
"""Resolve a sender field to a display name when possible.
When ``msg.sender`` was stored as a raw hex prefix (because the
contact was not yet known at archive time), this method attempts
a live lookup against the current contacts snapshot so the UI
always shows a human-readable name instead of a hex code.
Args:
sender: Value from ``Message.sender`` — may be a name or a hex string.
contacts: Current contacts snapshot from ``SharedData.get_snapshot()``.
Returns:
Resolved display name, or the original sender value if no
match is found, or ``'?'`` when sender is empty.
"""
if not sender:
return '?'
probe = sender.strip().lower()
# Only resolve when the field looks like a hex identifier (664 hex chars)
if not (6 <= len(probe) <= 64 and all(ch in '0123456789abcdef' for ch in probe)):
return sender
for key, contact in contacts.items():
candidate = key.strip().lower()
if candidate.startswith(probe) or probe.startswith(candidate[:len(probe)]):
name = str(contact.get('adv_name', '') or '').strip()
if name:
return name
return sender
# ------------------------------------------------------------------
# Internal — message display
# ------------------------------------------------------------------
@@ -399,6 +436,7 @@ class RoomServerPanel:
card_state: Dict,
room_messages: Dict,
live_messages: List[Message],
contacts: Dict,
) -> None:
"""Update the message display for a single room card.
@@ -412,6 +450,7 @@ class RoomServerPanel:
card_state: UI state dict for this room card.
room_messages: ``{12-char-prefix: [Message, …]}`` from archive cache.
live_messages: Current session's rolling message buffer.
contacts: Current contacts snapshot for live name resolution.
"""
msg_container = card_state.get('msg_container')
if not msg_container:
@@ -455,7 +494,7 @@ class RoomServerPanel:
with msg_container:
for msg in display:
direction = '' if msg.direction == 'out' else ''
sender = msg.sender or '?'
sender = self._resolve_sender_name(msg.sender or '', contacts)
line = f"{msg.time} {direction} {sender}: {msg.text}"
ui.label(line).classes(

View File

@@ -79,9 +79,8 @@ _ROUTE_MAP_ASSETS = r"""
ensureStylesheet('meshcore-leaflet-panel-css', '/static/leaflet_map_panel.css');
ensureScript('meshcore-leaflet-vendor-js', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', function () {
ensureScript('meshcore-leaflet-markercluster-js', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js', function () {
ensureScript('meshcore-leaflet-panel-js', '/static/leaflet_map_panel.js');
});
ensureScript('meshcore-leaflet-panel-js', '/static/leaflet_map_panel.js');
ensureScript('meshcore-leaflet-markercluster-js', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js');
});
})();
</script>
@@ -144,12 +143,8 @@ class RoutePage:
with ui.header().classes('items-center px-4 py-2 shadow-md'):
ui.button(
icon='arrow_back',
on_click=lambda: ui.navigate.to('/'),
).props('flat round dense color=white').tooltip('Back to Dashboard')
ui.button(
icon='history',
on_click=lambda: ui.navigate.to('/archive'),
).props('flat round dense color=white').tooltip('Back to Archive')
on_click=lambda: ui.run_javascript('window.history.back()'),
).props('flat round dense color=white').tooltip('Back')
ui.label('🗺️ MeshCore Route').classes(
'text-lg font-bold domca-header-text'
).style("font-family: 'JetBrains Mono', monospace")
@@ -230,19 +225,29 @@ class RoutePage:
@staticmethod
def _render_map(data: Dict, route: Dict) -> None:
"""Render the route map in browser JS using the shared MAP icons."""
"""Render the route map in browser JS using the shared MAP icons.
The Leaflet container is always rendered. When no nodes carry GPS
coordinates a notice is shown inside the card, but the map itself
still initialises so the user sees the configured home area.
MeshCoreRouteMapBoot handles an empty nodes array gracefully by
displaying the map at payload.center with no markers.
"""
with ui.card().classes('w-full'):
payload = RoutePage._build_route_map_payload(data, route)
# Show a notice when no node carries GPS, but do NOT skip the
# Leaflet container. The JS runtime renders the map at the
# configured home area (DEFAULT_MAP_CENTER) with no markers.
if not payload['nodes']:
ui.label(
'📍 No location data available for map display'
).classes('text-gray-500 italic p-4')
return
'📍 No GPS location data — map shows home area'
).classes('text-xs text-gray-400 italic px-2 pt-2')
container_id = f'route-map-{uuid4().hex}'
ui.html(
f'<div id="{container_id}" class="w-full h-96 rounded-lg overflow-hidden"></div>'
).classes('w-full')
ui.element('div').props(f'id={container_id}').classes(
'w-full'
).style('height:24rem;border-radius:0.5rem;overflow:hidden;')
boot_script = (
'(function bootRouteMap(retries){'

View File

@@ -0,0 +1,395 @@
"""
Application configuration for MeshCore GUI.
Contains only global runtime settings.
Bot configuration lives in :mod:`meshcore_gui.services.bot`.
UI display constants live in :mod:`meshcore_gui.gui.constants`.
The ``DEBUG`` flag defaults to False and can be activated at startup
with the ``--debug-on`` command-line option.
Debug output is written to both stdout and a rotating log file at
``~/.meshcore-gui/logs/meshcore_gui.log``.
"""
import json
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, Dict, List
# ==============================================================================
# VERSION
# ==============================================================================
VERSION: str = "1.14.0"
# ==============================================================================
# OPERATOR / LANDING PAGE
# ==============================================================================
# Operator callsign shown on the landing page SVG and drawer footer.
# Change this to your own callsign (e.g. "PE1HVH", "PE1HVH/MIT").
OPERATOR_CALLSIGN: str = "PE1HVH"
# Path to the landing page SVG file.
# The placeholder ``{callsign}`` inside the SVG is replaced at runtime
# with ``OPERATOR_CALLSIGN``.
#
# Default: the bundled DOMCA splash (static/landing_default.svg).
# To use a custom SVG, point this to your own file, e.g.:
# LANDING_SVG_PATH = DATA_DIR / "landing.svg"
LANDING_SVG_PATH: Path = Path(__file__).parent / "static" / "landing_default.svg"
# ==============================================================================
# MAP DEFAULTS
# ==============================================================================
# Default map centre used as the initial view *before* the device reports
# its own GPS position. Once the device advertises a valid adv_lat/adv_lon
# pair, every map will re-centre on the device's actual location.
#
# Change these values to match the location of your device / station.
# Current default: Zwolle, The Netherlands (52.5168, 6.0830).
DEFAULT_MAP_CENTER: tuple[float, float] = (52.5168, 6.0830)
# Default zoom level for all Leaflet maps (higher = more zoomed in).
DEFAULT_MAP_ZOOM: int = 9
# ==============================================================================
# DIRECTORY STRUCTURE
# ==============================================================================
# Base data directory — all persistent data lives under this root.
# Existing services (cache, pins, archive) each define their own
# sub-directory; this constant centralises the root for new consumers.
DATA_DIR: Path = Path.home() / ".meshcore-gui"
# Log directory for debug and error log files.
LOG_DIR: Path = DATA_DIR / "logs"
# Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total).
LOG_FILE: Path = LOG_DIR / "meshcore_gui.log"
def set_log_file_for_device(device_id: str) -> None:
"""Set the log file name based on the device identifier.
Transforms ``F0:9E:9E:75:A3:01`` into
``~/.meshcore-gui/logs/F0_9E_9E_75_A3_01_meshcore_gui.log`` and
``/dev/ttyUSB0`` into ``~/.meshcore-gui/logs/_dev_ttyUSB0_meshcore_gui.log``.
Must be called **before** the first ``debug_print()`` call so the
lazy logger initialisation picks up the correct path.
"""
global LOG_FILE
safe_name = (
device_id
.replace("literal:", "")
.replace(":", "_")
.replace("/", "_")
)
LOG_FILE = LOG_DIR / f"{safe_name}_meshcore_gui.log"
# Maximum size per log file in bytes (5 MB).
LOG_MAX_BYTES: int = 5 * 1024 * 1024
# Number of rotated backup files to keep.
LOG_BACKUP_COUNT: int = 3
# ==============================================================================
# DEBUG
# ==============================================================================
DEBUG: bool = False
# Internal file logger — initialised lazily on first debug_print() call.
_file_logger: logging.Logger | None = None
def _init_file_logger() -> logging.Logger:
"""Create and configure the rotating file logger (called once)."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("meshcore_gui.debug")
logger.setLevel(logging.DEBUG)
logger.propagate = False
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
)
logger.addHandler(handler)
return logger
def _caller_module() -> str:
"""Return a short module label for the calling code.
Walks two frames up (debug_print -> caller) and extracts the
module ``__name__``. The common ``meshcore_gui.`` prefix is
stripped for brevity, e.g. ``ble.worker`` instead of
``meshcore_gui.ble.worker``.
"""
frame = sys._getframe(2) # 0=_caller_module, 1=debug_print, 2=actual caller
module = frame.f_globals.get("__name__", "<unknown>")
if module.startswith("meshcore_gui."):
module = module[len("meshcore_gui."):]
return module
def _init_meshcore_logger() -> None:
"""Route meshcore library debug output to our rotating log file.
The meshcore library uses ``logging.getLogger("meshcore")`` throughout,
but never attaches a handler. Without this function all library-level
debug output (raw send/receive, event dispatching, command flow)
is silently dropped because Python's root logger only forwards
WARNING and above.
Call once at startup (or lazily from ``debug_print``) so that
``MESHCORE_LIB_DEBUG=True`` actually produces visible output.
"""
LOG_DIR.mkdir(parents=True, exist_ok=True)
mc_logger = logging.getLogger("meshcore")
# Guard against duplicate handlers on repeated calls
if any(isinstance(h, RotatingFileHandler) for h in mc_logger.handlers):
return
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT,
encoding="utf-8",
)
handler.setFormatter(
logging.Formatter(
"%(asctime)s LIB [%(name)s]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
mc_logger.addHandler(handler)
# Also add a stdout handler so library output appears in the console
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(
logging.Formatter(
"%(asctime)s LIB [%(name)s]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
mc_logger.addHandler(stdout_handler)
def debug_print(msg: str) -> None:
"""Print a debug message when ``DEBUG`` is enabled.
Output goes to both stdout and the rotating log file.
The calling module name is automatically included so that
exception context is immediately clear, e.g.::
DEBUG [ble.worker]: send_appstart attempt 3 exception: TimeoutError
"""
global _file_logger
if not DEBUG:
return
module = _caller_module()
formatted = f"DEBUG [{module}]: {msg}"
# stdout (existing behaviour, now with module tag)
print(formatted)
# Rotating log file
if _file_logger is None:
_file_logger = _init_file_logger()
# Also wire up the meshcore library logger so MESHCORE_LIB_DEBUG
# output actually appears in the same log file + stdout.
_init_meshcore_logger()
_file_logger.debug(formatted)
def pp(obj: Any, indent: int = 2) -> str:
"""Pretty-format a dict, list, or other object for debug output.
Use inside f-strings::
debug_print(f"payload={pp(r.payload)}")
Dicts/lists get indented JSON; everything else falls back to repr().
"""
if isinstance(obj, (dict, list)):
try:
return json.dumps(obj, indent=indent, default=str, ensure_ascii=False)
except (TypeError, ValueError):
return repr(obj)
return repr(obj)
def debug_data(label: str, obj: Any) -> None:
"""Print a labelled data structure with pretty indentation.
Combines a header line with pretty-printed data below it::
debug_data("get_contacts result", r.payload)
Output::
DEBUG [worker]: get_contacts result ↓
{
"name": "PE1HVH",
"contacts": 629,
...
}
"""
if not DEBUG:
return
formatted = pp(obj)
# Single-line values stay on the same line
if '\n' not in formatted:
debug_print(f"{label}: {formatted}")
else:
# Multi-line: indent each line for readability
indented = '\n'.join(f" {line}" for line in formatted.splitlines())
debug_print(f"{label}\n{indented}")
# ==============================================================================
# CHANNELS
# ==============================================================================
# Maximum number of channel slots to probe on the device.
# MeshCore supports up to 8 channels (indices 0-7).
MAX_CHANNELS: int = 8
# Enable or disable caching of the channel list to disk.
# When False (default), channels are always fetched fresh from the
# device at startup, guaranteeing the GUI always reflects the actual
# device configuration. When True, channels are loaded from cache
# for instant GUI population and then refreshed from the device.
# Note: channel *keys* (for packet decryption) are always cached
# regardless of this setting.
CHANNEL_CACHE_ENABLED: bool = False
# ==============================================================================
# BOT DEVICE NAME
# ==============================================================================
# Fixed device name applied when the BOT checkbox is enabled.
# The original device name is saved and restored when BOT is disabled.
BOT_DEVICE_NAME: str = "ZwolsBotje"
# Default device name used as fallback when restoring from BOT mode
# and no original name was saved (e.g. after a restart).
DEVICE_NAME: str = "PE1HVH T1000e"
# ==============================================================================
# CACHE / REFRESH
# ==============================================================================
# Default timeout (seconds) for meshcore command responses.
# Increase if you see frequent 'no_event_received' errors during startup.
DEFAULT_TIMEOUT: float = 10.0
# Enable debug logging inside the meshcore library itself.
# When True, raw send/receive data and event parsing are logged.
MESHCORE_LIB_DEBUG: bool = True
# ==============================================================================
# TRANSPORT MODE (auto-detected from CLI argument)
# ==============================================================================
# "serial" or "ble" — set at startup by main() based on the device argument.
TRANSPORT: str = "serial"
def is_ble_address(device_id: str) -> bool:
"""Detect whether *device_id* looks like a BLE MAC address.
Heuristic:
- Starts with ``literal:`` → BLE
- Matches ``XX:XX:XX:XX:XX:XX`` (6 colon-separated hex pairs) → BLE
- Everything else (``/dev/…``, ``COM…``) → Serial
"""
if device_id.lower().startswith("literal:"):
return True
parts = device_id.split(":")
if len(parts) == 6 and all(len(p) == 2 for p in parts):
try:
for p in parts:
int(p, 16)
return True
except ValueError:
pass
return False
TRANSPORT: str = "serial"
# Serial connection defaults.
SERIAL_BAUDRATE: int = 115200
SERIAL_CX_DELAY: float = 0.1
# BLE connection defaults.
# BLE pairing PIN for the MeshCore device (T1000e default: 123456).
# Used by the built-in D-Bus agent to answer pairing requests
# automatically — eliminates the need for bt-agent.service.
BLE_PIN: str = "123456"
# Maximum number of reconnect attempts after a disconnect.
RECONNECT_MAX_RETRIES: int = 5
# Base delay in seconds between reconnect attempts (multiplied by
# attempt number for linear backoff: 5s, 10s, 15s, 20s, 25s).
RECONNECT_BASE_DELAY: float = 5.0
# Interval in seconds between periodic contact refreshes from the device.
# Contacts are merged (new/changed contacts update the cache; contacts
# only present in cache are kept so offline nodes are preserved).
CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes
# ==============================================================================
# EXTERNAL LINKS (drawer menu)
# ==============================================================================
EXT_LINKS = [
('MeshCore', 'https://meshcore.co.uk'),
('Handleiding', 'https://www.pe1hvh.nl/pdf/MeshCore_Complete_Handleiding.pdf'),
('Netwerk kaart', 'https://meshcore.co.uk/map'),
('LocalMesh NL', 'https://www.localmesh.nl/'),
]
# ==============================================================================
# ARCHIVE / RETENTION
# ==============================================================================
# Retention period for archived messages (in days).
# Messages older than this are automatically removed during cleanup.
MESSAGE_RETENTION_DAYS: int = 30
# Retention period for RX log entries (in days).
# RX log entries older than this are automatically removed during cleanup.
RXLOG_RETENTION_DAYS: int = 7
# Retention period for contacts (in days).
# Contacts not seen for longer than this are removed from cache.
CONTACT_RETENTION_DAYS: int = 90
# BBS channel configuration is managed at runtime via BbsConfigStore.
# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json
# and edited through the BBS Settings panel in the GUI.

View File

@@ -0,0 +1,850 @@
"""
Main dashboard page for MeshCore GUI.
Thin orchestrator that owns the layout and the 500 ms update timer.
All visual content is delegated to individual panel classes in
:mod:`meshcore_gui.gui.panels`.
"""
import logging
from urllib.parse import urlencode
from nicegui import ui
from meshcore_gui import config
from meshcore_gui.core.protocols import SharedDataReader
from meshcore_gui.gui.panels import (
ActionsPanel,
BbsPanel,
ContactsPanel,
DevicePanel,
MapPanel,
MessagesPanel,
RoomServerPanel,
RxLogPanel,
)
from meshcore_gui.gui.archive_page import ArchivePage
from meshcore_gui.services.bbs_config_store import BbsConfigStore
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
from meshcore_gui.services.pin_store import PinStore
from meshcore_gui.services.room_password_store import RoomPasswordStore
# Suppress the harmless "Client has been deleted" warning that NiceGUI
# emits when a browser tab is refreshed while a ui.timer is active.
class _DeletedClientFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return 'Client has been deleted' not in record.getMessage()
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
# ── DOMCA Theme ──────────────────────────────────────────────────────
# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar.
# Dark/light variable sets switch via Quasar's body--dark / body--light classes.
_DOMCA_HEAD = '''
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#0d1f35">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="DOMCA">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ── DOMCA theme variables (dark) ── */
body.body--dark {
--bg: #0A1628;
--grid: #0077B6; --grid-op: 0.15;
--mesh-bg: #48CAE4; --mesh-bg-op: 0.08;
--line: #0077B6; --line-op: 0.6;
--wave: #48CAE4; --node: #00B4D8; --node-center: #CAF0F8;
--hub-text: #0A1628; --outer: #0077B6;
--title: #48CAE4; --subtitle: #48CAE4;
--tagline: #90E0EF; --tag-op: 0.5;
--badge-stroke: #0077B6; --badge-text: #48CAE4;
--callsign: #0077B6;
}
/* ── DOMCA theme variables (light) ── */
body.body--light {
--bg: #FFFFFF;
--grid: #023E8A; --grid-op: 0.04;
--mesh-bg: #0077B6; --mesh-bg-op: 0.05;
--line: #0096C7; --line-op: 0.35;
--wave: #0096C7; --node: #0077B6; --node-center: #FFFFFF;
--hub-text: #FFFFFF; --outer: #0096C7;
--title: #0077B6; --subtitle: #0077B6;
--tagline: #0096C7; --tag-op: 0.4;
--badge-stroke: #0077B6; --badge-text: #0077B6;
--callsign: #0096C7;
}
/* ── DOMCA page background ── */
body.body--dark { background: #0A1628 !important; }
body.body--light { background: #f4f8fb !important; }
body.body--dark .q-page { background: #0A1628 !important; }
body.body--light .q-page { background: #f4f8fb !important; }
/* ── DOMCA header ── */
body.body--dark .q-header { background: #0d1f35 !important; }
body.body--light .q-header { background: #0077B6 !important; }
/* ── DOMCA drawer — distinct from page background ── */
body.body--dark .domca-drawer { background: #0f2340 !important; border-right: 1px solid rgba(0,119,182,0.25) !important; }
body.body--light .domca-drawer { background: rgba(244,248,251,0.97) !important; }
.domca-drawer .q-btn__content { justify-content: flex-start !important; }
/* ── DOMCA cards — dark mode readable ── */
body.body--dark .q-card {
background: #112240 !important;
color: #e0f0f8 !important;
border: 1px solid rgba(0,119,182,0.15) !important;
}
body.body--dark .q-card .text-gray-600 { color: #48CAE4 !important; }
body.body--dark .q-card .text-gray-500 { color: #8badc4 !important; }
body.body--dark .q-card .text-gray-400 { color: #6a8fa8 !important; }
body.body--dark .q-card .text-xs { color: #c0dce8 !important; }
body.body--dark .q-card .text-sm { color: #d0e8f2 !important; }
body.body--dark .q-card .text-red-400 { color: #f87171 !important; }
/* ── Dark mode: message area, inputs, tables ── */
body.body--dark .bg-gray-50 { background: #0c1a2e !important; color: #c0dce8 !important; }
body.body--dark .bg-gray-100 { background: #152a45 !important; }
body.body--dark .hover\\:bg-gray-100:hover { background: #1a3352 !important; }
body.body--dark .hover\\:bg-blue-50:hover { background: #0d2a4a !important; }
body.body--dark .bg-yellow-50 { background: rgba(72,202,228,0.06) !important; }
body.body--dark .q-field__control { background: #0c1a2e !important; color: #e0f0f8 !important; }
body.body--dark .q-field__native { color: #e0f0f8 !important; }
body.body--dark .q-field__label { color: #8badc4 !important; }
body.body--dark .q-table { background: #112240 !important; color: #c0dce8 !important; }
body.body--dark .q-table thead th { color: #48CAE4 !important; }
body.body--dark .q-table tbody td { color: #c0dce8 !important; }
body.body--dark .q-checkbox__label { color: #c0dce8 !important; }
body.body--dark .q-btn--flat:not(.domca-menu-btn):not(.domca-sub-btn) { color: #48CAE4 !important; }
body.body--dark .q-separator { background: rgba(0,119,182,0.2) !important; }
/* ── DOMCA menu link styling ── */
body.body--dark .domca-menu-btn { color: #8badc4 !important; }
body.body--dark .domca-menu-btn:hover { color: #48CAE4 !important; }
body.body--light .domca-menu-btn { color: #3d6380 !important; }
body.body--light .domca-menu-btn:hover { color: #0077B6 !important; }
body.body--dark .domca-ext-link { color: #8badc4 !important; }
body.body--light .domca-ext-link { color: #3d6380 !important; }
/* ── DOMCA active menu item ── */
body.body--dark .domca-menu-active { color: #48CAE4 !important; background: rgba(72,202,228,0.1) !important; }
body.body--light .domca-menu-active { color: #0077B6 !important; background: rgba(0,119,182,0.08) !important; }
/* ── DOMCA submenu item styling ── */
body.body--dark .domca-sub-btn { color: #6a8fa8 !important; }
body.body--dark .domca-sub-btn:hover { color: #48CAE4 !important; }
body.body--light .domca-sub-btn { color: #5a7a90 !important; }
body.body--light .domca-sub-btn:hover { color: #0077B6 !important; }
/* ── DOMCA expansion panel in drawer ── */
.domca-drawer .q-expansion-item {
font-family: 'JetBrains Mono', monospace !important;
letter-spacing: 2px;
font-size: 0.8rem;
}
.domca-drawer .q-expansion-item .q-item {
padding: 0.35rem 1.2rem !important;
min-height: 32px !important;
}
.domca-drawer .q-expansion-item .q-expansion-item__content {
padding: 0 !important;
}
.domca-drawer .q-expansion-item + .q-expansion-item {
margin-top: 0 !important;
}
body.body--dark .domca-drawer .q-expansion-item { color: #8badc4 !important; }
body.body--dark .domca-drawer .q-expansion-item__container { background: transparent !important; }
body.body--dark .domca-drawer .q-item { color: #8badc4 !important; }
body.body--light .domca-drawer .q-expansion-item { color: #3d6380 !important; }
body.body--light .domca-drawer .q-item { color: #3d6380 !important; }
/* ── Landing page centering ── */
.domca-landing {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 64px);
padding: 0.5rem;
}
.domca-landing svg {
width: min(90vw, 800px);
height: auto;
display: block;
}
/* ── Panel container — responsive single column ── */
.domca-panel {
width: 100%;
max-width: 900px;
margin: 0 auto;
padding: 0.5rem;
}
/* ── Responsive heights — override fixed Tailwind heights in panels ── */
.domca-panel .h-40 { height: calc(100vh - 20rem) !important; min-height: 10rem; }
.domca-panel .h-32 { height: calc(100vh - 24rem) !important; min-height: 8rem; }
.domca-panel .h-72 { height: calc(100vh - 12rem) !important; min-height: 14rem; }
.domca-panel .h-96 { height: calc(100vh - 8rem) !important; min-height: 16rem; }
.domca-panel .max-h-48 { max-height: calc(100vh - 16rem) !important; min-height: 6rem; }
/* ── Allow narrow viewports down to 320px ── */
body, .q-layout, .q-page {
min-width: 0 !important;
}
.q-drawer { max-width: 80vw !important; width: 260px !important; min-width: 200px !important; }
/* ── Mobile optimisations ── */
@media (max-width: 640px) {
.domca-landing svg { width: 98vw; }
.domca-panel { padding: 0.25rem; }
.domca-panel .q-card { border-radius: 8px !important; }
}
@media (max-width: 400px) {
.domca-landing { padding: 0.25rem; }
.domca-landing svg { width: 100vw; }
.q-header { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
}
/* ── Footer label ── */
.domca-footer {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
letter-spacing: 2px;
opacity: 0.3;
}
/* ── Header text: icon-only on narrow viewports ── */
@media (max-width: 599px) {
.domca-header-text { display: none !important; }
}
</style>
'''
# ── Landing SVG loader ────────────────────────────────────────────────
# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign}
# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder
# when the file is missing.
def _load_landing_svg() -> str:
"""Load the landing page SVG from disk.
Returns:
SVG markup string with ``{callsign}`` replaced by the
configured operator callsign.
"""
path = config.LANDING_SVG_PATH
try:
raw = path.read_text(encoding="utf-8")
return raw.replace("{callsign}", config.OPERATOR_CALLSIGN)
except FileNotFoundError:
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 100">'
'<text x="200" y="55" text-anchor="middle" '
'font-family="\'JetBrains Mono\',monospace" font-size="14" '
f'fill="var(--title)">Landing SVG not found: {path.name}</text>'
'</svg>'
)
# ── Standalone menu items (no submenus) ──────────────────────────────
_STANDALONE_ITEMS = [
('\U0001f465', 'CONTACTS', 'contacts'),
('\U0001f5fa\ufe0f', 'MAP', 'map'),
('\U0001f4e1', 'DEVICE', 'device'),
('\u26a1', 'ACTIONS', 'actions'),
('\U0001f4ca', 'RX LOG', 'rxlog'),
('\U0001f4cb', 'BBS', 'bbs'),
]
_EXT_LINKS = config.EXT_LINKS
# ── Shared button styles ─────────────────────────────────────────────
_SUB_BTN_STYLE = (
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 1px; font-size: 0.72rem; "
"padding: 0.2rem 1.2rem 0.2rem 2.4rem"
)
_MENU_BTN_STYLE = (
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 2px; font-size: 0.8rem; "
"padding: 0.35rem 1.2rem"
)
class DashboardPage:
"""Main dashboard rendered at ``/``.
Args:
shared: SharedDataReader for data access and command dispatch.
"""
def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore) -> None:
self._shared = shared
self._pin_store = pin_store
self._room_password_store = room_password_store
# BBS service and config store (singletons shared with bot routing)
self._bbs_config_store = BbsConfigStore()
self._bbs_service = BbsService()
self._bbs_handler = BbsCommandHandler(
self._bbs_service, self._bbs_config_store
)
# Panels (created fresh on each render)
self._device: DevicePanel | None = None
self._contacts: ContactsPanel | None = None
self._map: MapPanel | None = None
self._messages: MessagesPanel | None = None
self._actions: ActionsPanel | None = None
self._rxlog: RxLogPanel | None = None
self._room_server: RoomServerPanel | None = None
self._bbs: BbsPanel | None = None
# Header status label
self._status_label = None
# Local first-render flag
self._initialized: bool = False
# Panel switching state (layout)
self._panel_containers: dict = {}
self._active_panel: str = 'landing'
self._drawer = None
self._menu_buttons: dict = {}
# Submenu containers (for dynamic channel/room items)
self._msg_sub_container = None
self._archive_sub_container = None
self._rooms_sub_container = None
self._last_channel_fingerprint = None
self._last_rooms_fingerprint = None
# Archive page reference (for inline channel switching)
self._archive_page: ArchivePage | None = None
# ------------------------------------------------------------------
# Public
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the complete dashboard layout and start the timer."""
self._initialized = False
# Reset fingerprints: render() creates new (empty) NiceGUI
# containers, so _update_submenus must rebuild into them even
# when the channel/room data hasn't changed since last session.
self._last_channel_fingerprint = None
self._last_rooms_fingerprint = None
# Create panel instances (UNCHANGED functional wiring)
put_cmd = self._shared.put_command
self._device = DevicePanel()
self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server)
self._map = MapPanel()
self._messages = MessagesPanel(put_cmd)
self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled)
self._rxlog = RxLogPanel()
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
# Inject DOMCA theme (fonts + CSS variables)
ui.add_head_html(_DOMCA_HEAD)
# Default to dark mode (DOMCA theme)
dark = ui.dark_mode(True)
dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value))
self._map.set_ui_dark_mode(dark.value)
# ── Left Drawer (must be created before header for Quasar) ────
self._drawer = ui.left_drawer(value=False, bordered=True).classes(
'domca-drawer'
).style('padding: 0')
with self._drawer:
# DOMCA branding (clickable → landing page)
with ui.column().style('padding: 0.2rem 1.2rem 0'):
ui.button(
'DOMCA',
on_click=lambda: self._navigate_panel('landing'),
).props('flat no-caps').style(
"font-family: 'Exo 2', sans-serif; font-size: 1.4rem; "
"font-weight: 800; color: var(--title); letter-spacing: 4px; "
"margin-bottom: 0.3rem; padding: 0"
)
self._menu_buttons = {}
# ── 💬 MESSAGES (expandable with channel submenu) ──────
with ui.expansion(
'\U0001f4ac MESSAGES', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._msg_sub_container = ui.column().classes('w-full gap-0')
with self._msg_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('messages', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('messages', channel='DM')
)
# Dynamic channel items populated by _update_submenus
# ── 🏠 ROOMS (expandable with room submenu) ───────────
with ui.expansion(
'\U0001f3e0 ROOMS', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._rooms_sub_container = ui.column().classes('w-full gap-0')
with self._rooms_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('rooms')
)
# Pre-populate from persisted rooms
for entry in self._room_password_store.get_rooms():
short = entry.name or entry.pubkey[:12]
self._make_sub_btn(
f'\U0001f3e0 {short}',
lambda: self._navigate_panel('rooms'),
)
# ── 📚 ARCHIVE (expandable with channel submenu) ──────
with ui.expansion(
'\U0001f4da ARCHIVE', icon=None, value=False,
).props('dense header-class="q-pa-none"').classes('w-full'):
self._archive_sub_container = ui.column().classes('w-full gap-0')
with self._archive_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('archive', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('archive', channel='DM')
)
# Dynamic channel items populated by _update_submenus
ui.separator().classes('my-1')
# ── Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG)
for icon, label, panel_id in _STANDALONE_ITEMS:
btn = ui.button(
f'{icon} {label}',
on_click=lambda pid=panel_id: self._navigate_panel(pid),
).props('flat no-caps align=left').classes(
'w-full justify-start domca-menu-btn'
).style(_MENU_BTN_STYLE)
self._menu_buttons[panel_id] = btn
ui.separator().classes('my-2')
# External links (same as domca.nl navigation)
with ui.column().style('padding: 0 1.2rem'):
for label, url in _EXT_LINKS:
ui.link(label, url, new_tab=True).classes(
'domca-ext-link'
).style(
"font-family: 'JetBrains Mono', monospace; "
"letter-spacing: 2px; font-size: 0.72rem; "
"text-decoration: none; opacity: 0.6; "
"display: block; padding: 0.35rem 0"
)
# Footer in drawer
ui.space()
ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem')
# ── Header ────────────────────────────────────────────────
with ui.header().classes('items-center px-4 py-2 shadow-md'):
menu_btn = ui.button(
icon='menu',
on_click=lambda: self._drawer.toggle(),
).props('flat round dense color=white')
# Swap icon: menu ↔ close
self._drawer.on_value_change(
lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}')
)
ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes(
'text-lg font-bold ml-2 domca-header-text'
).style("font-family: 'JetBrains Mono', monospace")
# Transport mode badge
_is_ble = config.TRANSPORT == "ble"
_badge_icon = '🔵' if _is_ble else '🟢'
_badge_label = 'BLE' if _is_ble else 'Serial'
ui.label(f'{_badge_icon} {_badge_label}').classes(
'text-xs ml-2 domca-header-text'
).style(
"font-family: 'JetBrains Mono', monospace; "
"opacity: 0.65; letter-spacing: 1px"
)
ui.space()
_initial_status = self._shared.get_snapshot().get('status', 'Starting...')
self._status_label = ui.label(_initial_status).classes(
'text-sm opacity-70 domca-header-text'
)
ui.button(
icon='brightness_6',
on_click=lambda: dark.toggle(),
).props('flat round dense color=white').tooltip('Toggle dark / light')
# ── Main Content Area ─────────────────────────────────────
self._panel_containers = {}
# Landing page (SVG splash from file — visible by default)
landing = ui.column().classes('domca-landing w-full')
with landing:
ui.html(_load_landing_svg())
self._panel_containers['landing'] = landing
# Panel containers (hidden by default, shown on menu click)
panel_defs = [
('messages', self._messages),
('contacts', self._contacts),
('map', self._map),
('device', self._device),
('actions', self._actions),
('rxlog', self._rxlog),
('rooms', self._room_server),
('bbs', self._bbs),
]
for panel_id, panel_obj in panel_defs:
container = ui.column().classes('domca-panel')
container.set_visibility(False)
with container:
panel_obj.render()
self._panel_containers[panel_id] = container
# Archive panel (inline — replaces separate /archive page)
archive_container = ui.column().classes('domca-panel')
archive_container.set_visibility(False)
with archive_container:
self._archive_page = ArchivePage(self._shared)
self._archive_page.render()
self._panel_containers['archive'] = archive_container
self._active_panel = 'landing'
# Start update timer
self._apply_url_state()
ui.timer(0.5, self._update_ui)
# ------------------------------------------------------------------
# Submenu button helper (layout only)
# ------------------------------------------------------------------
@staticmethod
def _make_sub_btn(label: str, on_click) -> ui.button:
"""Create a submenu button in the drawer."""
return ui.button(
label,
on_click=on_click,
).props('flat no-caps align=left').classes(
'w-full justify-start domca-sub-btn'
).style(_SUB_BTN_STYLE)
# ------------------------------------------------------------------
# Dynamic submenu updates (layout — called from _update_ui)
# ------------------------------------------------------------------
def _update_submenus(self, data: dict) -> None:
"""Rebuild channel/room submenu items when data changes.
Only the dynamic items are rebuilt; the container is cleared and
ALL items (static + dynamic) are re-rendered.
"""
# ── Channel submenus (Messages + Archive) ──
channels = data.get('channels', [])
ch_fingerprint = tuple((ch['idx'], ch['name']) for ch in channels)
if ch_fingerprint != self._last_channel_fingerprint and channels:
self._last_channel_fingerprint = ch_fingerprint
# Rebuild Messages submenu
if self._msg_sub_container:
self._msg_sub_container.clear()
with self._msg_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('messages', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('messages', channel='DM')
)
for ch in channels:
idx = ch['idx']
name = ch['name']
self._make_sub_btn(
f"[{idx}] {name}",
lambda i=idx: self._navigate_panel('messages', channel=i),
)
# Rebuild Archive submenu
if self._archive_sub_container:
self._archive_sub_container.clear()
with self._archive_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('archive', channel=None)
)
self._make_sub_btn(
'DM', lambda: self._navigate_panel('archive', channel='DM')
)
for ch in channels:
idx = ch['idx']
name = ch['name']
self._make_sub_btn(
f"[{idx}] {name}",
lambda n=name: self._navigate_panel('archive', channel=n),
)
# ── Room submenus ──
rooms = self._room_password_store.get_rooms()
rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms)
if rooms_fingerprint != self._last_rooms_fingerprint:
self._last_rooms_fingerprint = rooms_fingerprint
if self._rooms_sub_container:
self._rooms_sub_container.clear()
with self._rooms_sub_container:
self._make_sub_btn(
'ALL', lambda: self._navigate_panel('rooms')
)
for entry in rooms:
short = entry.name or entry.pubkey[:12]
self._make_sub_btn(
f'\U0001f3e0 {short}',
lambda: self._navigate_panel('rooms'),
)
# ------------------------------------------------------------------
# Panel switching (layout helper — no functional logic)
# ------------------------------------------------------------------
def _apply_url_state(self) -> None:
"""Apply panel selection from URL query params on first render."""
try:
params = ui.context.client.request.query_params
except Exception:
return
panel = params.get('panel') or 'landing'
channel = params.get('channel')
if panel not in self._panel_containers:
panel = 'landing'
channel = None
if panel == 'messages':
if channel is None or channel.lower() == 'all':
channel = None
elif channel.upper() == 'DM':
channel = 'DM'
else:
channel = int(channel) if channel.isdigit() else None
elif panel == 'archive':
if channel is None or channel.lower() == 'all':
channel = None
elif channel.upper() == 'DM':
channel = 'DM'
else:
channel = None
self._show_panel(panel, channel)
def _build_panel_url(self, panel_id: str, channel=None) -> str:
params = {'panel': panel_id}
if channel is not None:
params['channel'] = str(channel)
return '/?' + urlencode(params)
def _navigate_panel(self, panel_id: str, channel=None) -> None:
"""Navigate with panel id in the URL so browser back restores state."""
ui.navigate.to(self._build_panel_url(panel_id, channel))
def _show_panel(self, panel_id: str, channel=None) -> None:
"""Show the selected panel, hide all others, close the drawer.
Args:
panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms').
channel: Optional channel filter.
For messages: None=all, 'DM'=DM only, int=channel idx.
For archive: None=all, 'DM'=DM only, str=channel name.
"""
for pid, container in self._panel_containers.items():
container.set_visibility(pid == panel_id)
self._active_panel = panel_id
# Apply channel filter to messages panel
if panel_id == 'messages' and self._messages:
self._messages.set_active_channel(channel)
# Apply channel filter to archive panel
if panel_id == 'archive' and self._archive_page:
self._archive_page.set_channel_filter(channel)
self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
# Update active menu highlight (standalone buttons only)
for pid, btn in self._menu_buttons.items():
if pid == panel_id:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# Close drawer after selection
if self._drawer:
self._drawer.hide()
def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
"""Refresh only the currently visible panel.
This is used directly after a panel switch so the user does not
need to wait for the next 500 ms dashboard tick.
"""
data = self._shared.get_snapshot()
if data.get('channels'):
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
if self._active_panel == 'device':
self._device.update(data)
elif self._active_panel == 'map':
if force_map_center:
data['force_center'] = True
self._map.update(data)
elif self._active_panel == 'actions':
self._actions.update(data)
elif self._active_panel == 'contacts':
self._contacts.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# ------------------------------------------------------------------
# Room Server callback (from ContactsPanel)
# ------------------------------------------------------------------
def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None:
"""Handle adding a Room Server from the contacts panel.
Delegates to the RoomServerPanel which persists the entry,
creates the UI card and sends the login command.
"""
if self._room_server:
self._room_server.add_room(pubkey, name, password)
# ------------------------------------------------------------------
# Timer-driven UI update
# ------------------------------------------------------------------
def _update_ui(self) -> None:
try:
if not self._status_label:
return
# Atomic snapshot + flag clear: eliminates race condition
# where worker sets channels_updated between separate
# get_snapshot() and clear_update_flags() calls.
data = self._shared.get_snapshot_and_clear_flags()
is_first = not self._initialized
# Mark initialised immediately — even if a panel update
# crashes below, we must NOT retry the full first-render
# path every 500 ms (that causes the infinite rebuild).
if is_first:
self._initialized = True
# Always update status
self._status_label.text = data['status']
# Channel-dependent drawer/submenu state may stay global.
# The helpers below already contain equality checks, so this
# remains cheap while keeping navigation consistent.
if data['channels']:
self._messages.update_filters(data)
self._messages.update_channel_options(data['channels'])
self._update_submenus(data)
if self._active_panel == 'device':
if data['device_updated'] or is_first:
self._device.update(data)
elif self._active_panel == 'map':
# Keep sending snapshots while the map panel is active.
# The browser runtime coalesces pending payloads, so only
# the newest snapshot is applied.
self._map.update(data)
elif self._active_panel == 'actions':
if data['channels_updated'] or is_first:
self._actions.update(data)
elif self._active_panel == 'contacts':
if data['contacts_updated'] or is_first:
self._contacts.update(data)
elif self._active_panel == 'messages':
self._messages.update(
data,
self._messages.channel_filters,
self._messages.last_channels,
room_pubkeys=(
self._room_server.get_room_pubkeys()
if self._room_server else None
),
)
elif self._active_panel == 'rooms':
self._room_server.update(data)
elif self._active_panel == 'rxlog':
if data['rxlog_updated'] or is_first:
self._rxlog.update(data)
elif self._active_panel == 'bbs':
if self._bbs:
self._bbs.update(data)
# Signal worker that GUI is ready for data
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:
import traceback
print(f"GUI update error: {e}")
traceback.print_exc()

View File

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

View File

@@ -0,0 +1,614 @@
"""BBS panel -- board-based Bulletin Board System viewer and configuration."""
import re
from typing import Callable, Dict, List, Optional
from nicegui import ui
from meshcore_gui.config import debug_print
from meshcore_gui.services.bbs_config_store import (
BbsBoard,
BbsConfigStore,
DEFAULT_CATEGORIES,
DEFAULT_RETENTION_HOURS,
)
from meshcore_gui.services.bbs_service import BbsMessage, BbsService
from meshcore_gui.core.protocols import SharedDataReadAndLookup
def _slug(name: str) -> str:
"""Convert a board name to a safe id slug.
Args:
name: Human-readable board name.
Returns:
Lowercase alphanumeric + underscore string.
"""
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board"
# ---------------------------------------------------------------------------
# Main BBS panel (message view only — settings live on /bbs-settings)
# ---------------------------------------------------------------------------
class BbsPanel:
"""BBS panel: board selector, category buttons, message list and post form.
Settings are on a separate page (/bbs-settings), reachable via the
gear icon in the panel header.
Args:
put_command: Callable to enqueue a command dict for the worker.
bbs_service: Shared BbsService instance.
config_store: Shared BbsConfigStore instance.
"""
def __init__(
self,
put_command: Callable[[Dict], None],
bbs_service: BbsService,
config_store: BbsConfigStore,
) -> None:
self._put_command = put_command
self._service = bbs_service
self._config_store = config_store
# Active view state
self._active_board: Optional[BbsBoard] = None
self._active_category: Optional[str] = None
# UI refs
self._board_btn_row = None
self._category_btn_row = None
self._msg_list_container = None
self._post_region_row = None
self._post_region_select = None
self._post_category_select = None
self._text_input = None
# Button refs for active highlight
self._board_buttons: Dict[str, object] = {}
self._category_buttons: Dict[str, object] = {}
# Cached device channels (updated by update())
self._device_channels: List[Dict] = []
self._last_ch_fingerprint: tuple = ()
# ------------------------------------------------------------------
# Render
# ------------------------------------------------------------------
def render(self) -> None:
"""Build the BBS message view panel layout."""
with ui.card().classes('w-full'):
# Header row with gear icon
with ui.row().classes('w-full items-center justify-between'):
ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600')
ui.button(
icon='settings',
on_click=lambda: ui.navigate.to('/bbs-settings'),
).props('flat round dense').tooltip('BBS Settings')
# Board selector row
self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
with self._board_btn_row:
ui.label('No active boards — open Settings to enable a channel.').classes(
'text-xs text-gray-400 italic'
)
ui.separator()
# Category filter row (clickable buttons, replaces dropdown)
self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
with self._category_btn_row:
ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
ui.separator()
# Message list
self._msg_list_container = ui.column().classes(
'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2'
).style('max-height: calc(100vh - 24rem); min-height: 8rem')
ui.separator()
# Post row — keep selects for sending
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
ui.label('Post:').classes('text-sm text-gray-600')
self._post_region_row = ui.row().classes('items-center gap-1')
with self._post_region_row:
self._post_region_select = ui.select(
options=[], label='Region',
).classes('text-xs').style('min-width: 110px')
self._post_category_select = ui.select(
options=[], label='Category',
).classes('text-xs').style('min-width: 110px')
self._text_input = ui.input(
placeholder='Message text...',
).classes('flex-grow text-sm min-w-0')
ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
# Initial render
self._rebuild_board_buttons()
# ------------------------------------------------------------------
# Board selector
# ------------------------------------------------------------------
def _rebuild_board_buttons(self) -> None:
"""Rebuild board selector buttons from current config."""
if not self._board_btn_row:
return
self._board_btn_row.clear()
self._board_buttons = {}
boards = self._config_store.get_boards()
with self._board_btn_row:
if not boards:
ui.label('No active boards — open Settings to enable a channel.').classes(
'text-xs text-gray-400 italic'
)
return
for board in boards:
btn = ui.button(
board.name,
on_click=lambda b=board: self._select_board(b),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._board_buttons[board.id] = btn
ids = [b.id for b in boards]
if boards and (self._active_board is None or self._active_board.id not in ids):
self._select_board(boards[0])
elif self._active_board and self._active_board.id in self._board_buttons:
self._board_buttons[self._active_board.id].classes('domca-menu-active')
def _select_board(self, board: BbsBoard) -> None:
"""Activate a board and rebuild category buttons.
Args:
board: Board to activate.
"""
self._active_board = board
self._active_category = None
# Update board button highlights
for bid, btn in self._board_buttons.items():
if bid == board.id:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# Update post selects
if self._post_region_row:
self._post_region_row.set_visibility(bool(board.regions))
if self._post_region_select:
self._post_region_select.options = board.regions
self._post_region_select.value = board.regions[0] if board.regions else None
if self._post_category_select:
self._post_category_select.options = board.categories
self._post_category_select.value = board.categories[0] if board.categories else None
self._rebuild_category_buttons()
self._refresh_messages()
# ------------------------------------------------------------------
# Category buttons
# ------------------------------------------------------------------
def _rebuild_category_buttons(self) -> None:
"""Rebuild clickable category filter buttons for the active board."""
if not self._category_btn_row:
return
self._category_btn_row.clear()
self._category_buttons = {}
if self._active_board is None:
with self._category_btn_row:
ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
return
with self._category_btn_row:
# "All" button
all_btn = ui.button(
'ALL',
on_click=lambda: self._on_category_filter(None),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._category_buttons['__all__'] = all_btn
for cat in self._active_board.categories:
btn = ui.button(
cat,
on_click=lambda c=cat: self._on_category_filter(c),
).props('flat no-caps').classes('text-xs domca-menu-btn')
self._category_buttons[cat] = btn
# Highlight the current active category
self._update_category_highlight()
def _on_category_filter(self, category: Optional[str]) -> None:
"""Handle category button click.
Args:
category: Category string, or None for all.
"""
self._active_category = category
self._update_category_highlight()
self._refresh_messages()
def _update_category_highlight(self) -> None:
"""Apply domca-menu-active to the currently selected category button."""
active_key = self._active_category if self._active_category else '__all__'
for key, btn in self._category_buttons.items():
if key == active_key:
btn.classes('domca-menu-active', remove='')
else:
btn.classes(remove='domca-menu-active')
# ------------------------------------------------------------------
# Message list
# ------------------------------------------------------------------
def _refresh_messages(self) -> None:
if not self._msg_list_container:
return
self._msg_list_container.clear()
with self._msg_list_container:
if self._active_board is None:
ui.label('Select a board above.').classes('text-xs text-gray-400 italic')
return
if not self._active_board.channels:
ui.label('No channels assigned to this board.').classes(
'text-xs text-gray-400 italic'
)
return
messages = self._service.get_all_messages(
channels=self._active_board.channels,
region=None,
category=self._active_category,
)
if not messages:
ui.label('No messages.').classes('text-xs text-gray-400 italic')
return
for msg in messages:
self._render_message_row(msg)
def _render_message_row(self, msg: BbsMessage) -> None:
ts = msg.timestamp[:16].replace('T', ' ')
region_label = f' [{msg.region}]' if msg.region else ''
header = f'{ts} {msg.sender} [{msg.category}]{region_label}'
with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'):
ui.label(header).classes('text-xs text-gray-500').style(
'word-break: break-all; overflow-wrap: break-word'
)
ui.label(msg.text).classes('text-sm').style(
'word-break: break-word; overflow-wrap: break-word'
)
# ------------------------------------------------------------------
# Post
# ------------------------------------------------------------------
def _on_post(self) -> None:
if self._active_board is None:
ui.notify('Select a board first.', type='warning')
return
if not self._active_board.channels:
ui.notify('No channels assigned to this board.', type='warning')
return
text = (self._text_input.value or '').strip() if self._text_input else ''
if not text:
ui.notify('Message text cannot be empty.', type='warning')
return
category = (
self._post_category_select.value if self._post_category_select
else (self._active_board.categories[0] if self._active_board.categories else '')
)
if not category:
ui.notify('Please select a category.', type='warning')
return
region = ''
if self._active_board.regions and self._post_region_select:
region = self._post_region_select.value or ''
target_channel = self._active_board.channels[0]
msg = BbsMessage(
channel=target_channel,
region=region, category=category,
sender='Me', sender_key='', text=text,
)
self._service.post_message(msg)
region_part = f'{region} ' if region else ''
mesh_text = f'!bbs post {region_part}{category} {text}'
self._put_command({
'action': 'send_message',
'channel': target_channel,
'text': mesh_text,
})
debug_print(
f'BBS panel: posted to board={self._active_board.id} '
f'ch={target_channel} {mesh_text[:60]}'
)
if self._text_input:
self._text_input.value = ''
self._refresh_messages()
ui.notify('Message posted.', type='positive')
# ------------------------------------------------------------------
# External update hook
# ------------------------------------------------------------------
def update(self, data: Dict) -> None:
"""Called by the dashboard timer with the SharedData snapshot.
Args:
data: SharedData snapshot dict.
"""
device_channels = data.get('channels', [])
fingerprint = tuple(
(ch.get('idx', 0), ch.get('name', '')) for ch in device_channels
)
if fingerprint != self._last_ch_fingerprint:
self._last_ch_fingerprint = fingerprint
self._device_channels = device_channels
self._rebuild_board_buttons()
# ---------------------------------------------------------------------------
# Separate settings page (/bbs-settings)
# ---------------------------------------------------------------------------
class BbsSettingsPage:
"""Standalone BBS settings page, registered at /bbs-settings.
Follows the same pattern as RoutePage: one instance, render() called
per page load.
Args:
shared: SharedData instance (for device channel list).
config_store: BbsConfigStore instance.
"""
def __init__(
self,
shared: SharedDataReadAndLookup,
config_store: BbsConfigStore,
) -> None:
self._shared = shared
self._config_store = config_store
self._device_channels: List[Dict] = []
self._boards_settings_container = None
def render(self) -> None:
"""Render the BBS settings page."""
from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy — avoids circular import
data = self._shared.get_snapshot()
self._device_channels = data.get('channels', [])
ui.page_title('BBS Settings')
ui.add_head_html(_DOMCA_HEAD)
ui.dark_mode(True)
with ui.header().classes('items-center px-4 py-2 shadow-md'):
ui.button(
icon='arrow_back',
on_click=lambda: ui.run_javascript('window.history.back()'),
).props('flat round dense color=white').tooltip('Back')
ui.label('📋 BBS Settings').classes(
'text-lg font-bold domca-header-text'
).style("font-family: 'JetBrains Mono', monospace")
ui.space()
with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'):
with ui.card().classes('w-full'):
ui.label('BBS Settings').classes('font-bold text-gray-600')
ui.separator()
self._boards_settings_container = ui.column().classes('w-full gap-3')
with self._boards_settings_container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_all()
# ------------------------------------------------------------------
# Settings rendering
# ------------------------------------------------------------------
def _render_all(self) -> None:
"""Render all channel rows and the advanced section."""
for ch in self._device_channels:
self._render_channel_settings_row(ch)
ui.separator()
with ui.expansion('Advanced', value=False).classes('w-full').props('dense'):
ui.label('Regions and key list per channel').classes(
'text-xs text-gray-500 pb-1'
)
advanced_any = False
for ch in self._device_channels:
idx = ch.get('idx', ch.get('index', 0))
board = self._config_store.get_board(f'ch{idx}')
if board is not None:
self._render_channel_advanced_row(ch, board)
advanced_any = True
if not advanced_any:
ui.label(
'Enable at least one channel to see advanced options.'
).classes('text-xs text-gray-400 italic')
def _rebuild(self) -> None:
"""Clear and re-render the settings container in-place."""
if not self._boards_settings_container:
return
self._boards_settings_container.clear()
with self._boards_settings_container:
if not self._device_channels:
ui.label('Connect device to see channels.').classes(
'text-xs text-gray-400 italic'
)
else:
self._render_all()
def _render_channel_settings_row(self, ch: Dict) -> None:
"""Render the standard settings row for a single device channel.
Args:
ch: Device channel dict with 'idx'/'index' and 'name' keys.
"""
idx = ch.get('idx', ch.get('index', 0))
ch_name = ch.get('name', f'Ch {idx}')
board_id = f'ch{idx}'
board = self._config_store.get_board(board_id)
is_active = board is not None
cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES)
retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS)
with ui.card().classes('w-full p-2'):
with ui.row().classes('w-full items-center justify-between'):
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
active_toggle = ui.toggle(
{True: '● Active', False: '○ Off'},
value=is_active,
).classes('text-xs')
with ui.row().classes('w-full items-center gap-2 mt-1'):
ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
with ui.row().classes('w-full items-center gap-2 mt-1'):
ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
retention_input = ui.input(value=retention_value).classes('text-xs').style(
'max-width: 80px'
)
ui.label('hrs').classes('text-xs text-gray-600')
def _save(
bid=board_id,
bname=ch_name,
bidx=idx,
tog=active_toggle,
ci=cats_input,
ri=retention_input,
) -> None:
if tog.value:
existing = self._config_store.get_board(bid)
categories = [
c.strip().upper()
for c in (ci.value or '').split(',') if c.strip()
] or list(DEFAULT_CATEGORIES)
try:
ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
except ValueError:
ret_hours = DEFAULT_RETENTION_HOURS
extra_channels = (
[c for c in existing.channels if c != bidx]
if existing else []
)
updated = BbsBoard(
id=bid,
name=bname,
channels=[bidx] + extra_channels,
categories=categories,
regions=existing.regions if existing else [],
retention_hours=ret_hours,
allowed_keys=existing.allowed_keys if existing else [],
)
self._config_store.set_board(updated)
debug_print(f'BBS settings: channel {bid} saved')
ui.notify(f'{bname} saved.', type='positive')
else:
self._config_store.delete_board(bid)
debug_print(f'BBS settings: channel {bid} disabled')
ui.notify(f'{bname} disabled.', type='warning')
self._rebuild()
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1')
def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None:
"""Render the advanced settings block for a single active channel.
Args:
ch: Device channel dict.
board: Existing BbsBoard for this channel.
"""
idx = ch.get('idx', ch.get('index', 0))
ch_name = ch.get('name', f'Ch {idx}')
board_id = f'ch{idx}'
with ui.column().classes('w-full gap-1 py-2'):
ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
regions_input = ui.input(
label='Regions (comma-separated)',
value=', '.join(board.regions),
).classes('w-full text-xs')
wl_input = ui.input(
label='Allowed keys (empty = everyone on the channel)',
value=', '.join(board.allowed_keys),
).classes('w-full text-xs')
other_channels = [
c for c in self._device_channels
if c.get('idx', c.get('index', 0)) != idx
]
ch_checks: Dict[int, object] = {}
if other_channels:
ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1')
with ui.row().classes('flex-wrap gap-2'):
for other_ch in other_channels:
other_idx = other_ch.get('idx', other_ch.get('index', 0))
other_name = other_ch.get('name', f'Ch {other_idx}')
cb = ui.checkbox(
f'[{other_idx}] {other_name}',
value=other_idx in board.channels,
).classes('text-xs')
ch_checks[other_idx] = cb
def _save_adv(
bid=board_id,
bidx=idx,
bname=ch_name,
ri=regions_input,
wli=wl_input,
cc=ch_checks,
) -> None:
existing = self._config_store.get_board(bid)
if existing is None:
ui.notify('Enable this channel first.', type='warning')
return
regions = [
r.strip() for r in (ri.value or '').split(',') if r.strip()
]
allowed_keys = [
k.strip() for k in (wli.value or '').split(',') if k.strip()
]
combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value]
updated = BbsBoard(
id=bid,
name=bname,
channels=combined,
categories=existing.categories,
regions=regions,
retention_hours=existing.retention_hours,
allowed_keys=allowed_keys,
)
self._config_store.set_board(updated)
debug_print(f'BBS settings (advanced): {bid} saved')
ui.notify(f'{bname} saved.', type='positive')
self._rebuild()
ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1')
ui.separator()

View File

@@ -0,0 +1,302 @@
"""
BBS board configuration store for MeshCore GUI.
Persists BBS board configuration to
``~/.meshcore-gui/bbs/bbs_config.json``.
A **board** groups one or more MeshCore channel indices into a single
bulletin board. Messages posted on any of the board's channels are
visible in the board view. This supports two usage patterns:
- One board per channel (classic per-channel BBS)
- One board spanning multiple channels (shared bulletin board)
Config version history
~~~~~~~~~~~~~~~~~~~~~~
v1 — per-channel config (list of channels with enabled flag).
v2 — board-based config (list of boards, each with a channels list).
Automatic migration from v1 on first load.
Thread safety
~~~~~~~~~~~~~
All public methods acquire an internal ``threading.Lock``.
"""
import json
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from meshcore_gui.config import debug_print
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs"
BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json"
CONFIG_VERSION: int = 2
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"]
DEFAULT_REGIONS: List[str] = []
DEFAULT_RETENTION_HOURS: int = 48
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class BbsBoard:
"""A BBS board grouping one or more MeshCore channels.
Attributes:
id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``).
name: Human-readable board name.
channels: List of MeshCore channel indices assigned to this board.
categories: Valid category tags for this board.
regions: Optional region tags; empty = no region filtering.
retention_hours: Message retention period in hours.
allowed_keys: Sender public key whitelist (empty = all allowed).
"""
id: str
name: str
channels: List[int] = field(default_factory=list)
categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
regions: List[str] = field(default_factory=list)
retention_hours: int = DEFAULT_RETENTION_HOURS
allowed_keys: List[str] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Serialise to a JSON-compatible dict."""
return {
"id": self.id,
"name": self.name,
"channels": list(self.channels),
"categories": list(self.categories),
"regions": list(self.regions),
"retention_hours": self.retention_hours,
"allowed_keys": list(self.allowed_keys),
}
@staticmethod
def from_dict(d: Dict) -> "BbsBoard":
"""Deserialise from a config dict."""
return BbsBoard(
id=d.get("id", ""),
name=d.get("name", ""),
channels=list(d.get("channels", [])),
categories=list(d.get("categories", DEFAULT_CATEGORIES)),
regions=list(d.get("regions", [])),
retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)),
allowed_keys=list(d.get("allowed_keys", [])),
)
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
class BbsConfigStore:
"""Persistent store for BBS board configuration.
Args:
config_path: Path to the JSON config file.
Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``.
"""
def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None:
self._path = config_path
self._lock = threading.Lock()
self._boards: List[BbsBoard] = []
self._load()
# ------------------------------------------------------------------
# Load / save
# ------------------------------------------------------------------
def _load(self) -> None:
"""Load config from disk; migrate v1 → v2 if needed."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._save_unlocked()
debug_print("BBS config: created new config file (v2)")
return
try:
raw = self._path.read_text(encoding="utf-8")
data = json.loads(raw)
version = data.get("version", 1)
if version == CONFIG_VERSION:
self._boards = [
BbsBoard.from_dict(b) for b in data.get("boards", [])
]
debug_print(f"BBS config: loaded {len(self._boards)} boards")
elif version == 1:
# Migrate: each v1 channel → one board
self._boards = self._migrate_v1(data.get("channels", []))
self._save_unlocked()
debug_print(
f"BBS config: migrated v1 → v2 ({len(self._boards)} boards)"
)
else:
debug_print(
f"BBS config: unknown version {version}, using empty config"
)
except (json.JSONDecodeError, OSError) as exc:
debug_print(f"BBS config: load error ({exc}), using empty config")
@staticmethod
def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]:
"""Convert v1 per-channel entries to v2 boards.
Only enabled channels are migrated.
Args:
v1_channels: List of v1 channel config dicts.
Returns:
List of ``BbsBoard`` instances.
"""
boards = []
for ch in v1_channels:
if not ch.get("enabled", False):
continue
idx = ch.get("channel", 0)
board_id = f"ch{idx}"
boards.append(BbsBoard(
id=board_id,
name=ch.get("name", f"Channel {idx}"),
channels=[idx],
categories=list(ch.get("categories", DEFAULT_CATEGORIES)),
regions=list(ch.get("regions", [])),
retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)),
allowed_keys=list(ch.get("allowed_keys", [])),
))
return boards
def _save_unlocked(self) -> None:
"""Write config to disk. MUST be called with self._lock held."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
data = {
"version": CONFIG_VERSION,
"boards": [b.to_dict() for b in self._boards],
}
tmp = self._path.with_suffix(".tmp")
tmp.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(self._path)
def save(self) -> None:
"""Flush current configuration to disk."""
with self._lock:
self._save_unlocked()
# ------------------------------------------------------------------
# Board queries
# ------------------------------------------------------------------
def get_boards(self) -> List[BbsBoard]:
"""Return a copy of all configured boards.
Returns:
List of ``BbsBoard`` instances.
"""
with self._lock:
return list(self._boards)
def get_board(self, board_id: str) -> Optional[BbsBoard]:
"""Return a board by its id, or ``None``.
Args:
board_id: Board identifier string.
Returns:
``BbsBoard`` instance or ``None``.
"""
with self._lock:
for b in self._boards:
if b.id == board_id:
return BbsBoard.from_dict(b.to_dict())
return None
def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]:
"""Return the first board that includes *channel_idx*, or ``None``.
Used by ``BbsCommandHandler`` to route incoming mesh commands.
Args:
channel_idx: MeshCore channel index.
Returns:
``BbsBoard`` instance or ``None``.
"""
with self._lock:
for b in self._boards:
if channel_idx in b.channels:
return BbsBoard.from_dict(b.to_dict())
return None
# ------------------------------------------------------------------
# Board management
# ------------------------------------------------------------------
def set_board(self, board: BbsBoard) -> None:
"""Insert or replace a board (matched by ``board.id``).
Args:
board: ``BbsBoard`` to persist.
"""
with self._lock:
for i, b in enumerate(self._boards):
if b.id == board.id:
self._boards[i] = BbsBoard.from_dict(board.to_dict())
self._save_unlocked()
debug_print(f"BBS config: updated board '{board.id}'")
return
self._boards.append(BbsBoard.from_dict(board.to_dict()))
self._save_unlocked()
debug_print(f"BBS config: added board '{board.id}'")
def delete_board(self, board_id: str) -> bool:
"""Remove a board by id.
Args:
board_id: Board identifier to remove.
Returns:
``True`` if removed, ``False`` if not found.
"""
with self._lock:
before = len(self._boards)
self._boards = [b for b in self._boards if b.id != board_id]
if len(self._boards) < before:
self._save_unlocked()
debug_print(f"BBS config: deleted board '{board_id}'")
return True
return False
def board_id_exists(self, board_id: str) -> bool:
"""Check whether a board id is already in use.
Args:
board_id: Board identifier to check.
Returns:
``True`` if a board with this id exists.
"""
with self._lock:
return any(b.id == board_id for b in self._boards)

View File

@@ -0,0 +1,468 @@
"""
Offline Bulletin Board System (BBS) service for MeshCore GUI.
Stores BBS messages in a local SQLite database. Messages are keyed by
their originating MeshCore channel index. A **board** (see
:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
more channel indices to a single bulletin board, so queries are always
issued as ``WHERE channel IN (...)``.
Architecture
~~~~~~~~~~~~
- ``BbsService`` -- persistence layer (SQLite, retention, queries).
- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
delegates to ``BbsService``. Returns reply text.
Thread safety
~~~~~~~~~~~~~
SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
Storage
~~~~~~~
``~/.meshcore-gui/bbs/bbs_messages.db``
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
"""
import sqlite3
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional
from meshcore_gui.config import debug_print
BBS_DIR = Path.home() / ".meshcore-gui" / "bbs"
BBS_DB_PATH = BBS_DIR / "bbs_messages.db"
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class BbsMessage:
"""A single BBS message.
Attributes:
id: Database row id (``None`` before insert).
channel: MeshCore channel index the message arrived on.
region: Region tag (empty string when board has no regions).
category: Category tag.
sender: Display name of the sender.
sender_key: Public key of the sender (hex string).
text: Message body.
timestamp: UTC ISO-8601 timestamp string.
"""
channel: int
region: str
category: str
sender: str
sender_key: str
text: str
timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
id: Optional[int] = None
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
class BbsService:
"""SQLite-backed BBS storage service.
Args:
db_path: Path to the SQLite database file.
"""
def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _init_db(self) -> None:
"""Create the database directory and schema if not present."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=3000")
conn.execute("""
CREATE TABLE IF NOT EXISTS bbs_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel INTEGER NOT NULL,
region TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
sender TEXT NOT NULL,
sender_key TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL,
timestamp TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)"
)
conn.commit()
debug_print(f"BBS: database ready at {self._db_path}")
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(str(self._db_path), check_same_thread=False)
# ------------------------------------------------------------------
# Write
# ------------------------------------------------------------------
def post_message(self, msg: BbsMessage) -> int:
"""Insert a BBS message and return its row id.
Args:
msg: ``BbsMessage`` dataclass to persist.
Returns:
Assigned ``rowid`` (also set on ``msg.id``).
"""
with self._lock:
with self._connect() as conn:
cur = conn.execute(
"""INSERT INTO bbs_messages
(channel, region, category, sender, sender_key, text, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(msg.channel, msg.region, msg.category,
msg.sender, msg.sender_key, msg.text, msg.timestamp),
)
conn.commit()
msg.id = cur.lastrowid
debug_print(
f"BBS: posted id={msg.id} ch={msg.channel} "
f"cat={msg.category} sender={msg.sender}"
)
return msg.id
# ------------------------------------------------------------------
# Read (channels is a list to support multi-channel boards)
# ------------------------------------------------------------------
def get_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
limit: int = 5,
) -> List[BbsMessage]:
"""Return the *limit* most recent messages for a set of channels.
Args:
channels: MeshCore channel indices to query (board's channel list).
region: Optional region filter.
category: Optional category filter.
limit: Maximum number of messages to return.
Returns:
List of ``BbsMessage`` objects, newest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
if region:
query += " AND region = ?"
params.append(region)
if category:
query += " AND category = ?"
params.append(category)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
def get_all_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
) -> List[BbsMessage]:
"""Return all messages for a set of channels (oldest first).
Args:
channels: MeshCore channel indices to query.
region: Optional region filter.
category: Optional category filter.
Returns:
List of ``BbsMessage`` objects, oldest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
if region:
query += " AND region = ?"
params.append(region)
if category:
query += " AND category = ?"
params.append(category)
query += " ORDER BY timestamp ASC"
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
@staticmethod
def _row_to_msg(row: tuple) -> BbsMessage:
return BbsMessage(
id=row[0], channel=row[1], region=row[2], category=row[3],
sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7],
)
# ------------------------------------------------------------------
# Retention
# ------------------------------------------------------------------
def purge_expired(self, channels: List[int], retention_hours: int) -> int:
"""Delete messages older than *retention_hours* for a set of channels.
Args:
channels: MeshCore channel indices to purge.
retention_hours: Messages older than this are deleted.
Returns:
Number of rows deleted.
"""
if not channels:
return 0
cutoff = (
datetime.now(timezone.utc) - timedelta(hours=retention_hours)
).isoformat()
placeholders = ",".join("?" * len(channels))
with self._lock:
with self._connect() as conn:
cur = conn.execute(
f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
list(channels) + [cutoff],
)
conn.commit()
deleted = cur.rowcount
if deleted:
debug_print(
f"BBS: purged {deleted} expired messages from ch={channels}"
)
return deleted
def purge_all_expired(self, boards) -> None:
"""Run retention cleanup for all boards.
Args:
boards: Iterable of ``BbsBoard`` instances.
"""
for board in boards:
self.purge_expired(board.channels, board.retention_hours)
# ---------------------------------------------------------------------------
# Command handler
# ---------------------------------------------------------------------------
class BbsCommandHandler:
"""Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
Looks up the board for the incoming channel via ``BbsConfigStore``
so that a single board spanning multiple channels handles commands
from all of them.
Args:
service: Shared ``BbsService`` instance.
config_store: ``BbsConfigStore`` instance for live board config.
"""
READ_LIMIT: int = 5
def __init__(self, service: BbsService, config_store) -> None:
self._service = service
self._config_store = config_store
# ------------------------------------------------------------------
# Public entry point
# ------------------------------------------------------------------
def handle(
self,
channel_idx: int,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""Parse an incoming message and return a reply string (or ``None``).
Args:
channel_idx: MeshCore channel index the message arrived on.
sender: Display name of the sender.
sender_key: Public key of the sender (hex string).
text: Raw message text.
Returns:
Reply string, or ``None`` if no reply should be sent.
"""
text = (text or "").strip()
if not text.lower().startswith("!bbs"):
return None
board = self._config_store.get_board_for_channel(channel_idx)
if board is None:
return None
# Whitelist check
if board.allowed_keys and sender_key not in board.allowed_keys:
debug_print(
f"BBS: silently dropping msg from {sender} "
f"(key not in whitelist for board '{board.id}')"
)
return None
parts = text.split(None, 1)
args = parts[1].strip() if len(parts) > 1 else ""
return self._dispatch(board, channel_idx, sender, sender_key, args)
# ------------------------------------------------------------------
# Dispatch
# ------------------------------------------------------------------
def _dispatch(self, board, channel_idx, sender, sender_key, args):
sub = args.split(None, 1)[0].lower() if args else ""
rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else ""
if sub == "post":
return self._handle_post(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub == "help" or not sub:
return self._handle_help(board)
return f"Unknown command '{sub}'. {self._handle_help(board)}"
# ------------------------------------------------------------------
# post
# ------------------------------------------------------------------
def _handle_post(self, board, channel_idx, sender, sender_key, args):
regions = board.regions
categories = board.categories
tokens = args.split(None, 2) if args else []
if regions:
if len(tokens) < 3:
return (
f"Usage: !bbs post [region] [category] [text] | "
f"Regions: {', '.join(regions)} | "
f"Categories: {', '.join(categories)}"
)
region, category, text = tokens[0], tokens[1], tokens[2]
valid_r = [r.upper() for r in regions]
if region.upper() not in valid_r:
return f"Invalid region '{region}'. Valid: {', '.join(regions)}"
region = regions[valid_r.index(region.upper())]
valid_c = [c.upper() for c in categories]
if category.upper() not in valid_c:
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
category = categories[valid_c.index(category.upper())]
else:
if len(tokens) < 2:
return (
f"Usage: !bbs post [category] [text] | "
f"Categories: {', '.join(categories)}"
)
region = ""
category, text = tokens[0], tokens[1]
valid_c = [c.upper() for c in categories]
if category.upper() not in valid_c:
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
category = categories[valid_c.index(category.upper())]
msg = BbsMessage(
channel=channel_idx,
region=region, category=category,
sender=sender, sender_key=sender_key, text=text,
)
self._service.post_message(msg)
region_label = f" [{region}]" if region else ""
return f"Posted [{category}]{region_label}: {text[:60]}"
# ------------------------------------------------------------------
# read
# ------------------------------------------------------------------
def _handle_read(self, board, args):
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
if regions:
valid_r = [r.upper() for r in regions]
valid_c = [c.upper() for c in categories]
if tokens:
if tokens[0].upper() in valid_r:
region = regions[valid_r.index(tokens[0].upper())]
if len(tokens) >= 2:
if tokens[1].upper() in valid_c:
category = categories[valid_c.index(tokens[1].upper())]
else:
return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}"
else:
return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}"
else:
valid_c = [c.upper() for c in categories]
if tokens:
if tokens[0].upper() in valid_c:
category = categories[valid_c.index(tokens[0].upper())]
else:
return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}"
messages = self._service.get_messages(
board.channels, region=region, category=category, limit=self.READ_LIMIT,
)
if not messages:
return "BBS: no messages found."
lines = []
for m in messages:
ts = m.timestamp[:16].replace("T", " ")
region_label = f"[{m.region}] " if m.region else ""
lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}")
return "\n".join(lines)
# ------------------------------------------------------------------
# help
# ------------------------------------------------------------------
def _handle_help(self, board) -> str:
cats = ", ".join(board.categories)
if board.regions:
regs = ", ".join(board.regions)
return (
f"BBS [{board.name}] | "
f"!bbs post [region] [cat] [text] | "
f"!bbs read [region] [cat] | "
f"Regions: {regs} | Categories: {cats}"
)
return (
f"BBS [{board.name}] | "
f"!bbs post [cat] [text] | "
f"!bbs read [cat] | "
f"Categories: {cats}"
)

View File

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

View File

@@ -0,0 +1,407 @@
"""
BBS board configuration store for MeshCore GUI.
Persists BBS board configuration to
``~/.meshcore-gui/bbs/bbs_config.json``.
Design (v1.14.0 redesign)
~~~~~~~~~~~~~~~~~~~~~~~~~
One node = one board. The settings UI exposes a single channel selector;
the board id is always ``ch{channel_idx}`` and the name is taken from the
device channel. There is no Create/Delete UI — the board is saved or
cleared through :meth:`configure_board` / :meth:`clear_board`.
Multiple-board storage is retained internally so that the storage layer
(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged.
Config version history
~~~~~~~~~~~~~~~~~~~~~~
v1 — per-channel config (list of channels with enabled flag).
v2 — board-based config (list of boards, each with a channels list).
Automatic migration from v1 on first load.
Thread safety
~~~~~~~~~~~~~
All public methods acquire an internal ``threading.Lock``.
"""
import json
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
from meshcore_gui.config import debug_print
# ---------------------------------------------------------------------------
# Storage
# ---------------------------------------------------------------------------
BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs"
BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json"
CONFIG_VERSION: int = 2
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"]
DEFAULT_REGIONS: List[str] = []
DEFAULT_RETENTION_HOURS: int = 48
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class BbsBoard:
"""A BBS board grouping one or more MeshCore channels.
Attributes:
id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``).
name: Human-readable board name.
channels: List of MeshCore channel indices assigned to this board.
categories: Valid category tags for this board.
regions: Optional region tags; empty = no region filtering.
retention_hours: Message retention period in hours.
allowed_keys: Sender public key whitelist (empty = all allowed).
"""
id: str
name: str
channels: List[int] = field(default_factory=list)
categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
regions: List[str] = field(default_factory=list)
retention_hours: int = DEFAULT_RETENTION_HOURS
allowed_keys: List[str] = field(default_factory=list)
def to_dict(self) -> Dict:
"""Serialise to a JSON-compatible dict."""
return {
"id": self.id,
"name": self.name,
"channels": list(self.channels),
"categories": list(self.categories),
"regions": list(self.regions),
"retention_hours": self.retention_hours,
"allowed_keys": list(self.allowed_keys),
}
@staticmethod
def from_dict(d: Dict) -> "BbsBoard":
"""Deserialise from a config dict."""
return BbsBoard(
id=d.get("id", ""),
name=d.get("name", ""),
channels=list(d.get("channels", [])),
categories=list(d.get("categories", DEFAULT_CATEGORIES)),
regions=list(d.get("regions", [])),
retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)),
allowed_keys=list(d.get("allowed_keys", [])),
)
# ---------------------------------------------------------------------------
# Store
# ---------------------------------------------------------------------------
class BbsConfigStore:
"""Persistent store for BBS board configuration.
Args:
config_path: Path to the JSON config file.
Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``.
"""
def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None:
self._path = config_path
self._lock = threading.Lock()
self._boards: List[BbsBoard] = []
self._load()
# ------------------------------------------------------------------
# Load / save
# ------------------------------------------------------------------
def _load(self) -> None:
"""Load config from disk; migrate v1 → v2 if needed."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
if not self._path.exists():
self._save_unlocked()
debug_print("BBS config: created new config file (v2)")
return
try:
raw = self._path.read_text(encoding="utf-8")
data = json.loads(raw)
version = data.get("version", 1)
if version == CONFIG_VERSION:
self._boards = [
BbsBoard.from_dict(b) for b in data.get("boards", [])
]
debug_print(f"BBS config: loaded {len(self._boards)} boards")
elif version == 1:
# Migrate: each v1 channel → one board
self._boards = self._migrate_v1(data.get("channels", []))
self._save_unlocked()
debug_print(
f"BBS config: migrated v1 → v2 ({len(self._boards)} boards)"
)
else:
debug_print(
f"BBS config: unknown version {version}, using empty config"
)
except (json.JSONDecodeError, OSError) as exc:
debug_print(f"BBS config: load error ({exc}), using empty config")
@staticmethod
def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]:
"""Convert v1 per-channel entries to v2 boards.
Only enabled channels are migrated.
Args:
v1_channels: List of v1 channel config dicts.
Returns:
List of ``BbsBoard`` instances.
"""
boards = []
for ch in v1_channels:
if not ch.get("enabled", False):
continue
idx = ch.get("channel", 0)
board_id = f"ch{idx}"
boards.append(BbsBoard(
id=board_id,
name=ch.get("name", f"Channel {idx}"),
channels=[idx],
categories=list(ch.get("categories", DEFAULT_CATEGORIES)),
regions=list(ch.get("regions", [])),
retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)),
allowed_keys=list(ch.get("allowed_keys", [])),
))
return boards
def _save_unlocked(self) -> None:
"""Write config to disk. MUST be called with self._lock held."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
data = {
"version": CONFIG_VERSION,
"boards": [b.to_dict() for b in self._boards],
}
tmp = self._path.with_suffix(".tmp")
tmp.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
tmp.replace(self._path)
def save(self) -> None:
"""Flush current configuration to disk."""
with self._lock:
self._save_unlocked()
# ------------------------------------------------------------------
# Board queries
# ------------------------------------------------------------------
def get_boards(self) -> List[BbsBoard]:
"""Return a copy of all configured boards.
Returns:
List of ``BbsBoard`` instances.
"""
with self._lock:
return list(self._boards)
def get_board(self, board_id: str) -> Optional[BbsBoard]:
"""Return a board by its id, or ``None``.
Args:
board_id: Board identifier string.
Returns:
``BbsBoard`` instance or ``None``.
"""
with self._lock:
for b in self._boards:
if b.id == board_id:
return BbsBoard.from_dict(b.to_dict())
return None
def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]:
"""Return the first board that includes *channel_idx*, or ``None``.
Used by ``BbsCommandHandler`` to route incoming mesh commands.
Args:
channel_idx: MeshCore channel index.
Returns:
``BbsBoard`` instance or ``None``.
"""
with self._lock:
for b in self._boards:
if channel_idx in b.channels:
return BbsBoard.from_dict(b.to_dict())
return None
# ------------------------------------------------------------------
# Board management
# ------------------------------------------------------------------
def set_board(self, board: BbsBoard) -> None:
"""Insert or replace a board (matched by ``board.id``).
Args:
board: ``BbsBoard`` to persist.
"""
with self._lock:
for i, b in enumerate(self._boards):
if b.id == board.id:
self._boards[i] = BbsBoard.from_dict(board.to_dict())
self._save_unlocked()
debug_print(f"BBS config: updated board '{board.id}'")
return
self._boards.append(BbsBoard.from_dict(board.to_dict()))
self._save_unlocked()
debug_print(f"BBS config: added board '{board.id}'")
def delete_board(self, board_id: str) -> bool:
"""Remove a board by id.
Args:
board_id: Board identifier to remove.
Returns:
``True`` if removed, ``False`` if not found.
"""
with self._lock:
before = len(self._boards)
self._boards = [b for b in self._boards if b.id != board_id]
if len(self._boards) < before:
self._save_unlocked()
debug_print(f"BBS config: deleted board '{board_id}'")
return True
return False
def board_id_exists(self, board_id: str) -> bool:
"""Check whether a board id is already in use.
Args:
board_id: Board identifier to check.
Returns:
``True`` if a board with this id exists.
"""
with self._lock:
return any(b.id == board_id for b in self._boards)
# ------------------------------------------------------------------
# Board API (v1.14.0 redesign)
# ------------------------------------------------------------------
def get_single_board(self) -> Optional[BbsBoard]:
"""Return the configured board, or ``None`` if none exists.
Returns:
The first ``BbsBoard`` in the store, or ``None``.
"""
with self._lock:
if self._boards:
return BbsBoard.from_dict(self._boards[0].to_dict())
return None
def configure_board(
self,
channel_indices: List[int],
channel_names: Dict[int, str],
categories: List[str],
retention_hours: int = DEFAULT_RETENTION_HOURS,
regions: Optional[List[str]] = None,
allowed_keys: Optional[List[str]] = None,
) -> None:
"""Save the board configuration.
Multiple channels can be assigned. Every sender seen on any of
these channels is automatically eligible for DM access (the
worker calls :meth:`add_allowed_key` when it sees them).
The board id is always ``'bbs_board'``. The board name is built
from the channel names in *channel_names*.
Args:
channel_indices: MeshCore channel indices to assign.
channel_names: Mapping ``idx → display name`` for labelling.
categories: Category tag list.
retention_hours: Message retention period in hours.
regions: Optional region tags.
allowed_keys: Manual sender key whitelist seed (auto-learned
keys are added via :meth:`add_allowed_key`).
"""
name = ", ".join(
channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices)
) or "BBS"
# Preserve existing auto-learned keys unless caller supplies a new list
existing = self.get_single_board()
merged_keys = list(allowed_keys) if allowed_keys is not None else (
existing.allowed_keys if existing else []
)
board = BbsBoard(
id="bbs_board",
name=name,
channels=sorted(channel_indices),
categories=list(categories),
regions=list(regions) if regions else [],
retention_hours=retention_hours,
allowed_keys=merged_keys,
)
with self._lock:
self._boards = [board]
self._save_unlocked()
debug_print(
f"BBS config: board configured → channels={sorted(channel_indices)} "
f"name='{name}'"
)
def clear_board(self) -> None:
"""Remove the configured board (disable BBS on this node)."""
with self._lock:
self._boards = []
self._save_unlocked()
debug_print("BBS config: board cleared")
def add_allowed_key(self, sender_key: str) -> bool:
"""Add *sender_key* to the board's allowed_keys whitelist.
Called automatically by the worker whenever a sender is seen on
a configured BBS channel. No-op if the key is already present
or if no board is configured.
Args:
sender_key: Public key hex string of the sender.
Returns:
``True`` if the key was newly added, ``False`` otherwise.
"""
if not sender_key:
return False
with self._lock:
if not self._boards:
return False
board = self._boards[0]
if sender_key in board.allowed_keys:
return False
board.allowed_keys.append(sender_key)
self._save_unlocked()
debug_print(f"BBS config: auto-whitelisted key {sender_key[:12]}")
return True

View File

@@ -0,0 +1,699 @@
"""
Offline Bulletin Board System (BBS) service for MeshCore GUI.
Stores BBS messages in a local SQLite database. Messages are keyed by
their originating MeshCore channel index. A **board** (see
:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
more channel indices to a single bulletin board, so queries are always
issued as ``WHERE channel IN (...)``.
Architecture
~~~~~~~~~~~~
- ``BbsService`` -- persistence layer (SQLite, retention, queries).
- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
delegates to ``BbsService``. Returns reply text.
Thread safety
~~~~~~~~~~~~~
SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
Storage
~~~~~~~
``~/.meshcore-gui/bbs/bbs_messages.db``
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
"""
import sqlite3
import threading
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List, Optional
from meshcore_gui.config import debug_print
BBS_DIR = Path.home() / ".meshcore-gui" / "bbs"
BBS_DB_PATH = BBS_DIR / "bbs_messages.db"
# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------
@dataclass
class BbsMessage:
"""A single BBS message.
Attributes:
id: Database row id (``None`` before insert).
channel: MeshCore channel index the message arrived on.
region: Region tag (empty string when board has no regions).
category: Category tag.
sender: Display name of the sender.
sender_key: Public key of the sender (hex string).
text: Message body.
timestamp: UTC ISO-8601 timestamp string.
"""
channel: int
region: str
category: str
sender: str
sender_key: str
text: str
timestamp: str = field(
default_factory=lambda: datetime.now(timezone.utc).isoformat()
)
id: Optional[int] = None
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
class BbsService:
"""SQLite-backed BBS storage service.
Args:
db_path: Path to the SQLite database file.
"""
def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _init_db(self) -> None:
"""Create the database directory and schema if not present."""
BBS_DIR.mkdir(parents=True, exist_ok=True)
with self._connect() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=3000")
conn.execute("""
CREATE TABLE IF NOT EXISTS bbs_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel INTEGER NOT NULL,
region TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
sender TEXT NOT NULL,
sender_key TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL,
timestamp TEXT NOT NULL
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)"
)
conn.commit()
debug_print(f"BBS: database ready at {self._db_path}")
def _connect(self) -> sqlite3.Connection:
return sqlite3.connect(str(self._db_path), check_same_thread=False)
# ------------------------------------------------------------------
# Write
# ------------------------------------------------------------------
def post_message(self, msg: BbsMessage) -> int:
"""Insert a BBS message and return its row id.
Args:
msg: ``BbsMessage`` dataclass to persist.
Returns:
Assigned ``rowid`` (also set on ``msg.id``).
"""
with self._lock:
with self._connect() as conn:
cur = conn.execute(
"""INSERT INTO bbs_messages
(channel, region, category, sender, sender_key, text, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(msg.channel, msg.region, msg.category,
msg.sender, msg.sender_key, msg.text, msg.timestamp),
)
conn.commit()
msg.id = cur.lastrowid
debug_print(
f"BBS: posted id={msg.id} ch={msg.channel} "
f"cat={msg.category} sender={msg.sender}"
)
return msg.id
# ------------------------------------------------------------------
# Read (channels is a list to support multi-channel boards)
# ------------------------------------------------------------------
def get_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
limit: int = 5,
) -> List[BbsMessage]:
"""Return the *limit* most recent messages for a set of channels.
Args:
channels: MeshCore channel indices to query (board's channel list).
region: Optional region filter.
category: Optional category filter.
limit: Maximum number of messages to return.
Returns:
List of ``BbsMessage`` objects, newest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
if region:
query += " AND region = ?"
params.append(region)
if category:
query += " AND category = ?"
params.append(category)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
def get_all_messages(
self,
channels: List[int],
region: Optional[str] = None,
category: Optional[str] = None,
) -> List[BbsMessage]:
"""Return all messages for a set of channels (oldest first).
Args:
channels: MeshCore channel indices to query.
region: Optional region filter.
category: Optional category filter.
Returns:
List of ``BbsMessage`` objects, oldest first.
"""
if not channels:
return []
placeholders = ",".join("?" * len(channels))
query = (
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
f"FROM bbs_messages WHERE channel IN ({placeholders})"
)
params: list = list(channels)
if region:
query += " AND region = ?"
params.append(region)
if category:
query += " AND category = ?"
params.append(category)
query += " ORDER BY timestamp ASC"
with self._lock:
with self._connect() as conn:
rows = conn.execute(query, params).fetchall()
return [self._row_to_msg(r) for r in rows]
@staticmethod
def _row_to_msg(row: tuple) -> BbsMessage:
return BbsMessage(
id=row[0], channel=row[1], region=row[2], category=row[3],
sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7],
)
# ------------------------------------------------------------------
# Retention
# ------------------------------------------------------------------
def purge_expired(self, channels: List[int], retention_hours: int) -> int:
"""Delete messages older than *retention_hours* for a set of channels.
Args:
channels: MeshCore channel indices to purge.
retention_hours: Messages older than this are deleted.
Returns:
Number of rows deleted.
"""
if not channels:
return 0
cutoff = (
datetime.now(timezone.utc) - timedelta(hours=retention_hours)
).isoformat()
placeholders = ",".join("?" * len(channels))
with self._lock:
with self._connect() as conn:
cur = conn.execute(
f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
list(channels) + [cutoff],
)
conn.commit()
deleted = cur.rowcount
if deleted:
debug_print(
f"BBS: purged {deleted} expired messages from ch={channels}"
)
return deleted
def purge_all_expired(self, boards) -> None:
"""Run retention cleanup for all boards.
Args:
boards: Iterable of ``BbsBoard`` instances.
"""
for board in boards:
self.purge_expired(board.channels, board.retention_hours)
# ---------------------------------------------------------------------------
# Command handler
# ---------------------------------------------------------------------------
class BbsCommandHandler:
"""Parses BBS commands arriving as DMs and delegates to :class:`BbsService`.
Entry point
~~~~~~~~~~~
All BBS commands arrive as **Direct Messages** addressed to the node's
own public key. :meth:`handle_dm` is the sole public entry point and is
called directly from
:class:`~meshcore_gui.ble.events.EventHandler.on_contact_msg`.
It is completely independent of :class:`~meshcore_gui.services.bot.MeshBot`.
Command syntax
~~~~~~~~~~~~~~
Both styles are accepted:
Short syntax::
!p [region] <abbrev> <text> — post a message
!r [region] [abbrev] — read (5 most recent)
Full syntax::
!bbs post [region] <category> <text>
!bbs read [region] [category]
!bbs help
Category abbreviations are computed automatically as the shortest unique
prefix per category within the configured list. ``!r`` and ``!bbs help``
always include the abbreviation table in the reply.
Args:
service: Shared ``BbsService`` instance.
config_store: ``BbsConfigStore`` instance for live board config.
"""
READ_LIMIT: int = 5
def __init__(self, service: BbsService, config_store) -> None:
self._service = service
self._config_store = config_store
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
def handle_channel_msg(
self,
channel_idx: int,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""Handle a channel message on a configured BBS channel.
Called from ``EventHandler.on_channel_msg`` **after** the message
has been stored. Two responsibilities:
1. **Auto-whitelist**: every sender seen on a BBS channel gets their
key added to ``allowed_keys`` so they can use DMs afterwards.
2. **Bootstrap reply**: if the message starts with ``!``, reply on
the channel so the sender knows the BBS is active and receives
the abbreviation table.
Args:
channel_idx: MeshCore channel index the message arrived on.
sender: Display name of the sender.
sender_key: Public key of the sender (hex string).
text: Raw message text.
Returns:
Reply string to post on the channel, or ``None``.
"""
board = self._config_store.get_single_board()
if board is None:
return None
if channel_idx not in board.channels:
return None
# Auto-whitelist: register this sender so they can use DMs
if sender_key:
self._config_store.add_allowed_key(sender_key)
# Bootstrap reply only for !-commands
text = (text or "").strip()
if not text.startswith("!"):
return None
first = text.split()[0].lower()
channel_for_post = channel_idx
if first == "!p":
rest = text[len(first):].strip()
return self._handle_post_short(board, channel_for_post, sender, sender_key, rest)
if first == "!r":
rest = text[len(first):].strip()
return self._handle_read_short(board, rest)
if first == "!bbs":
parts = text.split(None, 2)
sub = parts[1].lower() if len(parts) > 1 else ""
rest = parts[2] if len(parts) > 2 else ""
if sub == "post":
return self._handle_post(board, channel_for_post, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub == "help" or not sub:
return self._handle_help(board)
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
return None
# ------------------------------------------------------------------
def handle_dm(
self,
sender: str,
sender_key: str,
text: str,
) -> Optional[str]:
"""Parse a DM addressed to this node and return a reply (or ``None``).
This is the **only** entry point for BBS commands. It is called
directly by ``EventHandler.on_contact_msg`` when a DM arrives whose
text starts with ``!``. The bot is never involved.
The board is looked up from the single configured board via
``BbsConfigStore.get_single_board()``.
Args:
sender: Display name of the DM sender.
sender_key: Public key of the sender (hex string).
text: Raw DM text.
Returns:
Reply string to send back as DM, or ``None`` for silent drop.
"""
text = (text or "").strip()
first = text.split()[0].lower() if text else ""
if not first.startswith("!"):
return None
board = self._config_store.get_single_board()
if board is None:
debug_print("BBS: no board configured, ignoring DM")
return None
# Whitelist check
if board.allowed_keys and sender_key not in board.allowed_keys:
debug_print(
f"BBS: silently dropping DM from {sender} "
f"(key not in whitelist for board '{board.id}')"
)
return None
# Channel for storing posted messages
channel_idx = board.channels[0] if board.channels else 0
# Route by command prefix
if first in ("!p",):
rest = text[len(first):].strip()
return self._handle_post_short(board, channel_idx, sender, sender_key, rest)
if first in ("!r",):
rest = text[len(first):].strip()
return self._handle_read_short(board, rest)
if first == "!bbs":
parts = text.split(None, 2)
sub = parts[1].lower() if len(parts) > 1 else ""
rest = parts[2] if len(parts) > 2 else ""
if sub == "post":
return self._handle_post(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub == "help" or not sub:
return self._handle_help(board)
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
# Unknown !-command starting with something else
return None
# ------------------------------------------------------------------
# Abbreviation helpers
# ------------------------------------------------------------------
@staticmethod
def compute_abbreviations(categories: List[str]) -> Dict[str, str]:
"""Compute shortest unique prefix for each category.
Returns a dict mapping ``abbrev.upper()`` → ``category``.
Examples::
["URGENT", "MEDICAL", "LOGISTICS", "STATUS", "GENERAL"]
{"U": "URGENT", "M": "MEDICAL", "L": "LOGISTICS",
"S": "STATUS", "G": "GENERAL"}
["MEDICAL", "MISSING"]
{"ME": "MEDICAL", "MI": "MISSING"}
"""
abbrevs: Dict[str, str] = {}
cats_upper = [c.upper() for c in categories]
for cat in cats_upper:
for length in range(1, len(cat) + 1):
prefix = cat[:length]
# Unique if no other category starts with this prefix
if sum(1 for c in cats_upper if c.startswith(prefix)) == 1:
abbrevs[prefix] = cat
break
return abbrevs
def _abbrev_table(self, categories: List[str]) -> str:
"""Return a compact abbreviation table string, e.g. ``U=URGENT M=MEDICAL``."""
abbrevs = self.compute_abbreviations(categories)
# abbrevs maps prefix → full name; invert for display
inv = {v: k for k, v in abbrevs.items()}
return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv)
def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]:
"""Resolve *token* to a category via exact match or abbreviation.
Returns the matching category string (original case from board
config), or ``None`` if unresolvable.
"""
token_up = token.upper()
cats_upper = [c.upper() for c in categories]
# Exact match first
if token_up in cats_upper:
return categories[cats_upper.index(token_up)]
# Abbreviation match
abbrevs = self.compute_abbreviations(categories)
if token_up in abbrevs:
matched = abbrevs[token_up]
return categories[cats_upper.index(matched)]
return None
def _resolve_region(self, token: str, regions: List[str]) -> Optional[str]:
"""Resolve *token* to a region via exact (case-insensitive) match."""
token_up = token.upper()
regs_upper = [r.upper() for r in regions]
if token_up in regs_upper:
return regions[regs_upper.index(token_up)]
return None
# ------------------------------------------------------------------
# Short syntax — !p and !r
# ------------------------------------------------------------------
def _handle_post_short(self, board, channel_idx, sender, sender_key, args):
"""Handle ``!p [region] <abbrev> <text>``."""
regions = board.regions
categories = board.categories
# First token may be a region; split loosely to detect it
first_tokens = args.split(None, 1) if args else []
region = ""
remainder = args # everything after optional region
if regions and first_tokens:
resolved_r = self._resolve_region(first_tokens[0], regions)
if resolved_r:
region = resolved_r
remainder = first_tokens[1] if len(first_tokens) > 1 else ""
# Split remainder into exactly [category/abbrev, full_text]
cat_and_text = remainder.split(None, 1) if remainder else []
if len(cat_and_text) < 2:
abbr = self._abbrev_table(categories)
return f"Usage: !p [region] <cat> <text> | {abbr}"
cat_token, text = cat_and_text[0], cat_and_text[1]
category = self._resolve_category(cat_token, categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{cat_token}'. Valid: {abbr}"
msg = BbsMessage(
channel=channel_idx,
region=region, category=category,
sender=sender, sender_key=sender_key, text=text,
)
self._service.post_message(msg)
region_label = f" [{region}]" if region else ""
return f"Posted [{category}]{region_label}: {text[:60]}"
def _handle_read_short(self, board, args):
"""Handle ``!r [region] [abbrev]``.
With no arguments returns 5 most recent messages across all
categories and always includes the abbreviation table.
"""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if tokens:
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
return self._format_messages(board, region, category, include_abbrevs=not args)
# ------------------------------------------------------------------
# Full syntax — !bbs post / read
# ------------------------------------------------------------------
def _handle_post(self, board, channel_idx, sender, sender_key, args):
"""Handle ``!bbs post [region] <category> <text>``."""
regions = board.regions
categories = board.categories
# First token may be a region; split loosely to detect it
first_tokens = args.split(None, 1) if args else []
region = ""
remainder = args # everything after optional region
if regions and first_tokens:
resolved_r = self._resolve_region(first_tokens[0], regions)
if resolved_r:
region = resolved_r
remainder = first_tokens[1] if len(first_tokens) > 1 else ""
# Split remainder into exactly [category, full_text]
cat_and_text = remainder.split(None, 1) if remainder else []
if len(cat_and_text) < 2:
abbr = self._abbrev_table(categories)
region_hint = " [region]" if regions else ""
return f"Usage: !bbs post{region_hint} <cat> <text> | {abbr}"
cat_token, text = cat_and_text[0], cat_and_text[1]
category = self._resolve_category(cat_token, categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{cat_token}'. Valid: {abbr}"
msg = BbsMessage(
channel=channel_idx,
region=region, category=category,
sender=sender, sender_key=sender_key, text=text,
)
self._service.post_message(msg)
region_label = f" [{region}]" if region else ""
return f"Posted [{category}]{region_label}: {text[:60]}"
def _handle_read(self, board, args):
"""Handle ``!bbs read [region] [category]``."""
regions = board.regions
categories = board.categories
tokens = args.split() if args else []
region = None
category = None
if tokens and regions:
resolved_r = self._resolve_region(tokens[0], regions)
if resolved_r:
region = resolved_r
tokens = tokens[1:]
if tokens:
category = self._resolve_category(tokens[0], categories)
if category is None:
abbr = self._abbrev_table(categories)
return f"Unknown category '{tokens[0]}'. Valid: {abbr}"
return self._format_messages(board, region, category, include_abbrevs=False)
# ------------------------------------------------------------------
# Shared message formatter
# ------------------------------------------------------------------
def _format_messages(self, board, region, category, include_abbrevs: bool) -> str:
messages = self._service.get_messages(
board.channels, region=region, category=category, limit=self.READ_LIMIT,
)
lines = []
if include_abbrevs:
lines.append(self._handle_help(board))
if not messages:
lines.append("BBS: no messages found.")
return "\n".join(lines)
for m in messages:
ts = m.timestamp[:16].replace("T", " ")
region_label = f"[{m.region}] " if m.region else ""
lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}")
return "\n".join(lines)
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
def _handle_help(self, board) -> str:
abbr = self._abbrev_table(board.categories)
header = f"BBS [{board.name}] | !p [cat] [text] | !r [cat]"
if board.regions:
regs = ", ".join(board.regions)
return f"{header} | Regions: {regs} | {abbr}"
return f"{header} | {abbr}"

View File

@@ -10,6 +10,15 @@ Open/Closed
New keywords are added via ``BotConfig.keywords`` (data) without
modifying the ``MeshBot`` class (code). Custom matching strategies
can be implemented by subclassing and overriding ``_match_keyword``.
BBS separation
~~~~~~~~~~~~~~
BBS commands (``!bbs``, ``!p``, ``!r``) are handled by
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` which is
wired directly into
:class:`~meshcore_gui.ble.events.EventHandler`. They never pass
through ``MeshBot`` — the bot is a pure keyword/channel-message
responder only.
"""
import time
@@ -96,7 +105,7 @@ class MeshBot:
path_len: int,
path_hashes: Optional[List[str]] = None,
) -> None:
"""Evaluate an incoming message and queue a reply if appropriate.
"""Evaluate an incoming channel message and queue a reply if appropriate.
Guards (in order):
1. Bot is enabled (checkbox in GUI).
@@ -105,6 +114,10 @@ class MeshBot:
4. Sender name does not end with ``'Bot'`` (prevent loops).
5. Cooldown period has elapsed.
6. Message text contains a recognised keyword.
Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here.
They arrive as DMs and are handled by ``BbsCommandHandler`` directly
inside ``EventHandler.on_contact_msg``.
"""
# Guard 1: enabled?
if not self._enabled():

View File

@@ -38,7 +38,17 @@
const existing = maps.get(containerId);
const host = document.getElementById(containerId);
if (!host || typeof window.L === 'undefined' || typeof window.L.markerClusterGroup !== 'function') {
if (!host || typeof window.L === 'undefined') {
return null;
}
// Do not initialize the Leaflet map while the host container has no
// rendered dimensions. This happens when the map panel is hidden at
// page load (display:none via Vue v-show). Calling L.map() on a
// zero-size element produces a broken map that never recovers.
// processPending will retry on the next scheduled tick once the panel
// becomes visible and the host gains real dimensions.
if (!existing && host.clientWidth === 0 && host.clientHeight === 0) {
return null;
}
@@ -71,6 +81,8 @@
maxZoom: 19,
zoomControl: true,
preferCanvas: true,
fadeAnimation: false,
markerZoomAnimation: false,
});
const state = {
@@ -108,21 +120,7 @@
).addTo(map);
state.theme = 'light';
state.layers.contacts = window.L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
removeOutsideVisibleBounds: true,
animate: false,
chunkedLoading: true,
maxClusterRadius: 50,
iconCreateFunction(cluster) {
return window.L.divIcon({
html: '<div><span>' + cluster.getChildCount() + '</span></div>',
className: 'meshcore-marker-cluster',
iconSize: window.L.point(42, 42),
});
},
}).addTo(map);
state.layers.contacts = buildContactsLayer().addTo(map);
} catch (error) {
maps.delete(containerId);
delete host.__meshcoreLeafletState;
@@ -293,8 +291,11 @@
}
state.deviceMarker.setLatLng(latLng);
state.deviceMarker.setIcon(icon);
state.deviceMarker.setPopupContent(popupHtml);
const devicePopupOpen = state.deviceMarker.isPopupOpen();
if (!devicePopupOpen) {
state.deviceMarker.setIcon(icon);
state.deviceMarker.setPopupContent(popupHtml);
}
state.deviceMarker.options.title = '📡 ' + device.name;
}
@@ -322,8 +323,11 @@
}
existing.setLatLng(latLng);
existing.setIcon(markerIcon);
existing.setPopupContent(popupHtml);
const contactPopupOpen = existing.isPopupOpen();
if (!contactPopupOpen) {
existing.setIcon(markerIcon);
existing.setPopupContent(popupHtml);
}
existing.options.title = markerTitle;
if (!state.layers.contacts.hasLayer(existing)) {
state.layers.contacts.addLayer(existing);
@@ -408,6 +412,29 @@
);
}
function buildContactsLayer() {
if (typeof window.L.markerClusterGroup === 'function') {
return window.L.markerClusterGroup({
showCoverageOnHover: false,
spiderfyOnMaxZoom: true,
removeOutsideVisibleBounds: true,
animate: false,
chunkedLoading: true,
maxClusterRadius: 50,
iconCreateFunction(cluster) {
return window.L.divIcon({
html: '<div><span>' + cluster.getChildCount() + '</span></div>',
className: 'meshcore-marker-cluster',
iconSize: window.L.point(42, 42),
});
},
});
}
console.warn('MeshCoreLeafletBoot markercluster unavailable; falling back to plain layer group');
return window.L.layerGroup();
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
@@ -446,9 +473,9 @@
return;
}
if (typeof window.L === 'undefined' || typeof window.L.markerClusterGroup !== 'function') {
if (typeof window.L === 'undefined') {
if (retries >= MAX_RETRIES) {
console.error('MeshCoreLeafletBoot timeout waiting for Leaflet markercluster', { containerId });
console.error('MeshCoreLeafletBoot timeout waiting for Leaflet runtime', { containerId });
return;
}
window.setTimeout(() => {
@@ -460,6 +487,13 @@
try {
const state = PANEL.ensureMap(containerId);
if (!state) {
if (retries >= MAX_RETRIES) {
console.error('MeshCoreLeafletBoot timeout waiting for visible map host', { containerId });
return;
}
window.setTimeout(() => {
scheduleProcess(containerId, retries + 1);
}, RETRY_DELAY_MS);
return;
}
const current = pending.get(containerId);
@@ -487,35 +521,21 @@
return;
}
const observer = new MutationObserver(() => {
const timer = window.setTimeout(() => {
watchers.delete(containerId);
const host = document.getElementById(containerId);
if (!host) {
if (host) {
scheduleProcess(containerId, retries + 1);
return;
}
observer.disconnect();
watchers.delete(containerId);
scheduleProcess(containerId, retries + 1);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
watchers.set(containerId, observer);
window.setTimeout(() => {
if (watchers.get(containerId) !== observer) {
return;
}
observer.disconnect();
watchers.delete(containerId);
if (retries >= MAX_RETRIES) {
console.error('MeshCoreLeafletBoot timeout waiting for host element', { containerId });
return;
}
scheduleProcess(containerId, retries + 1);
}, RETRY_DELAY_MS);
watchers.set(containerId, timer);
}
function isDomReady() {
@@ -523,14 +543,28 @@
}
window.MeshCoreRouteMapBoot = function (containerId, payload) {
window.MeshCoreRouteMapBoot = function (containerId, payload, retries) {
if (!containerId || !payload) {
return;
}
const attempt = typeof retries === 'number' ? retries : 0;
const host = document.getElementById(containerId);
if (!host || typeof window.L === 'undefined') {
window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload), RETRY_DELAY_MS);
if (attempt >= MAX_RETRIES) {
console.error('MeshCoreRouteMapBoot timeout waiting for host/runtime', { containerId });
return;
}
window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload, attempt + 1), RETRY_DELAY_MS);
return;
}
if (host.clientWidth === 0 && host.clientHeight === 0) {
if (attempt >= MAX_RETRIES) {
console.error('MeshCoreRouteMapBoot timeout waiting for visible route map host', { containerId });
return;
}
window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload, attempt + 1), RETRY_DELAY_MS);
return;
}
@@ -551,6 +585,8 @@
maxZoom: 19,
zoomControl: true,
preferCanvas: true,
fadeAnimation: false,
markerZoomAnimation: false,
});
host.__meshcoreRouteMap = map;
@@ -631,6 +667,11 @@
}
}
if (!current.snapshot && current.theme && !maps.has(containerId)) {
pending.set(containerId, current);
return;
}
pending.set(containerId, current);
scheduleProcess(containerId, 0);
};