fix(bbs_service): resolve NameError in _abbrev_table that crashed !h and !help(#v1.14.1)

_abbrev_table used a list comprehension inline inside a generator
expression filter. In Python 3, list comprehensions have their own
scope, so the loop variable 'cu' was not visible to the outer 'if'
condition — causing a NameError on every !h / !help DM command.

Extract the comprehension to a local variable 'cats_upper' so both
the iteration and the filter operate on the same pre-built list.
This commit is contained in:
pe1hvh
2026-03-16 11:20:55 +01:00
parent 298760f861
commit 8836d9dd6e
23 changed files with 197 additions and 3722 deletions

View File

@@ -30,6 +30,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver
> lower CPU usage during idle operation, and more stable map rendering.
---
## [1.14.3] - 2026-03-16 — BBS !h / !help NameError fix
### Fixed
- 🐛 **`services/bbs_service.py`** — `!h` en `!help` DM-commando's gooiden een `NameError: name 'cu' is not defined` in `_abbrev_table()`.
- **Root cause**: `cu` werd gedefinieerd in een inner list comprehension `[cu.upper() for cu in categories]`, maar Python 3 list comprehensions hebben een eigen scope. De `if cu.upper() in inv` in de buitenste generator expression kon `cu` daardoor niet bereiken.
- **Fix**: list comprehension extracted naar een aparte variabele `cats_upper`; de generator itereert nu over die lijst.
## [1.14.2] - 2026-03-16 — BBS whitelist fix: !bbs channel hook in on_rx_log
### Fixed
- 🐛 **`ble/events.py`** — `!bbs` op een geconfigureerd BBS-channel deed nooit een whitelist-add, waardoor `!h` en andere DM-BBS-commando's daarna silently werden gedropped.
- **Root cause**: de BBS channel hook stond uitsluitend in `on_channel_msg`, maar `on_channel_msg` wordt in het normale pad onderdrukt door de content-dedup early-return (het bericht is dan al door `on_rx_log` verwerkt en gemarkeerd).
- **Fix**: BBS channel hook (`handle_channel_msg`) ook aangeroepen in `on_rx_log`, direct ná de bot-aanroep, binnen de `GroupText + channel_idx resolved`-branch. `sender_pubkey` is daar al opgelost via `get_contact_by_name`.
- De hook in `on_channel_msg` blijft intact als fallback voor het deferred-path (channel_idx onopgelost in `on_rx_log`).
## [1.14.1] - 2026-03-16 — BBS test corrections
### Changed
- Testing package flattened to a single canonical `meshcore_gui/...` tree so runtime and validation target one code path.
- `!bbs` channel bootstrap, DM-only `!h` / `!help`, and chunked BBS reply work were applied as in-progress fixes under version `1.14.1` while testing continues.
- No release bump: version numbering is kept at `1.14.1` for this test set.
## [1.14.0] - 2026-03-14 — BBS (Bulletin Board System)
### Added

View File

@@ -1,231 +0,0 @@
# Map Architecture — MeshCore GUI
## Overview
The MeshCore GUI map subsystem is implemented as a **browser-managed Leaflet runtime** embedded inside a NiceGUI container.
The key design decision is that the **map lifecycle is owned by the browser**, not by the Python UI update loop.
NiceGUI acts only as a container and data provider.
This architecture prevents map resets, marker flicker, and viewport jumps during the 500 ms dashboard refresh cycle.
---
# Architecture
```
NiceGUI Dashboard
│ snapshot (500 ms)
MapPanel (Python)
│ JSON payload
Leaflet Runtime (Browser)
├─ Map instance (persistent)
├─ Marker registry
├─ Contact cluster layer
├─ Theme state
└─ Viewport state
```
---
# Component Responsibilities
## MapPanel (Python)
Location:
```
meshcore_gui/gui/panels/map_panel.py
```
Responsibilities:
* provides the map container
* injects the Leaflet runtime assets
* sends compact map snapshots
* handles UI actions:
* theme toggle
* center on device
MapPanel **does NOT control the Leaflet map directly**.
It only sends data.
---
## MapSnapshotService
Location:
```
meshcore_gui/services/map_snapshot_service.py
```
Responsibilities:
* converts device/contact data into a compact JSON snapshot
* ensures stable node identifiers
* prepares payloads for the browser runtime
Example snapshot structure:
```json
{
"device": {...},
"contacts": [...],
"force_center": false
}
```
Snapshots are emitted every **500 ms** by the dashboard update loop.
---
## Leaflet Runtime
Location:
```
meshcore_gui/static/leaflet_map_panel.js
```
Responsibilities:
* initialize the Leaflet map once
* maintain persistent map instance
* manage marker registry
* maintain a persistent contact cluster layer
* keep the own-device marker outside clustering
* apply snapshots incrementally
* manage map theme and viewport state
Key design rules:
```
map is created once
markers updated incrementally
snapshots never recreate the map
clustering is attached only after maxZoom is known
```
---
# Update Flow
```
SharedData
Dashboard update loop (500 ms)
MapSnapshotService
MapPanel
Leaflet Runtime
```
Snapshots are **coalesced** so the browser applies only the newest payload.
---
# Theme Handling
Theme changes are handled via a **dedicated theme channel**.
Snapshots do **not** carry theme information.
Reason:
Embedding theme state in snapshots caused race conditions where queued snapshots overwrote explicit user selections.
Theme state is managed in the browser runtime and restored on reconnect.
---
# Marker Model
Markers are keyed by **stable node id**.
```
device marker (standalone)
contact markers (clustered)
```
Updates are applied incrementally:
```
add marker
update marker
remove marker
```
This prevents marker flicker during the refresh loop.
---
# Important Constraints
Developers must **not**:
* recreate the Leaflet map inside the dashboard refresh loop
* call `L.map(...)` from snapshot handlers, retry loops or timer callbacks
* embed theme state in snapshots
* call Leaflet APIs directly from Python
* force viewport resets during normal snapshot updates
* place the device marker inside the contact cluster layer
Violating these rules will reintroduce:
* disappearing maps
* marker flicker
* viewport resets
* theme resets
---
# Reconnect Behaviour
When the NiceGUI connection temporarily drops:
1. the Leaflet runtime persists in the browser
2. the map instance remains intact
3. theme and viewport state are restored
4. snapshot updates resume once the connection returns
---
# Future Extensions
Possible improvements without breaking the architecture:
* heatmap layers
* route overlays
* tile provider switching
* richer cluster icons or spiderfy tuning
All extensions must remain **browser-managed**.
---
# Summary
The MeshCore map subsystem follows a strict separation:
```
Python → data
Browser → map lifecycle
```
This prevents UI refresh cycles from interfering with map state and ensures smooth rendering even with frequent dashboard updates.

View File

@@ -956,7 +956,7 @@ Retain: 48 hours
▶ Advanced
Regions (comma-separated)
Allowed keys (empty = auto-learned from channel activity)
Allowed keys (DM-BBS whitelist)
```
- **Channels** — check all channels whose participants should have access to the BBS. Multiple channels can be selected.

View File

@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
VERSION: str = "1.14.0"
VERSION: str = "1.14.1"
# ==============================================================================

View File

@@ -0,0 +1,30 @@
# BBS channel reply sent as broadcast when sender is unknown
**Component:** BBS service
**Version:** 1.14.1
**Priority:** Low
**Type:** Known Limitation
---
## Description
When a node sends `!bbs` on the BBS channel but is not yet known to the BBS node,
the help text reply is broadcast to the entire channel instead of sent privately
to the sender.
---
## Workaround
Ensure the sender has been in RF contact with the BBS node at least once before
using the BBS. Under normal operating conditions this happens automatically.
---
## Possible solution
When the BBS node receives `!bbs` from an unknown sender, reply on the channel
with a short message instructing the sender to send a direct ADVERT request first.
Once the ADVERT is received, the BBS node knows the sender's identity and can
deliver all further replies as private messages.

View File

@@ -27,6 +27,7 @@ Leaflet Runtime (Browser)
├─ Map instance (persistent)
├─ Marker registry
├─ Contact cluster layer
├─ Theme state
└─ Viewport state
```
@@ -100,6 +101,8 @@ Responsibilities:
* initialize the Leaflet map once
* maintain persistent map instance
* manage marker registry
* maintain a persistent contact cluster layer
* keep the own-device marker outside clustering
* apply snapshots incrementally
* manage map theme and viewport state
@@ -109,6 +112,7 @@ Key design rules:
map is created once
markers updated incrementally
snapshots never recreate the map
clustering is attached only after maxZoom is known
```
---
@@ -154,8 +158,8 @@ Theme state is managed in the browser runtime and restored on reconnect.
Markers are keyed by **stable node id**.
```
device marker
contact markers
device marker (standalone)
contact markers (clustered)
```
Updates are applied incrementally:
@@ -175,9 +179,11 @@ This prevents marker flicker during the refresh loop.
Developers must **not**:
* recreate the Leaflet map inside the dashboard refresh loop
* call `L.map(...)` from snapshot handlers, retry loops or timer callbacks
* embed theme state in snapshots
* call Leaflet APIs directly from Python
* force viewport resets during normal snapshot updates
* place the device marker inside the contact cluster layer
Violating these rules will reintroduce:
@@ -203,10 +209,10 @@ When the NiceGUI connection temporarily drops:
Possible improvements without breaking the architecture:
* marker clustering
* heatmap layers
* route overlays
* tile provider switching
* richer cluster icons or spiderfy tuning
All extensions must remain **browser-managed**.

15
install_scripts/install_serial.sh Executable file → Normal file
View File

@@ -64,13 +64,16 @@ fi
CURRENT_USER="$(whoami)"
VENV_PYTHON="${PROJECT_DIR}/venv/bin/python"
# Check venv
# Check venv / bootstrap dependencies when missing
if [[ ! -x "${VENV_PYTHON}" ]]; then
error "Virtual environment not found at: ${VENV_PYTHON}
Create it first:
python3 -m venv venv
source venv/bin/activate
pip install meshcore nicegui meshcoredecoder"
info "Virtual environment not found. Creating project venv..."
python3 -m venv "${PROJECT_DIR}/venv"
info "Installing required Python packages into the venv..."
# shellcheck disable=SC1091
source "${PROJECT_DIR}/venv/bin/activate"
pip install nicegui meshcore meshcoredecoder
ok "Virtual environment created and dependencies installed"
fi
# Determine the entry point

View File

@@ -1,922 +0,0 @@
# CHANGELOG
<!-- CHANGED: Title changed from "CHANGELOG: Message & Metadata Persistence" to "CHANGELOG" —
a root-level CHANGELOG.md should be project-wide, not feature-specific. -->
All notable changes to MeshCore GUI are documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
---
> **📈 Performance note — v1.13.1 through v1.13.4**
> Although versions 1.13.11.13.4 were released as targeted bugfix releases, the
> cumulative effect of the fixes delivered a significant performance improvement:
>
> - **v1.13.1** — Bot non-response fix eliminated a silent failure path that caused
> repeated dedup-marked command re-evaluation on every message tick.
> - **v1.13.2** — Map display fixes prevented Leaflet from being initialized on hidden
> zero-size containers, removing a source of repeated failed bootstrap retries and
> associated DOM churn.
> - **v1.13.3** — Active panel timer gating reduced the 500 ms dashboard update work to
> only the currently visible panel, cutting unnecessary UI updates and background
> redraw load substantially — especially noticeable over VPN or on slower hardware.
> - **v1.13.4** — Room Server event classification fix and sender name resolution removed
> redundant fallback processing paths and reduced per-tick contact lookup overhead.
>
> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching,
> lower CPU usage during idle operation, and more stable map rendering.
---
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
### Added
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`.
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`.
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel voor het dashboard.
- Board-selector (knoppen per geconfigureerd board).
- Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft).
- Scrollbare berichtenlijst over alle channels van het actieve board.
- Post-formulier: post op het eerste channel van het board.
- **Settings-sectie**: boards aanmaken (naam → Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieën, regio's, retentie, whitelist, Save en Delete.
### Changed
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`.
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd; versie `1.14.0`.
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `📋 BBS` drawer-item.
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
### Storage
```
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
```
### Not changed
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
---
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
### Fixed
- 🛠 **Route page back-button navigated to main menu regardless of origin** — the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page.
- 🛠 **Map marker popup flickered on every 500 ms update tick** — the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible.
- 🛠 **Map marker popup appeared with a flicker/flash on first click (main map and route map)** — Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts.
### Changed
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`.
- 🔄 `meshcore_gui/static/leaflet_map_panel.js``applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`.
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.5`.
### Impact
- Back navigation from the route page now always returns to the correct origin screen.
- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed.
- Popup opening is instant on both maps; no animation artefacts on low-power hardware.
---
## [1.13.4] - 2026-03-12 — Room Server message classification fix
### Fixed
- 🛠 **Incoming room messages from other participants could be misclassified as normal DMs**`CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`.
- 🛠 **Incoming room traffic could be attached to the wrong key** — room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`.
- 🛠 **Room login UI could stay out of sync with the actual server-confirmed state**`LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key.
- 🛠 **Room Server panel showed hex codes instead of sender names** — when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known.
### Changed
- 🔄 `meshcore_gui/ble/events.py` — Broadened room payload parsing and added payload-key debug logging for incoming room traffic.
- 🔄 `meshcore_gui/ble/worker.py``LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history.
- 🔄 `meshcore_gui/config.py` — Version kept at `1.13.4`.
### Impact
- Keeps the existing Room Server panel logic intact.
- Fix is limited to room event classification and room login confirmation handling.
- No intended behavioural change for ordinary DMs or channel messages.
---
---
## [1.13.3] - 2026-03-12 — Active Panel Timer Gating
### Changed
- 🔄 `meshcore_gui/gui/dashboard.py` — The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only
- 🔄 `meshcore_gui/gui/dashboard.py` — Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick
- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.3`
### Fixed
- 🛠 **Hidden panels still refreshed every 500 ms** — Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active
- 🛠 **Map bootstrap activity while panel is not visible** — Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic
- 🛠 **Slow navigation over VPN** — Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel
### Impact
- Faster panel switching because the selected panel gets one direct refresh immediately
- Lower background UI/update load on dashboard level, especially when the map panel is not active
- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage
- No intended functional regression for route maps or visible panel behaviour
---
## [1.13.2] - 2026-03-11 — Map Display Bugfix
### Fixed
- 🛠 **MAP panel blank when contacts list is empty at startup** — dashboard update loop
had two separate conditional map-update blocks that both silently stopped firing after
tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and
remained blank indefinitely.
- 🛠 **Leaflet map initialized on hidden (zero-size) container**`processPending` in
the browser runtime called `L.map()` on the host element while it was still
`display:none` (Vue v-show, panel not yet visible). This produced a broken 0×0 map
that never recovered because `ensureMap` returned the cached broken state on all
subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`:
initialization is deferred until the host has real dimensions.
- 🛠 **Route map container had no height**`route_page.py` used the Tailwind class
`h-96` for the Leaflet host `<div>`. NiceGUI/Quasar does not include Tailwind CSS,
so `h-96` had no effect and the container rendered at height 0. Leaflet initialized
on a zero-height element and produced a blank map.
- 🛠 **Route map not rendered when no node has GPS coordinates**`_render_map`
returned early before creating the Leaflet container when `payload['nodes']` was
empty. Fixed: container is always created; a notice label is shown instead.
### Changed
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Added size guard in `ensureMap`:
returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map
state exists yet. `processPending` retries on the next tick once the panel is visible.
- 🔄 `meshcore_gui/gui/dashboard.py` — Consolidated two conditional map-update blocks
into a single unconditional update while the MAP panel is active. Added `h-96` to the
DOMCA CSS height overrides for consistency with the route page map container.
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced `h-96` Tailwind class on the route
map host `<div>` with an explicit inline `style` (height: 24rem). Removed early
`return` guard so the Leaflet container is always created.
### Impact
- MAP panel now renders reliably on first open regardless of contact/GPS availability
- Route map now always shows with correct height even when route nodes have no GPS
- No breaking changes outside the three files listed above
---
## [1.13.0] - 2026-03-09 — Leaflet Map Runtime Stabilization
### Added
-`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
### 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
---
## [1.12.1] - 2026-03-08 — Minor change bot
### Changed
- 🔄 `meshcore_gui/services/bot.py`: remove path id's
### Impact
- No breaking changes — all existing functionality preserved serial.
---
## [1.12.0] - 2026-02-26 — MeshCore Observer Fase 1
### Added
-**MeshCore Observer daemon** — New standalone read-only daemon (`meshcore_observer.py`) that reads archive JSON files produced by meshcore_gui and meshcore_bridge, aggregates them, and presents a unified NiceGUI monitoring dashboard on port 9093.
-**ArchiveWatcher** — Core component that polls `~/.meshcore-gui/archive/` for `*_messages.json` and `*_rxlog.json` files, tracks mtime changes, and returns only new entries since previous poll. Thread-safe, zero writes, graceful on corrupt JSON.
-**Observer dashboard panels** — Sources overview, aggregated messages feed (sorted by timestamp), aggregated RX log table, and statistics panel with uptime/counters/per-source breakdown. Full DOMCA theme (dark + light mode).
-**Source filter** — Dropdown to filter messages and RX log by archive source.
-**Channel filter** — Dropdown to filter messages by channel name.
-**ObserverConfig** — YAML-based configuration with `from_yaml()` classmethod, defaults work without config file.
-**observer_config.yaml** — Documented config template with all options.
-**install_observer.sh** — systemd installer (`/opt/meshcore-observer/`, `/etc/meshcore/observer_config.yaml`), with `--uninstall` option.
-**RxLogEntry raw packet fields** — 5 new fields on `RxLogEntry` dataclass: `raw_payload`, `packet_len`, `payload_len`, `route_type`, `packet_type_num` (all with defaults, backward compatible).
-**EventHandler.on_rx_log() metadata** — Raw payload hex and packet metadata now passed through to RxLogEntry and archived (preparation for Fase 2 LetsMesh uplink).
### Changed
- 🔄 `meshcore_gui/core/models.py`: RxLogEntry +5 fields with defaults (backward compatible).
- 🔄 `meshcore_gui/ble/events.py`: on_rx_log() fills raw_payload and metadata (~10 lines added).
- 🔄 `meshcore_gui/services/message_archive.py`: add_rx_log() serializes the 5 new RxLogEntry fields.
- 🔄 `meshcore_gui/config.py`: Version bumped to `1.12.0`.
### Impact
- **No breaking changes** — All new RxLogEntry fields have defaults; existing archives and code work identically.
- **New daemon** — meshcore_observer is fully standalone; no imports from meshcore_gui (reads only JSON files).
---
### Added
-**Serial CLI flags**`--baud=BAUD` and `--serial-cx-dly=SECONDS` for serial configuration at startup.
### Changed
- 🔄 **Connection layer** — Switched from BLE to serial (`MeshCore.create_serial`) with serial reconnect handling.
- 🔄 `config.py`: Added `SERIAL_BAUDRATE`, `SERIAL_CX_DELAY`, `DEFAULT_TIMEOUT`, `MESHCORE_LIB_DEBUG`; removed BLE PIN settings; version bumped to `1.10.0`.
- 🔄 `meshcore_gui.py` / `meshcore_gui/__main__.py`: Updated usage, banners and defaults for serial ports.
- 🔄 Docs: Updated README and core docs for serial usage; BLE documents marked as legacy.
### Impact
- No breaking changes — all existing functionality preserved serial.
---
## [1.9.11] - 2026-02-19 — Message Dedup Hotfix
### Fixed
- 🛠 **Duplicate messages after (re)connect**`load_recent_from_archive()` appended archived messages on every connect attempt without clearing existing entries; after N failed connects, each message appeared N times. Method is now idempotent: clears the in-memory list before loading.
- 🛠 **Persistent duplicate messages** — Live BLE events for messages already loaded from archive were not suppressed because the `DualDeduplicator` was never seeded with archived content. Added `_seed_dedup_from_messages()` in `BLEWorker` after cache/archive load and after reconnect.
- 🛠 **Last-line-of-defence dedup in SharedData**`add_message()` now maintains a fingerprint set (`message_hash` or `channel:sender:text`) and silently skips messages whose fingerprint is already tracked. This guards against duplicates regardless of their source.
- 🛠 **Messages panel empty on first click**`_show_panel()` made the container visible but relied on the next 500 ms timer tick to populate it. Added an immediate `_messages.update()` call so content is rendered the moment the panel becomes visible.
### Changed
- 🔄 `core/shared_data.py`: Added `_message_fingerprints` set and `_message_fingerprint()` static method; `add_message()` checks fingerprint before insert and evicts fingerprints when messages are rotated out; `load_recent_from_archive()` clears messages and fingerprints before loading (idempotent)
- 🔄 `ble/worker.py`: Added `_seed_dedup_from_messages()` helper; called after `_apply_cache()` and after reconnect `_load_data()` to seed `DualDeduplicator` with existing messages
- 🔄 `gui/dashboard.py`: `_show_panel()` now forces an immediate `_messages.update()` when the messages panel is shown, eliminating the stale-content flash
- 🔄 `config.py`: Version bumped to `1.9.11`
### Impact
- Eliminates all duplicate message display scenarios: initial connect, failed retries, reconnect, and BLE event replay
- No breaking changes — all existing functionality preserved
- Fingerprint set is bounded to the same 100-message cap as the message list
---
## [1.9.10] - 2026-02-19 — Map Tooltips & Separate Own-Position Marker
### Added
-**Map marker tooltips** — All markers on the Leaflet map now show a tooltip on hover with the node name and type icon (📱, 📡, 🏠) from `TYPE_ICONS`
-**Separate own-position marker** — The device's own position is now tracked as a dedicated `_own_marker`, independent from contact markers. This prevents the own marker from being removed/recreated on every contact update cycle
### Changed
- 🔄 `gui/panels/map_panel.py`: Renamed `_markers` to `_contacts_markers`; added `_own_marker` attribute; own position marker is only updated when `device_updated` flag is set (not every timer tick); contact markers are only rebuilt when `contacts_updated` is set; added `TYPE_ICONS` import for tooltip icons
- 🔄 `gui/dashboard.py`: Added `self._map.update(data)` call in the `device_updated` block so the own-position marker updates when device info changes (e.g. GPS position update)
- 🔄 `config.py`: Version bumped to `1.9.10`
### Impact
- Map centering on own device now works correctly and updates only when position actually changes
- Contact markers are no longer needlessly destroyed and recreated on every UI timer tick — only on actual contact data changes
- Tooltips make it easy to identify nodes on the map without clicking
- No breaking changes — all existing map functionality preserved
### Credits
- Based on [PR #16](https://github.com/pe1hvh/meshcore-gui/pull/16) by [@rich257](https://github.com/rich257)
---
## [1.9.9] - 2026-02-18 — Variable Landing Page & Operator Callsign
### Added
-**Configurable operator callsign** — New `OPERATOR_CALLSIGN` constant in `config.py` (default: `"PE1HVH"`). Used in the landing page SVG and the drawer footer copyright label. Change this single value to personalize the entire GUI for a different operator
-**External landing page SVG** — The DOMCA splash screen is now loaded from a standalone file (`static/landing_default.svg`) instead of being hardcoded in `dashboard.py`. New `LANDING_SVG_PATH` constant in `config.py` points to the SVG file. The placeholder `{callsign}` in the SVG is replaced at runtime with `OPERATOR_CALLSIGN`
-**Landing page customization** — To use a custom landing page: copy `landing_default.svg` (or create your own SVG), use `{callsign}` wherever the operator callsign should appear, and point `LANDING_SVG_PATH` to your file. The default SVG includes an instructive comment block explaining the placeholder mechanism
### Changed
- 🔄 `config.py`: Added `OPERATOR_CALLSIGN` and `LANDING_SVG_PATH` constants in new **OPERATOR / LANDING PAGE** section; version bumped to `1.9.9`
- 🔄 `gui/dashboard.py`: Removed hardcoded `_DOMCA_SVG` string (~70 lines); added `_load_landing_svg()` helper that reads SVG from disk and replaces `{callsign}` placeholder; CSS variable `--pe1hvh` renamed to `--callsign`; drawer footer copyright label now uses `config.OPERATOR_CALLSIGN`
### Added (files)
-`static/landing_default.svg` — The original DOMCA splash SVG extracted as a standalone file, with `{callsign}` placeholder and `--callsign` CSS variable. Serves as both the default landing page and a reference template for custom SVGs
### Impact
- Out-of-the-box behavior is identical to v1.9.8 (same DOMCA branding, same PE1HVH callsign)
- Operators personalize by changing 12 lines in `config.py` — no code modifications needed
- Fallback: if the SVG file is missing, a minimal placeholder text is shown instead of a crash
- No breaking changes — all existing dashboard functionality (panels, menus, timer, theming) unchanged
---
## [1.9.8] - 2026-02-17 — Bugfix: Route Page Sender ID, Type & Location Not Populated
### Fixed
- 🛠 **Sender ID, Type and Location empty in Route Page** — After the v4.1 refactoring to `RouteBuilder`/`RouteNode`, the sender contact lookup relied solely on `SharedData.get_contact_by_prefix()` (live lock-based) and `get_contact_by_name()`. When both failed (e.g. empty `sender_pubkey` from RX_LOG decode, or name mismatch), `route['sender']` remained `None` and the route table fell through to a hardcoded fallback with `type: '-'`, `location: '-'`. The contact data was available in the snapshot `data['contacts']` but was never searched
- 🛠 **Route table fallback row ignored available contact data** — When `route['sender']` was `None`, the `_render_route_table` method used a static fallback row without attempting to find the contact in the data snapshot. Even when the contact was present in `data['contacts']` with valid type and location, these fields showed as `'-'`
### Changed
- 🔄 `services/route_builder.py`: Added two additional fallback strategies in `build()` after the existing SharedData lookups: (3) bidirectional pubkey prefix match against `data['contacts']` snapshot, (4) case-insensitive `adv_name` match against `data['contacts']` snapshot. Added helper methods `_find_contact_by_pubkey()` and `_find_contact_by_adv_name()` for snapshot-based lookups
- 🔄 `gui/route_page.py`: Added defensive fallback in `_render_route_table()` sender section — when `route['sender']` is `None`, attempts to find the contact in the snapshot via `_find_sender_contact()` before falling back to the static `'-'` row. Added `_find_sender_contact()` helper method
### Impact
- Sender ID (hash), Type and Location are now populated correctly in the route table when the contact is known
- Four-layer lookup chain ensures maximum resolution: (1) SharedData pubkey lookup, (2) SharedData name lookup, (3) snapshot pubkey lookup, (4) snapshot name lookup
- Defensive fallback in route_page guarantees data is shown even if RouteBuilder misses it
- No breaking changes — all existing route page behavior, styling and data flows unchanged
---
## [1.9.7] - 2026-02-17 — Layout Fix: Archive Filter Toggle & Route Page Styling
### Changed
- 🔄 `gui/archive_page.py`: Archive filter card now hidden by default; toggle visibility via a `filter_list` icon button placed right-aligned on the same row as the "📚 Archive" title. Header restructured from single label to `ui.row()` with `justify-between` layout
- 🔄 `gui/route_page.py`: Route page now uses DOMCA theme (imported from `dashboard.py`) with dark mode as default, consistent with the main dashboard. Header restyled from `bg-blue-600` to Quasar-themed header with JetBrains Mono font. Content container changed from `w-full max-w-4xl mx-auto` to `domca-panel` class for consistent responsive sizing
- 🔄 `gui/dashboard.py`: Added `domca-header-text` CSS class with `@media (max-width: 599px)` rule to hide header text on narrow viewports; applied to version label and status label
- 🔄 `gui/route_page.py`: Header label also uses `domca-header-text` class for consistent responsive behaviour
### Added
-**Archive filter toggle**`filter_list` icon button in archive header row toggles the filter card visibility on click
-**Route page close button**`X` (close) icon button added right-aligned in the route page header; calls `window.close()` to close the browser tab
-**Responsive header** — On viewports < 600px, header text labels are hidden; only icon buttons (menu, dark mode toggle, close) remain visible
### Impact
- Archive page is cleaner by default — filters only shown when needed
- Route page visually consistent with the main dashboard (DOMCA theme, dark mode, responsive panel width)
- Headers degrade gracefully on mobile (< 600px): only icon buttons visible, no text overflow
- No functional changes — all event handlers, callbacks, data bindings, logic and imports are identical to the input
---
## [1.9.6] - 2026-02-17 — Bugfix: Channel Discovery Reliability
### Fixed
- 🛠 **Channels not appearing (especially on mobile)** — Channel discovery aborted too early on slow BLE connections. The `_discover_channels()` probe used a single attempt per channel slot and stopped after just 2 consecutive empty responses. On mobile BLE stacks (WebBluetooth via NiceGUI) where GATT responses are slower, this caused discovery to abort before finding any channels, falling back to only `[0] Public`
- 🛠 **Race condition: channel update flag lost between threads**`get_snapshot()` and `clear_update_flags()` were two separate calls, each acquiring the lock independently. If the BLE worker set `channels_updated = True` between these two calls, the GUI consumed the flag via `get_snapshot()` but then `clear_update_flags()` reset it — causing the channel submenu and dropdown to never populate
- 🛠 **Channels disappear on browser reconnect** — When a browser tab is closed and reopened, `render()` creates new (empty) NiceGUI containers for the drawer submenus, but did not reset `_last_channel_fingerprint`. The `_update_submenus()` method compared the new fingerprint against the stale one, found them equal, and skipped the rebuild — leaving the new containers permanently empty. Fixed by resetting both `_last_channel_fingerprint` and `_last_rooms_fingerprint` in `render()`
### Changed
- 🔄 `core/shared_data.py`: New atomic method `get_snapshot_and_clear_flags()` that reads the snapshot and resets all update flags in a single lock acquisition. Internally refactored to `_build_snapshot_unlocked()` helper. Existing `get_snapshot()` and `clear_update_flags()` retained for backward compatibility
- 🔄 `ble/worker.py`: `_discover_channels()``max_attempts` increased from 1 to 2 per channel slot; inter-attempt `delay` increased from 0.5s to 1.0s; consecutive error threshold raised from 2 to 3; inter-channel pause increased from 0.15s to 0.3s for mobile BLE stack breathing room
- 🔄 `gui/dashboard.py`: `_update_ui()` now uses `get_snapshot_and_clear_flags()` instead of separate `get_snapshot()` + `clear_update_flags()`; `render()` now resets `_last_channel_fingerprint` and `_last_rooms_fingerprint` to `None` so that `_update_submenus()` rebuilds into the freshly created containers; channel-dependent updates (`update_filters`, `update_channel_options`, `_update_submenus`) now run unconditionally when channel data exists — safe because each method has internal idempotency checks
- 🔄 `gui/panels/messages_panel.py`: `update_channel_options()` now includes an equality check on options dict to skip redundant `.update()` calls to the NiceGUI client on every 500ms timer tick
### Impact
- Channel discovery now survives transient BLE timeouts that are common on mobile connections
- Atomic snapshot eliminates the threading race condition that caused channels to silently never appear
- Browser close+reopen no longer loses channels — the single-instance timer race on the shared `DashboardPage` is fully mitigated
- No breaking changes — all existing API methods retained, all other functionality unchanged
---
## [1.9.5] - 2026-02-16 — Layout Fix: RX Log Table Responsive Sizing
### Fixed
- 🛠 **RX Log table did not adapt to panel/card size** — The table used `max-h-48` (a maximum height cap) instead of a responsive fixed height, causing it to remain small regardless of available space. Changed to `h-40` which is overridden by the existing dashboard CSS to `calc(100vh - 20rem)` — the same responsive pattern used by the Messages panel
- 🛠 **RX Log table did not fill card width** — Added `w-full` class to the table element so it stretches to the full width of the parent card
- 🛠 **RX Log card did not fill panel height** — Added `flex-grow` class to the card container so it expands to fill the available panel space
### Changed
- 🔄 `gui/panels/rxlog_panel.py`: Card classes `'w-full'``'w-full flex-grow'` (line 45); table classes `'text-xs max-h-48 overflow-y-auto'``'w-full text-xs h-40 overflow-y-auto'` (line 65)
### Impact
- RX Log table now fills the panel consistently on both desktop and mobile viewports
- Layout is consistent with other panels (Messages, Contacts) that use the same `h-40` responsive height pattern
- No functional changes — all event handlers, callbacks, data bindings, logica and imports are identical to the input
---
## [1.9.4] - 2026-02-16 — BLE Address Log Prefix & Entry Point Cleanup
### Added
-**BLE address prefix in log filename** — Log file is now named `<BLE_ADDRESS>_meshcore_gui.log` (e.g. `AA_BB_CC_DD_EE_FF_meshcore_gui.log`) instead of the generic `meshcore_gui.log`. Makes it easy to identify which device produced which log file when running multiple instances
- New helper `_sanitize_ble_address()` strips `literal:` prefix and replaces colons with underscores
- New function `configure_log_file(ble_address)` updates `LOG_FILE` at runtime before the logger is initialised
- Rotated backups follow the same naming pattern automatically
### Removed
-**`meshcore_gui/meshcore_gui.py`** — Redundant copy of `main()` that was never imported. All three entry points (`meshcore_gui.py` root, `__main__.py`, and `meshcore_gui/meshcore_gui.py`) contained near-identical copies of the same logic, causing changes to be missed (as demonstrated by this fix). `__main__.py` is now the single source of truth; root `meshcore_gui.py` is a thin wrapper that imports from it
### Changed
- 🔄 `config.py`: Added `_sanitize_ble_address()` and `configure_log_file()`; version bumped to `1.9.4`
- 🔄 `__main__.py`: Added `config.configure_log_file(ble_address)` call before any debug output
- 🔄 `meshcore_gui.py` (root): Reduced to 4-line wrapper importing `main` from `__main__`
### Impact
- Log files are now identifiable per BLE device
- Single source of truth for `main()` eliminates future sync issues between entry points
- Both startup methods (`python meshcore_gui.py` and `python -m meshcore_gui`) remain functional
- No breaking changes — defaults and all existing behaviour unchanged
---
## [1.9.3] - 2026-02-16 — Bugfix: Map Default Location & Payload Type Decoding
### Fixed
- 🛠 **Map centred on hardcoded Zwolle instead of device location** — All Leaflet maps used magic-number coordinates `(52.5, 6.0)` as initial centre and fallback. These are now replaced by a single configurable constant `DEFAULT_MAP_CENTER` in `config.py`. Once the device reports a valid `adv_lat`/`adv_lon`, maps re-centre on the actual device position (existing behaviour, unchanged)
- 🛠 **Payload type shown as raw integer** — Payload type is now retrieved from the decoded payload and translated to human-readable text using MeshCoreDecoder functions, instead of displaying the raw numeric type value
### Changed
- 🔄 `config.py`: Added `DEFAULT_MAP_CENTER` (default: `(52.5168, 6.0830)`) and `DEFAULT_MAP_ZOOM` (default: `9`) constants in new **MAP DEFAULTS** section. Version bumped to `1.9.2`
- 🔄 `gui/panels/map_panel.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; `ui.leaflet(center=...)` uses config constants instead of hardcoded values
- 🔄 `gui/route_page.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; fallback coordinates (`or 52.5` / `or 6.0`) replaced by `DEFAULT_MAP_CENTER[0]` / `[1]`; zoom uses `DEFAULT_MAP_ZOOM`
### Impact
- Map default location is now a single-point-of-change in `config.py`
- Payload type is displayed as readable text instead of a raw number
- No breaking changes — all existing map behaviour (re-centre on device position, contact markers) unchanged
## [1.9.2] - 2026-02-15 — CLI Parameters & Cleanup
### Added
-**`--port=PORT` CLI parameter** — Web server port is now configurable at startup (default: `8081`). Allows running multiple instances simultaneously on different ports
-**`--ble-pin=PIN` CLI parameter** — BLE pairing PIN is now configurable at startup (default: `123456`). Eliminates the need to edit `config.py` for devices with a non-default PIN, and works in systemd service files
-**Per-device log file** — Debug log file now includes the BLE address in its filename (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`), so multiple instances log to separate files
### Fixed
- 🛠 **BLE PIN not applied from CLI**`ble/worker.py` imported `BLE_PIN` as a constant at module load time (`from config import BLE_PIN`), capturing the default value `"123456"` before CLI parsing could override `config.BLE_PIN`. Changed to runtime access via `config.BLE_PIN` so the `--ble-pin` parameter is correctly passed to the BLE agent
### Removed
-**Redundant `meshcore_gui/meshcore_gui.py`** — This file was a near-identical copy of both `meshcore_gui.py` (top-level) and `meshcore_gui/__main__.py`, but was never imported or referenced. Removed to eliminate maintenance risk. The two remaining entry points cover all startup methods: `python meshcore_gui.py` and `python -m meshcore_gui`
### Impact
- Multiple instances can run side-by-side with different ports, PINs and log files
- Service deployments no longer require editing `config.py` — all runtime settings via CLI
- No breaking changes — all defaults are unchanged
---
## [1.9.1] - 2026-02-14 — Bugfix: Dual Reconnect Conflict
### Fixed
- 🛠 **Library reconnect interfered with application reconnect** — The meshcore library's internal `auto_reconnect` (visible in logs as `"Attempting reconnection 1/3"`) ran a fast 3-attempt reconnect cycle without bond cleanup. This prevented the application's own `reconnect_loop` (which does `remove_bond()` + backoff) from succeeding, because BlueZ retained a stale bond → `"failed to discover service"`
### Changed
- 🔄 `ble/worker.py`: Set `auto_reconnect=False` in both `MeshCore.create_ble()` call sites (`_connect()` and `_create_fresh_connection()`), so only the application's bond-aware `reconnect_loop` handles reconnection
- 🔄 `ble/worker.py`: Added `"failed to discover"` and `"service discovery"` to disconnect detection keywords for defensive coverage
### Impact
- Eliminates the ~9 second wasted library reconnect cycle after every BLE disconnect
- Application's `reconnect_loop` (with bond cleanup) now runs immediately after disconnect detection
- No breaking changes — the application reconnect logic was already fully functional
---
## [1.9.0] - 2026-02-14 — BLE Connection Stability
### Added
-**Built-in BLE PIN agent** — New `ble/ble_agent.py` registers a D-Bus agent with BlueZ to handle PIN pairing requests automatically. Eliminates the need for external `bt-agent.service` and `bluez-tools` package
- Uses `dbus_fast` (already a dependency of `bleak`, no new packages)
- Supports `RequestPinCode`, `RequestPasskey`, `DisplayPasskey`, `RequestConfirmation`, `AuthorizeService` callbacks
- Configurable PIN via `BLE_PIN` in `config.py` (default: `123456`)
-**Automatic bond cleanup** — New `ble/ble_reconnect.py` provides `remove_bond()` function that removes stale BLE bonds via D-Bus, equivalent to `bluetoothctl remove <address>`. Called automatically on startup and before each reconnect attempt
-**Automatic reconnect after disconnect** — BLEWorker main loop now detects BLE disconnects (via connection error exceptions) and automatically triggers a reconnect sequence: bond removal → linear backoff wait → fresh connection → re-wire handlers → reload device data
- Configurable via `RECONNECT_MAX_RETRIES` (default: 5) and `RECONNECT_BASE_DELAY` (default: 5.0s)
- After all retries exhausted: waits 60s then starts a new retry cycle (infinite recovery)
-**Generic install script**`install_ble_stable.sh` auto-detects user, project directory, venv path and entry point to generate systemd service and D-Bus policy. Supports `--uninstall` flag
### Changed
- 🔄 **`ble/worker.py`** — `_async_main()` rewritten with three phases: (1) start PIN agent, (2) remove stale bond, (3) connect + main loop with disconnect detection. Reconnect logic re-wires all event handlers and reloads device data after successful reconnection
- 🔄 **`config.py`** — Added `BLE_PIN`, `RECONNECT_MAX_RETRIES`, `RECONNECT_BASE_DELAY` constants
### Removed
-**`bt-agent.service` dependency** — No longer needed; PIN pairing is handled by the built-in agent
-**`bluez-tools` system package** — No longer needed
-**`~/.meshcore-ble-pin` file** — No longer needed
-**Manual `bluetoothctl remove` before startup** — Handled automatically
-**`ExecStartPre` in systemd service** — Bond cleanup is internal
### Impact
- Zero external dependencies for BLE pairing on Linux
- Automatic recovery from the T1000e ~2 hour BLE disconnect issue
- No manual intervention needed after BLE connection loss
- Single systemd service (`meshcore-gui.service`) manages everything
- No breaking changes to existing functionality
---
## [1.8.0] - 2026-02-14 — DRY Message Construction & Archive Layout Unification
### Fixed
- 🛠 **Case-sensitive prefix matching**`get_contact_name_by_prefix()` and `get_contact_by_prefix()` in `shared_data.py` failed to match path hashes (uppercase, e.g. `'B8'`) against contact pubkeys (lowercase, e.g. `'b8a3f2...'`). Added `.lower()` to both sides of the comparison, consistent with `_resolve_path_names()` which already had it
- 🛠 **Route page 404 from archive** — Archive page linked to `/route/{hash}` but route was registered as `/route/{msg_index:int}`, causing a JSON parse error for hex hash strings. Route parameter changed to `str` with 3-strategy lookup (index → memory hash → archive fallback)
- 🛠 **Three entry points out of sync**`meshcore_gui.py` (root), `meshcore_gui/meshcore_gui.py` (inner) and `meshcore_gui/__main__.py` had diverging route registrations. All three now use identical `/route/{msg_key}` with `str` parameter
### Changed
- 🔄 **`core/models.py` — DRY factory methods and formatting**
- `Message.now_timestamp()`: static method replacing 7× hardcoded `datetime.now().strftime('%H:%M:%S')` across `events.py` and `commands.py`
- `Message.incoming()`: classmethod factory for received messages (`direction='in'`, auto-timestamp)
- `Message.outgoing()`: classmethod factory for sent messages (`sender='Me'`, `direction='out'`, auto-timestamp)
- `Message.format_line(channel_names)`: single-line display formatting (`"12:34:56 ← [Public] [2h✓] PE1ABC: Hello mesh!"`), replacing duplicate inline formatting in `messages_panel.py` and `archive_page.py`
- 🔄 **`ble/events.py`** — 4× `Message(...)` constructors replaced by `Message.incoming()`; `datetime` import removed
- 🔄 **`ble/commands.py`** — 3× `Message(...)` constructors replaced by `Message.outgoing()`; `datetime` import removed
- 🔄 **`gui/panels/messages_panel.py`** — 15 lines inline formatting replaced by single `msg.format_line(channel_names)` call
- 🔄 **`gui/archive_page.py` — Layout unified with main page**
- Multi-row card layout replaced by single-line `msg.format_line()` in monospace container (same style as main page)
- DM added to channel filter dropdown (post-filter on `channel is None`)
- Message click opens `/route/{message_hash}` in new tab (was: no click handler on archive messages)
- Removed `_render_message_card()` (98 lines) and `_render_archive_route()` (75 lines)
- Removed `RouteBuilder` dependency and `TYPE_LABELS` import
- File reduced from 445 to 267 lines
- 🔄 **`gui/route_page.py`** — `render(msg_index: int)``render(msg_key: str)` with 3-strategy message lookup: (1) numeric index from in-memory list, (2) hash match in memory, (3) `archive.get_message_by_hash()` fallback
- 🔄 **`services/message_archive.py`** — New method `get_message_by_hash(hash)` for single-message lookup by packet hash
- 🔄 **`__main__.py` + `meshcore_gui.py` (both)** — Route changed from `/route/{msg_index}` (int) to `/route/{msg_key}` (str)
### Impact
- DRY: timestamp formatting 7→1 definition, message construction 7→2 factories, line formatting 2→1 method
- Archive page visually consistent with main messages panel (single-line, monospace)
- Archive messages now clickable to open route visualization (was: only in-memory messages)
- Case-insensitive prefix matching fixes path name resolution for contacts with uppercase path hashes
- No breaking changes to BLE protocol handling, dedup, bot, or data storage
### Known Limitations
- DM filter in archive uses post-filtering (query without channel filter + filter on `channel is None`); becomes exact when `query_messages()` gets native DM support
### Parked for later
- Multi-path tracking (enrich RxLogEntry with multiple path observations)
- Events correlation improvements (only if proven data loss after `.lower()` fix)
---
## [1.7.0] - 2026-02-13 — Archive Channel Name Persistence
### Added
-**Channel name stored in archive** — Messages now persist `channel_name` alongside the numeric `channel` index in `<ADDRESS>_messages.json`, so archived messages retain their human-readable channel name even when the device is not connected
- `Message` dataclass: new field `channel_name: str` (default `""`, backward compatible)
- `SharedData.add_message()`: automatically resolves `channel_name` from the live channels list when not already set (new helper `_resolve_channel_name()`)
- `MessageArchive.add_message()`: writes `channel_name` to the JSON dict
-**Archive channel selector built from archived data** — Channel filter dropdown on `/archive` now populated via `SELECT DISTINCT channel_name` on the archive instead of the live BLE channels list
- New method `MessageArchive.get_distinct_channel_names()` returns sorted unique channel names from stored messages
- Selector shows only channels that actually have archived messages
-**Archive filter on channel name**`MessageArchive.query_messages()` parameter changed from `channel: Optional[int]` to `channel_name: Optional[str]` (exact match on name string)
### Changed
- 🔄 `core/models.py`: Added `channel_name` field to `Message` dataclass and `from_dict()`
- 🔄 `core/shared_data.py`: `add_message()` resolves channel name; added `_resolve_channel_name()` helper
- 🔄 `services/message_archive.py`: `channel_name` persisted in JSON; `query_messages()` filters by name; new `get_distinct_channel_names()` method
- 🔄 `gui/archive_page.py`: Channel selector built from `archive.get_distinct_channel_names()`; filter state changed from `_channel_filter` (int) to `_channel_name_filter` (str); message cards show `channel_name` directly from archive
### Fixed
- 🛠 **Main page empty after startup** — After a restart the messages panel showed no messages until new live BLE traffic arrived. `SharedData.load_recent_from_archive()` now loads up to 100 recent archived messages during the cache-first startup phase, so historical messages are immediately visible
- New method `SharedData.load_recent_from_archive(limit)` — reads from `MessageArchive.query_messages()` and populates the in-memory list without re-archiving
- `BLEWorker._apply_cache()` calls `load_recent_from_archive()` at the end of cache loading
### Impact
- Archived messages now self-contained — channel name visible without live BLE connection
- Main page immediately shows historical messages after startup (no waiting for live BLE traffic)
- Backward compatible — old archive entries without `channel_name` fall back to `"Ch <idx>"`
- No breaking changes to existing functionality
---
## [1.6.0] - 2026-02-13 — Dashboard Layout Consolidation
### Changed
- 🔄 **Messages panel consolidated** — Filter checkboxes (DM + channels) and message input (text field, channel selector, Send button) are now integrated into the Messages panel, replacing the separate Filter and Input panels
- DM + channel checkboxes displayed centered in the Messages header row, between the "💬 Messages" label and the "📚 Archive" button
- Message input row (text field, channel selector, Send button) placed below the message list within the same card
- `messages_panel.py`: Constructor now accepts `put_command` callable; added `update_filters(data)`, `update_channel_options(channels)` methods and `channel_filters`, `last_channels` properties (all logic 1:1 from FilterPanel/InputPanel); `update()` signature unchanged
- 🔄 **Actions panel expanded** — BOT toggle checkbox moved from Filter panel to Actions panel, below the Refresh/Advert buttons
- `actions_panel.py`: Constructor now accepts `set_bot_enabled` callable; added `update(data)` method for BOT state sync; `_on_bot_toggle()` logic 1:1 from FilterPanel
- 🔄 **Dashboard layout simplified** — Centre column reduced from 4 panels (Map → Input → Filter → Messages) to 2 panels (Map → Messages)
- `dashboard.py`: FilterPanel and InputPanel no longer rendered; all dependencies rerouted to MessagesPanel and ActionsPanel; `_update_ui()` call-sites updated accordingly
### Removed (from layout, files retained)
-**Filter panel** no longer rendered as separate panel — `filter_panel.py` retained in codebase but not instantiated in dashboard
-**Input panel** no longer rendered as separate panel — `input_panel.py` retained in codebase but not instantiated in dashboard
### Impact
- Cleaner, more compact dashboard: 2 fewer panels in the centre column
- All functionality preserved — message filtering, send, BOT toggle, archive all work identically
- No breaking changes to BLE, services, core or other panels
---
<!-- ADDED: v1.5.0 feature + bugfix entry -->
## [1.5.0] - 2026-02-11 — Room Server Support, Dynamic Channel Discovery & Contact Management
### Added
-**Room Server panel** — Dedicated per-room-server message panel in the centre column below Messages. Each Room Server (type=3 contact) gets its own `ui.card()` with login/logout controls and message display
- Click a Room Server contact to open an add/login dialog with password field
- After login: messages are displayed in the room card; send messages directly from the room panel
- Password row + login button automatically replaced by Logout button after successful login
- Room Server author attribution via `signature` field (txt_type=2) — real message author is resolved from the 4-byte pubkey prefix, not the room server pubkey
- New panel: `gui/panels/room_server_panel.py` — per-room card management with login state tracking
-**Room Server password store** — Passwords stored outside the repository in `~/.meshcore-gui/room_passwords/<ADDRESS>.json`
- New service: `services/room_password_store.py` — JSON-backed persistent password storage per BLE device, analogous to `PinStore`
- Room panels are restored from stored passwords on app restart
-**Dynamic channel discovery** — Channels are now auto-discovered from the device at startup via `get_channel()` BLE probing, replacing the hardcoded `CHANNELS_CONFIG`
- Single-attempt probe per channel slot with early stop after 2 consecutive empty slots
- Channel name and encryption key extracted in a single pass (combined discovery + key loading)
- Configurable channel caching via `CHANNEL_CACHE_ENABLED` (default: `False` — always fresh from device)
- `MAX_CHANNELS` setting (default: 8) controls how many slots are probed
-**Individual contact deletion** — 🗑️ delete button per unpinned contact in the contacts list, with confirmation dialog
- New command: `remove_single_contact` in BLE command handler
- Pinned contacts are protected (no delete button shown)
-**"Also delete from history" option** — Checkbox in the Clean up confirmation dialog to also remove locally cached contact data
<!-- ADDED: Research document reference -->
-**Room Server protocol research**`RoomServer_Companion_App_Onderzoek.md` documents the full companion app message flow (login, push protocol, signature mechanism, auto_message_fetching)
### Changed
- 🔄 `config.py`: Removed `CHANNELS_CONFIG` constant; added `MAX_CHANNELS` (default: 8) and `CHANNEL_CACHE_ENABLED` (default: `False`)
- 🔄 `ble/worker.py`: Replaced hardcoded channel loading with `_discover_channels()` method; added `_try_get_channel_info()` helper; `_apply_cache()` respects `CHANNEL_CACHE_ENABLED` setting; removed `_load_channel_keys()` (integrated into discovery pass)
- 🔄 `ble/commands.py`: Added `login_room`, `send_room_msg` and `remove_single_contact` command handlers
- 🔄 `gui/panels/contacts_panel.py`: Contact click now dispatches by type — type=3 (Room Server) opens room dialog, others open DM dialog; added `on_add_room` callback parameter; added 🗑️ delete button per unpinned contact
- 🔄 `gui/panels/messages_panel.py`: Room Server messages filtered from general message view via `_is_room_message()` with prefix matching; `update()` accepts `room_pubkeys` parameter
- 🔄 `gui/dashboard.py`: Added `RoomServerPanel` in centre column; `_update_ui()` passes `room_pubkeys` to Messages panel; added `_on_add_room_server` callback
- 🔄 `gui/panels/filter_panel.py`: Channel filter checkboxes now built dynamically from discovered channels (no hardcoded references)
- 🔄 `services/bot.py`: Removed stale comment referencing hardcoded channels
### Fixed
- 🛠 **Room Server messages appeared as DM** — Messages from Room Servers (txt_type=2) were displayed in the general Messages panel as direct messages. They are now filtered out and shown exclusively in the Room Server panel
- 🛠 **Historical room messages not shown after login** — Post-login fetch loop was polling `get_msg()` before room server had time to push messages over LoRa RF (1075s per message). Removed redundant fetch loop; the library's `auto_message_fetching` handles `MESSAGES_WAITING` events correctly and event-driven
- 🛠 **Author attribution incorrect for room messages** — Room server messages showed the room server name as sender instead of the actual message author. Now correctly resolved from the `signature` field (4-byte pubkey prefix) via contact lookup
### Impact
- Room Servers are now first-class citizens in the GUI with dedicated panels
- Channel configuration no longer requires manual editing of `config.py`
- Contact list management is more granular with per-contact deletion
- No breaking changes to existing functionality (messages, DM, map, archive, bot, etc.)
---
## [1.4.0] - 2026-02-09 — SDK Event Race Condition Fix
### Fixed
- 🛠 **BLE startup delay of ~2 minutes eliminated** — The meshcore Python SDK (`commands/base.py`) dispatched device response events before `wait_for_events()` registered its subscription. On busy networks with frequent `RX_LOG_DATA` events, this caused `send_device_query()` and `get_channel()` to fail repeatedly with `no_event_received`, wasting 110+ seconds in timeouts
### Changed
- 📄 `meshcore` SDK `commands/base.py`: Rewritten `send()` method to subscribe to expected events **before** transmitting the BLE command (subscribe-before-send pattern), matching the approach used by the companion apps (meshcore.js, iOS, Android). Submitted upstream as [meshcore_py PR #52](https://github.com/meshcore-dev/meshcore_py/pull/52)
### Impact
- Startup time reduced from ~2+ minutes to ~10 seconds on busy networks
- All BLE commands (`send_device_query`, `get_channel`, `get_bat`, `send_appstart`, etc.) now succeed on first attempt instead of requiring multiple retries
- No changes to meshcore_gui code required — the fix is entirely in the meshcore SDK
### Temporary Installation
Until the fix is merged upstream, install the patched meshcore SDK:
```bash
pip install --force-reinstall git+https://github.com/PE1HVH/meshcore_py.git@fix/event-race-condition
```
---
<!-- ADDED: v1.3.2 bugfix entry -->
## [1.3.2] - 2026-02-09 — Bugfix: Bot Device Name Restoration After Restart
### Fixed
- 🛠 **Bot device name not properly restored after restart/crash** — After a restart or crash with bot mode previously active, the original device name was incorrectly stored as the bot name (e.g. `NL-OV-ZWL-STDSHGN-WKC Bot`) instead of the real device name (e.g. `PE1HVH T1000e`). The original device name is now correctly preserved and restored when bot mode is disabled
### Changed
- 🔄 `commands.py`: `set_bot_name` handler now verifies that the stored original name is not already the bot name before saving
- 🔄 `shared_data.py`: `original_device_name` is only written when it differs from `BOT_DEVICE_NAME` to prevent overwriting with the bot name on restart
---
<!-- ADDED: v1.3.1 bugfix entry -->
## [1.3.1] - 2026-02-09 — Bugfix: Auto-add AttributeError
### Fixed
- 🛠 **Auto-add error on first toggle** — Setting auto-add for the first time raised `AttributeError: 'telemetry_mode_base'`. The `set_manual_add_contacts()` SDK call now handles missing `telemetry_mode_base` attribute gracefully
### Changed
- 🔄 `commands.py`: `set_auto_add` handler wraps `set_manual_add_contacts()` call with attribute check and error handling for missing `telemetry_mode_base`
---
<!-- ADDED: New v1.3.0 entry at top -->
## [1.3.0] - 2026-02-08 — Bot Device Name Management
### Added
-**Bot device name switching** — When the BOT checkbox is enabled, the device name is automatically changed to a configurable bot name; when disabled, the original name is restored
- Original device name is saved before renaming so it can be restored on BOT disable
- Device name written to device via BLE `set_name()` SDK call
- Graceful handling of BLE failures during name change
-**`BOT_DEVICE_NAME` constant** in `config.py` — Configurable fixed device name used when bot mode is active (default: `;NL-OV-ZWL-STDSHGN-WKC Bot`)
### Changed
- 🔄 `config.py`: Added `BOT_DEVICE_NAME` constant for bot mode device name
- 🔄 `bot.py`: Removed hardcoded `BOT_NAME` prefix ("Zwolle Bot") from bot reply messages — bot replies no longer include a name prefix
- 🔄 `filter_panel.py`: BOT checkbox toggle now triggers device name save/rename via command queue
- 🔄 `commands.py`: Added `set_bot_name` and `restore_name` command handlers for device name switching
- 🔄 `shared_data.py`: Added `original_device_name` field for storing the pre-bot device name
### Removed
-`BOT_NAME` constant from `bot.py` — bot reply prefix removed; replies no longer prepend a bot display name
---
## [1.2.0] - 2026-02-08 — Contact Maintenance Feature
### Added
-**Pin/Unpin contacts** (Iteration A) — Toggle to pin individual contacts, protecting them from bulk deletion
- Persistent pin state stored in `~/.meshcore-gui/cache/<ADDRESS>_pins.json`
- Pinned contacts visually marked with yellow background
- Pinned contacts sorted to top of contact list
- Pin state survives app restart
- New service: `services/pin_store.py` — JSON-backed persistent pin storage
-**Bulk delete unpinned contacts** (Iteration B) — Remove all unpinned contacts from device in one action
- "🧹 Clean up" button in contacts panel with confirmation dialog
- Shows count of contacts to be removed vs. pinned contacts kept
- Progress status updates during removal
- Automatic device resync after completion
- New service: `services/contact_cleaner.py` — ContactCleanerService with purge statistics
-**Auto-add contacts toggle** (Iteration C) — Control whether device automatically adds new contacts from mesh adverts
- "📥 Auto-add" checkbox in contacts panel (next to Clean up button)
- Syncs with device via `set_manual_add_contacts()` SDK call
- Inverted logic handled internally (UI "Auto-add ON" = `set_manual_add_contacts(false)`)
- Optimistic update with automatic rollback on BLE failure
- State synchronized from device on each GUI update cycle
### Changed
- 🔄 `contacts_panel.py`: Added pin checkbox per contact, purge button, auto-add toggle, DM dialog (all existing functionality preserved)
- 🔄 `commands.py`: Added `purge_unpinned` and `set_auto_add` command handlers
- 🔄 `shared_data.py`: Added `auto_add_enabled` field with thread-safe getter/setter
- 🔄 `protocols.py`: Added `set_auto_add_enabled` and `is_auto_add_enabled` to Writer and Reader protocols
- 🔄 `dashboard.py`: Passes `PinStore` and `set_auto_add_enabled` callback to ContactsPanel
- 🔄 **UI language**: All Dutch strings in `contacts_panel.py` and `commands.py` translated to English
---
### Fixed
- 🛠 **Route table names and IDs not displayed** — Route tables in both current messages (RoutePage) and archive messages (ArchivePage) now correctly show node names and public key IDs for sender, repeaters and receiver
### Changed
- 🔄 **CHANGELOG.md**: Corrected version numbering to semantic versioning, fixed inaccurate references (archive button location, filter state persistence)
- 🔄 **README.md**: Added Message Archive feature, updated project structure, configuration table and architecture diagram
- 🔄 **MeshCore_GUI_Design.docx**: Added ArchivePage, MessageArchive, Models components; updated project structure, protocols, configuration and version history
---
## [1.1.0] - 2026-02-07 — Archive Viewer Feature
### Added
-**Archive Viewer Page** (`/archive`) — Full-featured message archive browser
- Pagination (50 messages per page, configurable)
- Channel filter dropdown (All + configured channels)
- Time range filter (24h, 7d, 30d, 90d, All time)
- Text search (case-insensitive)
- Filter state stored in instance variables (reset on page reload)
- Message cards with same styling as main messages panel
- Clickable messages for route visualization (where available)
- **💬 Reply functionality** — Expandable reply panel per message
- **🗺️ Inline route table** — Expandable route display per archive message with sender, repeaters and receiver (names, IDs, node types)
- *(Note: Reply panels and inline route tables removed in v1.8.0, replaced by click-to-route navigation via message hash)*
<!-- CHANGED: "Filter state persistence (app.storage.user)" replaced with "Filter state stored in
instance variables" — the code (archive_page.py:36-40) uses self._current_page etc.,
not app.storage.user. The comment in the code is misleading. -->
<!-- ADDED: "Inline route table" entry — _render_archive_route() in archive_page.py:333-407
was not documented. -->
-**MessageArchive.query_messages()** method
- Filter by: time range, channel, text search, sender
- Pagination support (limit, offset)
- Returns tuple: (messages, total_count)
- Sorting: Newest first
-**UI Integration**
- "📚 Archive" button in Messages panel header (opens in new tab)
- Back to Dashboard button in archive page
<!-- CHANGED: "📚 View Archive button in Actions panel" corrected — the button is in
MessagesPanel (messages_panel.py:25), not in ActionsPanel (actions_panel.py).
ActionsPanel only contains Refresh and Advert buttons. -->
-**Reply Panel**
- Expandable reply per message (💬 Reply button)
- Pre-filled with @sender mention
- Channel selector
- Send button with success notification
- Auto-close expansion after send
### Changed
- 🔄 `SharedData.get_snapshot()`: Now includes `'archive'` field
- 🔄 `MessagesPanel`: Added archive button in header row
- 🔄 Both entry points (`__main__.py` and `meshcore_gui.py`): Register `/archive` route
<!-- CHANGED: "ActionsPanel: Added archive button" corrected to "MessagesPanel" -->
### Performance
- Query: ~10ms for 10k messages with filters
- Memory: ~10KB per page (50 messages)
- No impact on main UI (separate page)
### Known Limitations
- ~~Route visualization only works for messages in recent buffer (last 100)~~ — Fixed in v1.8.0: archive messages now support click-to-route via `get_message_by_hash()` fallback
- Text search is linear scan (no indexing yet)
- Sender filter exists in API but not in UI yet
---
## [1.0.3] - 2026-02-07 — Critical Bugfix: Archive Overwrite Prevention
### Fixed
- 🛠 **CRITICAL**: Fixed bug where archive was overwritten instead of appended on restart
- 🛠 Archive now preserves existing data when read errors occur
- 🛠 Buffer is retained for retry if existing archive cannot be read
### Changed
- 🔄 `_flush_messages()`: Early return on read error instead of overwriting
- 🔄 `_flush_rxlog()`: Early return on read error instead of overwriting
- 🔄 Better error messages for version mismatch and JSON decode errors
### Details
**Problem:** If the existing archive file had a JSON parse error or version mismatch,
the flush operation would proceed with `existing_messages = []`, effectively
overwriting all historical data with only the new buffered messages.
**Solution:** The flush methods now:
1. Try to read existing archive first
2. If read fails (JSON error, version mismatch, IO error), abort the flush
3. Keep buffer intact for next retry
4. Only clear buffer after successful write
**Impact:** No data loss on restart or when archive files have issues.
### Testing
- ✅ Added `test_append_on_restart_not_overwrite()` integration test
- ✅ Verifies data is appended across multiple sessions
- ✅ All existing tests still pass
---
## [1.0.2] - 2026-02-07 — RxLog message_hash Enhancement
### Added
-`message_hash` field added to `RxLogEntry` model
- ✅ RxLog entries now include message_hash for correlation with messages
- ✅ Archive JSON includes message_hash in rxlog entries
### Changed
- 🔄 `events.py`: Restructured `on_rx_log()` to extract message_hash before creating RxLogEntry
- 🔄 `message_archive.py`: Updated rxlog archiving to include message_hash field
- 🔄 Tests updated to verify message_hash persistence
### Benefits
- **Correlation**: Link RX log entries to their corresponding messages
- **Analysis**: Track which packets resulted in messages
- **Debugging**: Better troubleshooting of packet processing
---
## [1.0.1] - 2026-02-07 — Entry Point Fix
### Fixed
-`meshcore_gui.py` (root entry point) now passes ble_address to SharedData
- ✅ Archive works correctly regardless of how application is started
### Changed
- 🔄 Both entry points (`meshcore_gui.py` and `meshcore_gui/__main__.py`) updated
---
## [1.0.0] - 2026-02-07 — Message & Metadata Persistence
### Added
- ✅ MessageArchive class for persistent storage
- ✅ Configurable retention periods (MESSAGE_RETENTION_DAYS, RXLOG_RETENTION_DAYS, CONTACT_RETENTION_DAYS)
- ✅ Automatic daily cleanup of old data
- ✅ Batch writes for performance
- ✅ Thread-safe with separate locks
- ✅ Atomic file writes
- ✅ Contact retention in DeviceCache
- ✅ Archive statistics API
- ✅ Comprehensive tests (20+ unit, 8+ integration)
- ✅ Full documentation
### Storage Locations
- `~/.meshcore-gui/archive/<ADDRESS>_messages.json`
- `~/.meshcore-gui/archive/<ADDRESS>_rxlog.json`
### Requirements Completed
- R1: All incoming messages persistent ✅
- R2: All incoming RxLog entries persistent ✅
- R3: Configurable retention ✅
- R4: Automatic cleanup ✅
- R5: Backward compatibility ✅
- R6: Contact retention ✅
- R7: Archive stats API ✅
- Fix3: Leaflet asset injection is now per page render instead of process-global, and browser bootstrap now retries until the host element, Leaflet runtime, and MeshCore panel runtime are all available. This fixes blank map containers caused by missing or late-loaded JS/CSS assets.
- Fix5: Removed per-snapshot map invalidate calls, stopped forcing a default dark theme during map bootstrap, and added client-side interaction/resize guards so zooming stays responsive and the theme no longer jumps back during status-loop updates.
## 2026-03-09 map hotfix v2
- regular map snapshots no longer carry theme state
- 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

View File

@@ -18,6 +18,8 @@ from meshcore_gui.services.cache import DeviceCache
class CommandHandler:
MAX_REPLY_LEN: int = 180
"""Dispatches and executes commands sent from the GUI.
Args:
@@ -67,6 +69,34 @@ class CommandHandler:
else:
debug_print(f"Unknown command action: {action}")
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _split_reply(self, text: str) -> List[str]:
"""Split long replies into transport-safe chunks on line boundaries."""
if not text:
return []
lines = str(text).splitlines() or [str(text)]
chunks: List[str] = []
current = ""
for line in lines:
line = line.rstrip()
candidate = line if not current else f"{current}\n{line}"
if len(candidate) <= self.MAX_REPLY_LEN:
current = candidate
continue
if current:
chunks.append(current)
current = ""
while len(line) > self.MAX_REPLY_LEN:
chunks.append(line[:self.MAX_REPLY_LEN])
line = line[self.MAX_REPLY_LEN:]
current = line
if current:
chunks.append(current)
return chunks
# ------------------------------------------------------------------
# Individual command handlers
# ------------------------------------------------------------------
@@ -76,11 +106,13 @@ class CommandHandler:
text = cmd.get('text', '')
is_bot = cmd.get('_bot', False)
if text:
await self._mc.commands.send_chan_msg(channel, text)
chunks = self._split_reply(text)
for idx, chunk in enumerate(chunks):
await self._mc.commands.send_chan_msg(channel, chunk)
if idx + 1 < len(chunks):
await asyncio.sleep(0.2)
if not is_bot:
self._shared.add_message(Message.outgoing(
text, channel,
))
self._shared.add_message(Message.outgoing(text, channel))
debug_print(
f"{'BOT' if is_bot else 'Sent'} message to "
f"channel {channel}: {text[:30]}"
@@ -91,10 +123,12 @@ class CommandHandler:
text = cmd.get('text', '')
contact_name = cmd.get('contact_name', pubkey[:8])
if text and pubkey:
await self._mc.commands.send_msg(pubkey, text)
self._shared.add_message(Message.outgoing(
text, None, sender_pubkey=pubkey,
))
chunks = self._split_reply(text)
for idx, chunk in enumerate(chunks):
await self._mc.commands.send_msg(pubkey, chunk)
if idx + 1 < len(chunks):
await asyncio.sleep(0.2)
self._shared.add_message(Message.outgoing(text, None, sender_pubkey=pubkey))
debug_print(f"Sent DM to {contact_name}: {text[:30]}")
async def _cmd_send_advert(self, cmd: Dict) -> None:

View File

@@ -208,7 +208,34 @@ class EventHandler:
path_len=decoded.path_length,
path_hashes=decoded.path_hashes,
)
# BBS channel hook: auto-whitelist sender and reply
# for '!bbs' on a configured BBS channel.
# Must run here because on_channel_msg is suppressed
# by content-dedup when on_rx_log already stored the
# message (the common path for resolved channel_idx).
if (
self._bbs_handler is not None
and self._command_sink is not None
):
bbs_reply = self._bbs_handler.handle_channel_msg(
channel_idx=decoded.channel_idx,
sender=decoded.sender,
sender_key=sender_pubkey,
text=decoded.text,
)
if bbs_reply is not None:
debug_print(
f"BBS channel reply (rx_log) on "
f"ch{decoded.channel_idx} to "
f"{decoded.sender!r}: {bbs_reply[:60]}"
)
self._command_sink({
"action": "send_message",
"channel": decoded.channel_idx,
"text": bbs_reply,
})
# 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
@@ -276,8 +303,13 @@ class EventHandler:
f"text={msg_text[:40]!r}"
)
sender_pubkey = ''
if sender:
sender_pubkey = (
payload.get('pubkey_prefix')
or payload.get('sender_pubkey')
or payload.get('signature')
or ''
)
if not sender_pubkey and sender:
match = self._shared.get_contact_by_name(sender)
if match:
sender_pubkey, _contact = match

View File

@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
VERSION: str = "1.14.0"
VERSION: str = "1.14.2"
# ==============================================================================

View File

@@ -324,16 +324,9 @@ class BbsPanel:
)
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]}'
f'BBS panel: locally posted to board={self._active_board.id} '
f'ch={target_channel} [{category}] {text[:60]}'
)
if self._text_input:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ class BbsBoard:
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).
allowed_keys: Sender public key whitelist for DM-BBS access.
"""
id: str
@@ -330,9 +330,9 @@ class BbsConfigStore:
) -> 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).
Multiple channels can be assigned. DM-BBS access is controlled by the
whitelist; senders are added only through the explicit ``!bbs``
bootstrap on the linked channel.
The board id is always ``'bbs_board'``. The board name is built
from the channel names in *channel_names*.
@@ -343,8 +343,7 @@ class BbsConfigStore:
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`).
allowed_keys: Manual sender key whitelist seed.
"""
name = ", ".join(
channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices)
@@ -383,8 +382,8 @@ class BbsConfigStore:
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
Called when the explicit ``!bbs`` bootstrap is received on the linked
BBS channel. No-op if the key is already present
or if no board is configured.
Args:

View File

@@ -377,13 +377,12 @@ class BbsCommandHandler:
"""Handle a channel message on a configured BBS channel.
Called from ``EventHandler.on_channel_msg`` **after** the message
has been stored. Two responsibilities:
has been stored.
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.
Design rule:
- only ``!bbs`` is handled on the linked BBS channel;
- it whitelists the sender public key for later DM-BBS use;
- help/read/post/search commands are DM-only.
Args:
channel_idx: MeshCore channel index the message arrived on.
@@ -400,46 +399,16 @@ class BbsCommandHandler:
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("!"):
if text.lower() != "!bbs":
return None
first = text.split()[0].lower()
channel_for_post = channel_idx
if first in ("!p",):
rest = text[len(first):].strip()
return self._handle_post_short(board, channel_for_post, sender, sender_key, rest)
if first in ("!r",):
rest = text[len(first):].strip()
return self._handle_read_short(board, rest)
if first in ("!s",):
rest = text[len(first):].strip()
return self._handle_search(board, rest)
if first in ("!help", "!h"):
return self._handle_help(board)
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 in ("help", ""):
return self._handle_help(board)
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
return None
if not sender_key:
debug_print("BBS: !bbs ignored on channel because sender key is empty")
return "BBS whitelist failed: sender key unknown."
added = self._config_store.add_allowed_key(sender_key)
if added:
return "Add to BBS OK. Use !h in DM-BBS for help."
return "Already on BBS whitelist. Use !h in DM-BBS for help."
# ------------------------------------------------------------------
@@ -476,13 +445,25 @@ class BbsCommandHandler:
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
# Whitelist check (accept full-key/prefix matches in both directions)
if board.allowed_keys:
sender_key_up = (sender_key or "").upper()
allowed = False
for key in board.allowed_keys:
key_up = (key or "").upper()
if sender_key_up and key_up and (
sender_key_up == key_up
or sender_key_up.startswith(key_up)
or key_up.startswith(sender_key_up)
):
allowed = True
break
if not allowed:
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
@@ -511,9 +492,9 @@ class BbsCommandHandler:
return self._handle_post(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
if sub in ("help", ""):
if sub == "help":
return self._handle_help(board)
return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
return "Use !bbs on the linked channel for whitelist bootstrap."
# Unknown !-command
return None
@@ -553,7 +534,8 @@ class BbsCommandHandler:
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)
cats_upper = [c.upper() for c in categories]
return " ".join(f"{inv[c]}={c}" for c in cats_upper if c in inv)
def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]:
"""Resolve *token* to a category via exact match or abbreviation.
@@ -633,7 +615,7 @@ class BbsCommandHandler:
Range syntax: ``!r U 6-10`` returns messages 6 to 10 (1-indexed,
newest first). Without a range the default is 1-5 (five most recent).
``!r`` without any arguments always includes the abbreviation table.
Bare ``!r`` returns the most recent messages across all categories.
"""
regions = board.regions
categories = board.categories
@@ -670,7 +652,7 @@ class BbsCommandHandler:
return self._format_messages(
board, region, category,
offset=offset, limit=limit,
include_abbrevs=not args,
include_abbrevs=False,
)
def _handle_search(self, board, args):