mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax
This commit is contained in:
186
CHANGELOG.md
186
CHANGELOG.md
@@ -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.1–1.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
103
README.md
@@ -7,6 +7,8 @@
|
||||

|
||||

|
||||

|
||||
<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
|
||||
|
||||
@@ -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.1–1.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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
379
meshcore_gui/ble/events.py.bak
Normal file
379
meshcore_gui/ble/events.py.bak
Normal 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
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
964
meshcore_gui/ble/worker.py.bak
Normal file
964
meshcore_gui/ble/worker.py.bak
Normal 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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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
|
||||
|
||||
543
meshcore_gui/gui/panels/bbs_panel.py
Normal file
543
meshcore_gui/gui/panels/bbs_panel.py
Normal 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()
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 (6–64 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(
|
||||
|
||||
@@ -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){'
|
||||
|
||||
395
meshcore_gui/meshcore_gui/config.py
Normal file
395
meshcore_gui/meshcore_gui/config.py
Normal 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.
|
||||
850
meshcore_gui/meshcore_gui/gui/dashboard.py
Normal file
850
meshcore_gui/meshcore_gui/gui/dashboard.py
Normal 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()
|
||||
18
meshcore_gui/meshcore_gui/gui/panels/__init__.py
Normal file
18
meshcore_gui/meshcore_gui/gui/panels/__init__.py
Normal 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
|
||||
614
meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
Normal file
614
meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
Normal 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()
|
||||
302
meshcore_gui/meshcore_gui/services/bbs_config_store.py
Normal file
302
meshcore_gui/meshcore_gui/services/bbs_config_store.py
Normal 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)
|
||||
468
meshcore_gui/meshcore_gui/services/bbs_service.py
Normal file
468
meshcore_gui/meshcore_gui/services/bbs_service.py
Normal 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}"
|
||||
)
|
||||
221
meshcore_gui/meshcore_gui/services/bot.py
Normal file
221
meshcore_gui/meshcore_gui/services/bot.py
Normal 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})"
|
||||
407
meshcore_gui/services/bbs_config_store.py
Normal file
407
meshcore_gui/services/bbs_config_store.py
Normal 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
|
||||
699
meshcore_gui/services/bbs_service.py
Normal file
699
meshcore_gui/services/bbs_service.py
Normal 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}"
|
||||
@@ -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():
|
||||
|
||||
@@ -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('&', '&')
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user