From f3e5fcb949618acae4a56034f3061ace4c2303c6 Mon Sep 17 00:00:00 2001 From: pe1hvh <123490760+pe1hvh@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:06:42 +0100 Subject: [PATCH 01/39] Revise CHANGELOG for version 1.13.1 updates Updated CHANGELOG for version 1.13.1 to reflect changes in message icon consistency and related fixes. --- CHANGELOG.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2202e54..ec552be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,3 @@ - -## [1.13.1] - 2026-03-09 - -### Fixed -- Route map markers now use the same JS-rendered node icons as the main MAP instead of NiceGUI default blue markers. -- Route detail pages now bootstrap their Leaflet assets explicitly so the shared map icon runtime is available there too. - -### Changed -- Route maps are now rendered browser-side through the shared Leaflet JS runtime for icon consistency with MAP, Messages, and Archive. - # CHANGELOG - -All notable changes to MeshCore GUI are documented in this file. -Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). - ---- -## [1.13.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 1–2 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 `_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
`. 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 `
_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 "` -- 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 - ---- - - - -## [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/
.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 - - -- βœ… **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 (10–75s 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 -``` - ---- - - - -## [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 - ---- - - - -## [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` - ---- - - - -## [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/
_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)* - - - - - -- βœ… **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 - - - -- βœ… **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 - - - -### 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/
_messages.json` -- `~/.meshcore-gui/archive/
_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 diff --git a/meshcore_gui/gui/panels/map_panel.py b/meshcore_gui/gui/panels/map_panel.py index f5a8e7b..a3526dc 100644 --- a/meshcore_gui/gui/panels/map_panel.py +++ b/meshcore_gui/gui/panels/map_panel.py @@ -188,10 +188,10 @@ class MapPanel: 'meshcore-leaflet-vendor-js', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', function () { + ensurePanelRuntime(); ensureScript( 'meshcore-leaflet-markercluster-js', - 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js', - ensurePanelRuntime + 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js' ); } ); diff --git a/meshcore_gui/gui/route_page.py b/meshcore_gui/gui/route_page.py index 6b84c52..93e97e9 100644 --- a/meshcore_gui/gui/route_page.py +++ b/meshcore_gui/gui/route_page.py @@ -79,9 +79,8 @@ _ROUTE_MAP_ASSETS = r""" ensureStylesheet('meshcore-leaflet-panel-css', '/static/leaflet_map_panel.css'); ensureScript('meshcore-leaflet-vendor-js', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', function () { - ensureScript('meshcore-leaflet-markercluster-js', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js', function () { - ensureScript('meshcore-leaflet-panel-js', '/static/leaflet_map_panel.js'); - }); + ensureScript('meshcore-leaflet-panel-js', '/static/leaflet_map_panel.js'); + ensureScript('meshcore-leaflet-markercluster-js', 'https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js'); }); })(); diff --git a/meshcore_gui/static/leaflet_map_panel.js b/meshcore_gui/static/leaflet_map_panel.js index dad1cee..a845f4d 100644 --- a/meshcore_gui/static/leaflet_map_panel.js +++ b/meshcore_gui/static/leaflet_map_panel.js @@ -38,7 +38,7 @@ const existing = maps.get(containerId); const host = document.getElementById(containerId); - if (!host || typeof window.L === 'undefined' || typeof window.L.markerClusterGroup !== 'function') { + if (!host || typeof window.L === 'undefined') { return null; } @@ -118,21 +118,7 @@ ).addTo(map); state.theme = 'light'; - state.layers.contacts = window.L.markerClusterGroup({ - showCoverageOnHover: false, - spiderfyOnMaxZoom: true, - removeOutsideVisibleBounds: true, - animate: false, - chunkedLoading: true, - maxClusterRadius: 50, - iconCreateFunction(cluster) { - return window.L.divIcon({ - html: '
' + cluster.getChildCount() + '
', - className: 'meshcore-marker-cluster', - iconSize: window.L.point(42, 42), - }); - }, - }).addTo(map); + state.layers.contacts = buildContactsLayer().addTo(map); } catch (error) { maps.delete(containerId); delete host.__meshcoreLeafletState; @@ -418,6 +404,29 @@ ); } + function buildContactsLayer() { + if (typeof window.L.markerClusterGroup === 'function') { + return window.L.markerClusterGroup({ + showCoverageOnHover: false, + spiderfyOnMaxZoom: true, + removeOutsideVisibleBounds: true, + animate: false, + chunkedLoading: true, + maxClusterRadius: 50, + iconCreateFunction(cluster) { + return window.L.divIcon({ + html: '
' + cluster.getChildCount() + '
', + className: 'meshcore-marker-cluster', + iconSize: window.L.point(42, 42), + }); + }, + }); + } + + console.warn('MeshCoreLeafletBoot markercluster unavailable; falling back to plain layer group'); + return window.L.layerGroup(); + } + function escapeHtml(value) { return String(value) .replaceAll('&', '&') @@ -456,9 +465,9 @@ return; } - if (typeof window.L === 'undefined' || typeof window.L.markerClusterGroup !== 'function') { + if (typeof window.L === 'undefined') { if (retries >= MAX_RETRIES) { - console.error('MeshCoreLeafletBoot timeout waiting for Leaflet markercluster', { containerId }); + console.error('MeshCoreLeafletBoot timeout waiting for Leaflet runtime', { containerId }); return; } window.setTimeout(() => { @@ -526,14 +535,28 @@ } - window.MeshCoreRouteMapBoot = function (containerId, payload) { + window.MeshCoreRouteMapBoot = function (containerId, payload, retries) { if (!containerId || !payload) { return; } + const attempt = typeof retries === 'number' ? retries : 0; const host = document.getElementById(containerId); if (!host || typeof window.L === 'undefined') { - window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload), RETRY_DELAY_MS); + if (attempt >= MAX_RETRIES) { + console.error('MeshCoreRouteMapBoot timeout waiting for host/runtime', { containerId }); + return; + } + window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload, attempt + 1), RETRY_DELAY_MS); + return; + } + + if (host.clientWidth === 0 && host.clientHeight === 0) { + if (attempt >= MAX_RETRIES) { + console.error('MeshCoreRouteMapBoot timeout waiting for visible route map host', { containerId }); + return; + } + window.setTimeout(() => window.MeshCoreRouteMapBoot(containerId, payload, attempt + 1), RETRY_DELAY_MS); return; } From 97edf22efb556630a85d33c7b52c077a807b57aa Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 16:00:26 +0100 Subject: [PATCH 14/39] HotFixRoomServer --- CHANGELOG.md | 57 ++++++++++++++++-------------------- meshcore_gui/ble/commands.py | 1 + meshcore_gui/ble/events.py | 17 +++++++---- meshcore_gui/ble/worker.py | 12 ++++++++ meshcore_gui/config.py | 2 +- 5 files changed, 52 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c9131..e79ede2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,28 @@ # CHANGELOG - - 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/). --- -<<<<<<< HEAD +## [1.13.4] - 2026-03-12 β€” Room Server Login & Receive Reliability + +### Changed +- πŸ”„ `meshcore_gui/ble/commands.py` β€” Room login success now refreshes archived room history immediately after `LOGIN_SUCCESS`, so the room panel is populated deterministically right after a successful login +- πŸ”„ `meshcore_gui/ble/events.py` β€” `CONTACT_MSG_RECV` with `txt_type == 2` is now always treated as a Room Server message, even when the `signature` field is absent; the author name falls back gracefully instead of routing the message through the normal DM path +- πŸ”„ `meshcore_gui/ble/worker.py` β€” The global `LOGIN_SUCCESS` subscriber now also synchronizes room login state into `SharedData` and refreshes room history, so UI state no longer depends solely on the command-side waiter winning the event timing race +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.4` + +### Fixed +- πŸ›  **Initial room login could remain pending or feel unreliable** β€” UI state now also updates from the subscribed `LOGIN_SUCCESS` event, not only from the command coroutine waiting for the same event +- πŸ›  **Room messages could be missed when `txt_type == 2` arrived without `signature`** β€” such packets are now still classified as room traffic and shown in the Room Server panel +- πŸ›  **Room history refresh after login was timing-sensitive** β€” history is now reloaded both from the command success path and from the subscribed login-success callback + +### Impact +- More reliable first login behaviour for Room Server panels +- Better chance that room history and newly arriving room messages show up immediately after login +- No intended breaking changes outside the Room Server receive/login flow + +--- ## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating ### Changed @@ -32,34 +47,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [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 `
`. 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. +- πŸ›  **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 `
`. 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 `
` with an explicit inline `style` (height: 24rem). Removed early - `return` guard so the Leaflet container is always created. +- πŸ”„ `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 `
` 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 @@ -67,7 +63,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- ->>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py index 9203d93..6858f85 100644 --- a/meshcore_gui/ble/commands.py +++ b/meshcore_gui/ble/commands.py @@ -406,6 +406,7 @@ class CommandHandler: pubkey, 'ok', f"admin={is_admin}", ) + self._shared.load_room_history(pubkey) self._shared.set_status( f"βœ… Room login OK: {room_name} β€” " f"history arriving over RF…" diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 8fbf16f..65ca5c5 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -319,11 +319,18 @@ class EventHandler: path_len = len(path_hashes) # --- Room Server message (txt_type 2) --- - if txt_type == 2 and signature: - # Resolve actual author from signature (author pubkey prefix) - author = self._shared.get_contact_name_by_prefix(signature) + if txt_type == 2: + # Prefer the embedded author signature when available. + # Some room-history / server-side messages arrive without a + # signature; those still belong to the room and must not fall + # through to the regular DM path. + author = '' + if signature: + author = self._shared.get_contact_name_by_prefix(signature) + if not author: + author = signature[:8] if not author: - author = signature[:8] if signature else '?' + author = pubkey[:8] if pubkey else '?' self._shared.add_message(Message.incoming( author, @@ -337,7 +344,7 @@ class EventHandler: message_hash=msg_hash, )) debug_print( - f"Room msg from {author} (sig={signature}) " + f"Room msg from {author} (sig={signature or '-'}) " f"via room {pubkey[:12]}: " f"{payload.get('text', '')[:30]}" ) diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 6aac002..7ed1751 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -258,10 +258,22 @@ class _BaseWorker(abc.ABC): # ── LOGIN_SUCCESS handler (Room Server) ─────────────────────── def _on_login_success(self, event) -> None: + """Synchronise Room Server login success into SharedData. + + This callback is intentionally independent from the command-side + ``wait_for_event(LOGIN_SUCCESS)`` path. If the library delivers the + event to subscribers before or instead of the waiter, the UI must + still transition to the logged-in state and refresh room history. + """ payload = event.payload or {} pubkey = payload.get("pubkey_prefix", "") is_admin = payload.get("is_admin", False) + detail = f"admin={is_admin}" + debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}") + self.shared.set_room_login_state(pubkey, 'ok', detail) + if pubkey: + self.shared.load_room_history(pubkey) self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") # ── apply cache ─────────────────────────────────────────────── diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index 941b0ec..cfe4d5b 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.3" +VERSION: str = "1.13.4" # ============================================================================== From dbecf7ac2456cc4c0739d7b160fa366abf2c259a Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 16:23:56 +0100 Subject: [PATCH 15/39] HotFixRoomServer --- CHANGELOG.md | 57 +++++++++++++++++++++++---------- meshcore_gui/ble/commands.py | 61 +++++++++++++++++++++++++++++++++++- meshcore_gui/ble/events.py | 33 ++++++++++++------- meshcore_gui/ble/worker.py | 11 +------ 4 files changed, 122 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e79ede2..da9ed5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,29 @@ # CHANGELOG + + All notable changes to MeshCore GUI are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). --- -## [1.13.4] - 2026-03-12 β€” Room Server Login & Receive Reliability +## [1.13.4] - 2026-03-12 β€” Room Server USB Login & Fetch Fix ### Changed -- πŸ”„ `meshcore_gui/ble/commands.py` β€” Room login success now refreshes archived room history immediately after `LOGIN_SUCCESS`, so the room panel is populated deterministically right after a successful login -- πŸ”„ `meshcore_gui/ble/events.py` β€” `CONTACT_MSG_RECV` with `txt_type == 2` is now always treated as a Room Server message, even when the `signature` field is absent; the author name falls back gracefully instead of routing the message through the normal DM path -- πŸ”„ `meshcore_gui/ble/worker.py` β€” The global `LOGIN_SUCCESS` subscriber now also synchronizes room login state into `SharedData` and refreshes room history, so UI state no longer depends solely on the command-side waiter winning the event timing race +- πŸ”„ `meshcore_gui/ble/commands.py` β€” After `LOGIN_SUCCESS`, the room login flow now starts a bounded background `get_msg()` sync loop so serial/USB sessions actively drain queued room messages instead of relying on a single defensive fetch +- πŸ”„ `meshcore_gui/ble/events.py` β€” Room messages are now classified on `txt_type == 2` even when the `signature` field is absent; sender/room pubkeys also use broader payload fallbacks for room traffic +- πŸ”„ `meshcore_gui/ble/worker.py` β€” Global `LOGIN_SUCCESS` handling now updates `room_login_states` and refreshes cached room history in `SharedData` - πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.4` ### Fixed -- πŸ›  **Initial room login could remain pending or feel unreliable** β€” UI state now also updates from the subscribed `LOGIN_SUCCESS` event, not only from the command coroutine waiting for the same event -- πŸ›  **Room messages could be missed when `txt_type == 2` arrived without `signature`** β€” such packets are now still classified as room traffic and shown in the Room Server panel -- πŸ›  **Room history refresh after login was timing-sensitive** β€” history is now reloaded both from the command success path and from the subscribed login-success callback +- πŸ›  **USB/serial room login showed only app-sent messages** β€” After login, the app now keeps polling queued room messages for a short window so messages from other room participants are actually fetched +- πŸ›  **Incoming room messages without `signature` were misclassified** β€” `CONTACT_MSG_RECV` packets with `txt_type == 2` no longer fall back to DM handling just because the room server omitted `signature` +- πŸ›  **Room login UI state could depend on one code path** β€” Worker-side `LOGIN_SUCCESS` processing now reinforces the room state update even when the command-side wait path is not the only consumer ### Impact -- More reliable first login behaviour for Room Server panels -- Better chance that room history and newly arriving room messages show up immediately after login -- No intended breaking changes outside the Room Server receive/login flow +- Faster and more reliable room history retrieval on USB/serial setups +- Room traffic from other users has a better chance of appearing in the Room Server panel immediately after login +- No intended regression for DM or normal channel message handling --- ## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating @@ -47,15 +50,34 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [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 `
`. 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. +- πŸ›  **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 `
`. 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 `
` with an explicit inline `style` (height: 24rem). Removed early `return` guard so the Leaflet container is always created. +- πŸ”„ `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 `
` 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 @@ -63,6 +85,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py index 6858f85..d1bae49 100644 --- a/meshcore_gui/ble/commands.py +++ b/meshcore_gui/ble/commands.py @@ -35,6 +35,7 @@ class CommandHandler: self._mc = mc self._shared = shared self._cache = cache + self._room_sync_tasks: Dict[str, asyncio.Task] = {} # Handler registry β€” add new commands here (OCP) self._handlers: Dict[str, object] = { @@ -406,7 +407,6 @@ class CommandHandler: pubkey, 'ok', f"admin={is_admin}", ) - self._shared.load_room_history(pubkey) self._shared.set_status( f"βœ… Room login OK: {room_name} β€” " f"history arriving over RF…" @@ -426,6 +426,8 @@ class CommandHandler: except Exception as exc: debug_print(f"login_room: defensive get_msg() error: {exc}") + self._start_room_sync(pubkey, room_name) + else: self._shared.set_room_login_state( pubkey, 'fail', @@ -552,6 +554,63 @@ class CommandHandler: ) debug_print(f"send_room_msg exception: {exc}") + def _cancel_room_sync(self, pubkey: str) -> None: + """Cancel an active background room-history sync task.""" + task = self._room_sync_tasks.pop(pubkey, None) + if task and not task.done(): + task.cancel() + + def _start_room_sync(self, pubkey: str, room_name: str) -> None: + """Start a bounded background fetch loop for room history.""" + self._cancel_room_sync(pubkey) + self._room_sync_tasks[pubkey] = asyncio.create_task( + self._sync_room_history(pubkey, room_name) + ) + + async def _sync_room_history(self, pubkey: str, room_name: str) -> None: + """Fetch queued room messages for a short period after login. + + On some serial/USB setups the SDK's auto-message fetching is + not sufficient to drain the room backlog promptly after + ``LOGIN_SUCCESS``. This bounded loop polls ``get_msg()`` for a + short window so historical room messages from other users are + actually pulled into the app. + """ + idle_errors = 0 + try: + for attempt in range(24): + try: + result = await self._mc.commands.get_msg() + result_type = getattr(result, 'type', None) + if result_type == EventType.ERROR: + idle_errors += 1 + debug_print( + f"room_sync: get_msg ERROR for {room_name} " + f"(attempt {attempt + 1}/24, idle={idle_errors})" + ) + else: + idle_errors = 0 + debug_print( + f"room_sync: get_msg fetched data for {room_name} " + f"(attempt {attempt + 1}/24)" + ) + except Exception as exc: + idle_errors += 1 + debug_print( + f"room_sync: get_msg exception for {room_name}: {exc}" + ) + + if idle_errors >= 4: + break + + await asyncio.sleep(2.0) + except asyncio.CancelledError: + debug_print(f"room_sync: cancelled for {room_name}") + raise + finally: + self._shared.load_room_history(pubkey) + self._room_sync_tasks.pop(pubkey, None) + # ------------------------------------------------------------------ # Callback for refresh (set by SerialWorker after construction) # ------------------------------------------------------------------ diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 65ca5c5..e5cd220 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -320,17 +320,26 @@ class EventHandler: # --- Room Server message (txt_type 2) --- if txt_type == 2: - # Prefer the embedded author signature when available. - # Some room-history / server-side messages arrive without a - # signature; those still belong to the room and must not fall - # through to the regular DM path. + room_pubkey = ( + payload.get('room_pubkey') + or payload.get('receiver_pubkey') + or payload.get('recipient_pubkey') + or payload.get('pubkey') + or pubkey + ) + author_prefix = ( + signature + or payload.get('sender_pubkey_prefix', '') + or payload.get('sender_pubkey', '') + or payload.get('sender_prefix', '') + ) author = '' - if signature: - author = self._shared.get_contact_name_by_prefix(signature) - if not author: - author = signature[:8] + if author_prefix: + author = self._shared.get_contact_name_by_prefix(author_prefix) if not author: - author = pubkey[:8] if pubkey else '?' + author = payload.get('sender_name', '') or payload.get('name', '') + if not author: + author = author_prefix[:8] if author_prefix else room_pubkey[:8] if room_pubkey else '?' self._shared.add_message(Message.incoming( author, @@ -338,14 +347,14 @@ class EventHandler: None, snr=self._extract_snr(payload), path_len=path_len, - sender_pubkey=pubkey, + sender_pubkey=room_pubkey, path_hashes=path_hashes, path_names=path_names, message_hash=msg_hash, )) debug_print( - f"Room msg from {author} (sig={signature or '-'}) " - f"via room {pubkey[:12]}: " + f"Room msg from {author} (sig={signature}) " + f"via room {room_pubkey[:12]}: " f"{payload.get('text', '')[:30]}" ) return diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 7ed1751..af9965b 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -258,21 +258,12 @@ class _BaseWorker(abc.ABC): # ── LOGIN_SUCCESS handler (Room Server) ─────────────────────── def _on_login_success(self, event) -> None: - """Synchronise Room Server login success into SharedData. - - This callback is intentionally independent from the command-side - ``wait_for_event(LOGIN_SUCCESS)`` path. If the library delivers the - event to subscribers before or instead of the waiter, the UI must - still transition to the logged-in state and refresh room history. - """ payload = event.payload or {} pubkey = payload.get("pubkey_prefix", "") is_admin = payload.get("is_admin", False) - detail = f"admin={is_admin}" - debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}") - self.shared.set_room_login_state(pubkey, 'ok', detail) if pubkey: + self.shared.set_room_login_state(pubkey, 'ok', f'admin={is_admin}') self.shared.load_room_history(pubkey) self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") From 49c8fb338e724980408b82e1db090157821916c4 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 16:40:58 +0100 Subject: [PATCH 16/39] HotFixRoom --- CHANGELOG.md | 42 ++++++++++++------------- meshcore_gui/ble/commands.py | 60 ------------------------------------ meshcore_gui/ble/events.py | 1 + 3 files changed, 20 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da9ed5b..7d6c585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,30 +1,27 @@ -# CHANGELOG +## [1.13.4] - 2026-03-12 β€” Room Server message classification fix - +### Fixed +- πŸ›  **Room messages without `signature` were not shown in the Room Server panel** β€” `CONTACT_MSG_RECV` with `txt_type == 2` is now always treated as room traffic, even when the room server omits the `signature` field. +- πŸ›  **Room messages could be stored under the wrong pubkey** β€” room message classification now prefers `room_pubkey` / receiver-style keys before falling back to `pubkey_prefix`, so incoming room traffic is attached to the room and becomes visible in the room panel/history cache. +- πŸ›  **UI state could lag behind the actual room login event** β€” `LOGIN_SUCCESS` now also updates `room_login_states` and refreshes room history through `SharedData`, so the panel reflects the server-confirmed login immediately. + +### Changed +- πŸ”„ `meshcore_gui/ble/events.py`: relaxed room-message detection from `txt_type == 2 and signature` to `txt_type == 2`; added safer fallbacks for room pubkey and author resolution. +- πŸ”„ `meshcore_gui/ble/worker.py`: `LOGIN_SUCCESS` handler now updates room login state and reloads room history. +- πŸ”„ `meshcore_gui/config.py`: Version kept at `1.13.4`. + +### Impact +- Keeps the original login behaviour without the rejected extra post-login fetch loop from Iteratie A. +- Targets USB/serial and BLE equally because the changes are in the shared event/worker layer above the transport. +- No intended breaking changes outside the Room Server flow. + +--- + +# CHANGELOG All notable changes to MeshCore GUI are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). ---- -## [1.13.4] - 2026-03-12 β€” Room Server USB Login & Fetch Fix - -### Changed -- πŸ”„ `meshcore_gui/ble/commands.py` β€” After `LOGIN_SUCCESS`, the room login flow now starts a bounded background `get_msg()` sync loop so serial/USB sessions actively drain queued room messages instead of relying on a single defensive fetch -- πŸ”„ `meshcore_gui/ble/events.py` β€” Room messages are now classified on `txt_type == 2` even when the `signature` field is absent; sender/room pubkeys also use broader payload fallbacks for room traffic -- πŸ”„ `meshcore_gui/ble/worker.py` β€” Global `LOGIN_SUCCESS` handling now updates `room_login_states` and refreshes cached room history in `SharedData` -- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.4` - -### Fixed -- πŸ›  **USB/serial room login showed only app-sent messages** β€” After login, the app now keeps polling queued room messages for a short window so messages from other room participants are actually fetched -- πŸ›  **Incoming room messages without `signature` were misclassified** β€” `CONTACT_MSG_RECV` packets with `txt_type == 2` no longer fall back to DM handling just because the room server omitted `signature` -- πŸ›  **Room login UI state could depend on one code path** β€” Worker-side `LOGIN_SUCCESS` processing now reinforces the room state update even when the command-side wait path is not the only consumer - -### Impact -- Faster and more reliable room history retrieval on USB/serial setups -- Room traffic from other users has a better chance of appearing in the Room Server panel immediately after login -- No intended regression for DM or normal channel message handling - --- ## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating @@ -85,7 +82,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- ->>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py index d1bae49..9203d93 100644 --- a/meshcore_gui/ble/commands.py +++ b/meshcore_gui/ble/commands.py @@ -35,7 +35,6 @@ class CommandHandler: self._mc = mc self._shared = shared self._cache = cache - self._room_sync_tasks: Dict[str, asyncio.Task] = {} # Handler registry β€” add new commands here (OCP) self._handlers: Dict[str, object] = { @@ -426,8 +425,6 @@ class CommandHandler: except Exception as exc: debug_print(f"login_room: defensive get_msg() error: {exc}") - self._start_room_sync(pubkey, room_name) - else: self._shared.set_room_login_state( pubkey, 'fail', @@ -554,63 +551,6 @@ class CommandHandler: ) debug_print(f"send_room_msg exception: {exc}") - def _cancel_room_sync(self, pubkey: str) -> None: - """Cancel an active background room-history sync task.""" - task = self._room_sync_tasks.pop(pubkey, None) - if task and not task.done(): - task.cancel() - - def _start_room_sync(self, pubkey: str, room_name: str) -> None: - """Start a bounded background fetch loop for room history.""" - self._cancel_room_sync(pubkey) - self._room_sync_tasks[pubkey] = asyncio.create_task( - self._sync_room_history(pubkey, room_name) - ) - - async def _sync_room_history(self, pubkey: str, room_name: str) -> None: - """Fetch queued room messages for a short period after login. - - On some serial/USB setups the SDK's auto-message fetching is - not sufficient to drain the room backlog promptly after - ``LOGIN_SUCCESS``. This bounded loop polls ``get_msg()`` for a - short window so historical room messages from other users are - actually pulled into the app. - """ - idle_errors = 0 - try: - for attempt in range(24): - try: - result = await self._mc.commands.get_msg() - result_type = getattr(result, 'type', None) - if result_type == EventType.ERROR: - idle_errors += 1 - debug_print( - f"room_sync: get_msg ERROR for {room_name} " - f"(attempt {attempt + 1}/24, idle={idle_errors})" - ) - else: - idle_errors = 0 - debug_print( - f"room_sync: get_msg fetched data for {room_name} " - f"(attempt {attempt + 1}/24)" - ) - except Exception as exc: - idle_errors += 1 - debug_print( - f"room_sync: get_msg exception for {room_name}: {exc}" - ) - - if idle_errors >= 4: - break - - await asyncio.sleep(2.0) - except asyncio.CancelledError: - debug_print(f"room_sync: cancelled for {room_name}") - raise - finally: - self._shared.load_room_history(pubkey) - self._room_sync_tasks.pop(pubkey, None) - # ------------------------------------------------------------------ # Callback for refresh (set by SerialWorker after construction) # ------------------------------------------------------------------ diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index e5cd220..cf1150f 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -333,6 +333,7 @@ class EventHandler: or payload.get('sender_pubkey', '') or payload.get('sender_prefix', '') ) + author = '' if author_prefix: author = self._shared.get_contact_name_by_prefix(author_prefix) From 3cf14f8758345c7fb4118edd629485ae1ebb74e2 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 17:50:44 +0100 Subject: [PATCH 17/39] HotFix --- meshcore_gui/ble/events.py | 39 +++++++++++++++++++------------------- meshcore_gui/ble/worker.py | 9 +++++++-- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index cf1150f..7105179 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -302,6 +302,13 @@ class EventHandler: pubkey = payload.get('pubkey_prefix', '') txt_type = payload.get('txt_type', 0) signature = payload.get('signature', '') + room_pubkey = ( + payload.get('room_pubkey') + or payload.get('receiver') + or payload.get('receiver_pubkey') + or payload.get('receiver_pubkey_prefix') + or pubkey + ) debug_print(f"DM payload keys: {list(payload.keys())}") @@ -320,27 +327,21 @@ class EventHandler: # --- Room Server message (txt_type 2) --- if txt_type == 2: - room_pubkey = ( - payload.get('room_pubkey') - or payload.get('receiver_pubkey') - or payload.get('recipient_pubkey') - or payload.get('pubkey') - or pubkey - ) - author_prefix = ( - signature - or payload.get('sender_pubkey_prefix', '') - or payload.get('sender_pubkey', '') - or payload.get('sender_prefix', '') - ) - + # Resolve actual author from signature when present. + # Some room servers omit the signature field; in that case + # fall back to the payload sender/display name if available. author = '' - if author_prefix: - author = self._shared.get_contact_name_by_prefix(author_prefix) + if signature: + author = self._shared.get_contact_name_by_prefix(signature) + if not author: + author = signature[:8] if not author: - author = payload.get('sender_name', '') or payload.get('name', '') - if not author: - author = author_prefix[:8] if author_prefix else room_pubkey[:8] if room_pubkey else '?' + author = ( + payload.get('sender') + or payload.get('name') + or payload.get('author') + or '?' + ) self._shared.add_message(Message.incoming( author, diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index af9965b..449db10 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -259,13 +259,18 @@ class _BaseWorker(abc.ABC): def _on_login_success(self, event) -> None: payload = event.payload or {} - pubkey = payload.get("pubkey_prefix", "") + pubkey = ( + payload.get("room_pubkey") + or payload.get("pubkey_prefix") + or payload.get("receiver") + or "" + ) is_admin = payload.get("is_admin", False) debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}") + self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") if pubkey: self.shared.set_room_login_state(pubkey, 'ok', f'admin={is_admin}') self.shared.load_room_history(pubkey) - self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") # ── apply cache ─────────────────────────────────────────────── From 11dac3e8751e799e8669f9147ce17ceb2f4f2d15 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 18:00:42 +0100 Subject: [PATCH 18/39] hOTfIX --- CHANGELOG.md | 24 +++---------- meshcore_gui/ble/events.py | 69 ++++++++++++++++++++++++-------------- meshcore_gui/ble/worker.py | 16 +++++---- 3 files changed, 58 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6c585..a2c9131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,28 +1,13 @@ -## [1.13.4] - 2026-03-12 β€” Room Server message classification fix - -### Fixed -- πŸ›  **Room messages without `signature` were not shown in the Room Server panel** β€” `CONTACT_MSG_RECV` with `txt_type == 2` is now always treated as room traffic, even when the room server omits the `signature` field. -- πŸ›  **Room messages could be stored under the wrong pubkey** β€” room message classification now prefers `room_pubkey` / receiver-style keys before falling back to `pubkey_prefix`, so incoming room traffic is attached to the room and becomes visible in the room panel/history cache. -- πŸ›  **UI state could lag behind the actual room login event** β€” `LOGIN_SUCCESS` now also updates `room_login_states` and refreshes room history through `SharedData`, so the panel reflects the server-confirmed login immediately. - -### Changed -- πŸ”„ `meshcore_gui/ble/events.py`: relaxed room-message detection from `txt_type == 2 and signature` to `txt_type == 2`; added safer fallbacks for room pubkey and author resolution. -- πŸ”„ `meshcore_gui/ble/worker.py`: `LOGIN_SUCCESS` handler now updates room login state and reloads room history. -- πŸ”„ `meshcore_gui/config.py`: Version kept at `1.13.4`. - -### Impact -- Keeps the original login behaviour without the rejected extra post-login fetch loop from Iteratie A. -- Targets USB/serial and BLE equally because the changes are in the shared event/worker layer above the transport. -- No intended breaking changes outside the Room Server flow. - ---- - # CHANGELOG + + 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/). --- +<<<<<<< HEAD ## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating ### Changed @@ -82,6 +67,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- +>>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 7105179..cb11722 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -293,22 +293,15 @@ class EventHandler: """Handle direct message and room message events. Room Server messages arrive as ``CONTACT_MSG_RECV`` with - ``txt_type == 2``. The ``pubkey_prefix`` is the Room Server's - key and the ``signature`` field contains the original author's - pubkey prefix. We resolve the author name from ``signature`` - so the UI shows who actually wrote the message. + ``txt_type == 2``. Some room servers omit the ``signature`` + field, so room detection may not depend on that key being + present. For room traffic the storage key must match the room + pubkey that the Room Server panel later filters on. """ payload = event.payload pubkey = payload.get('pubkey_prefix', '') txt_type = payload.get('txt_type', 0) signature = payload.get('signature', '') - room_pubkey = ( - payload.get('room_pubkey') - or payload.get('receiver') - or payload.get('receiver_pubkey') - or payload.get('receiver_pubkey_prefix') - or pubkey - ) debug_print(f"DM payload keys: {list(payload.keys())}") @@ -327,21 +320,8 @@ class EventHandler: # --- Room Server message (txt_type 2) --- if txt_type == 2: - # Resolve actual author from signature when present. - # Some room servers omit the signature field; in that case - # fall back to the payload sender/display name if available. - author = '' - if signature: - author = self._shared.get_contact_name_by_prefix(signature) - if not author: - author = signature[:8] - if not author: - author = ( - payload.get('sender') - or payload.get('name') - or payload.get('author') - or '?' - ) + room_pubkey = self._resolve_room_pubkey(payload) or pubkey + author = self._resolve_room_author(payload) self._shared.add_message(Message.incoming( author, @@ -381,6 +361,43 @@ class EventHandler: )) debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") + def _resolve_room_pubkey(self, payload: Dict) -> str: + """Resolve the room key used for room-message storage. + + Prefer explicit room/receiver-style keys because some room-server + events carry the room identity there instead of in + ``pubkey_prefix``. Falling back to ``pubkey_prefix`` preserves + compatibility with earlier working cases. + """ + for key in ( + 'room_pubkey', + 'receiver', + 'receiver_pubkey', + 'receiver_pubkey_prefix', + 'pubkey_prefix', + ): + value = payload.get(key, '') + if isinstance(value, str) and value: + return value + return '' + + def _resolve_room_author(self, payload: Dict) -> str: + """Resolve the display author for a room message.""" + signature = payload.get('signature', '') + if signature: + author = self._shared.get_contact_name_by_prefix(signature) + if author: + return author + return signature[:8] + + for key in ('sender', 'name', 'author'): + value = payload.get(key, '') + if isinstance(value, str) and value: + return value + + pubkey = payload.get('pubkey_prefix', '') + return pubkey[:8] if pubkey else '?' + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 449db10..4f5482a 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -260,17 +260,21 @@ class _BaseWorker(abc.ABC): def _on_login_success(self, event) -> None: payload = event.payload or {} pubkey = ( - payload.get("room_pubkey") - or payload.get("pubkey_prefix") - or payload.get("receiver") - or "" + payload.get('room_pubkey') + or payload.get('pubkey_prefix') + or payload.get('receiver') + or payload.get('receiver_pubkey') + or payload.get('receiver_pubkey_prefix') + or '' ) is_admin = payload.get("is_admin", False) debug_print(f"LOGIN_SUCCESS received: pubkey={pubkey}, admin={is_admin}") - self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") if pubkey: - self.shared.set_room_login_state(pubkey, 'ok', f'admin={is_admin}') + self.shared.set_room_login_state( + pubkey, 'ok', f'admin={is_admin}', + ) self.shared.load_room_history(pubkey) + self.shared.set_status("βœ… Room login OK β€” messages arriving over RF…") # ── apply cache ─────────────────────────────────────────────── From bf031f857ff93e672e4e84d0046352976fd140cb Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Thu, 12 Mar 2026 18:12:13 +0100 Subject: [PATCH 19/39] HotFix --- CHANGELOG.md | 19 + meshcore_gui/ble/events.py | 111 ++-- meshcore_gui/ble/events.py.bak | 379 +++++++++++++ meshcore_gui/ble/worker.py | 30 +- meshcore_gui/ble/worker.py.bak | 964 +++++++++++++++++++++++++++++++++ 5 files changed, 1437 insertions(+), 66 deletions(-) create mode 100644 meshcore_gui/ble/events.py.bak create mode 100644 meshcore_gui/ble/worker.py.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c9131..7ae3259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [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. + +### 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. + +--- + # CHANGELOG + +All notable changes to MeshCore GUI are documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). + + +--- + +> **πŸ“ˆ Performance note β€” v1.13.1 through v1.13.4** +> Although versions 1.13.1–1.13.4 were released as targeted bugfix releases, the +> cumulative effect of the fixes delivered a significant performance improvement: +> +> - **v1.13.1** β€” Bot non-response fix eliminated a silent failure path that caused +> repeated dedup-marked command re-evaluation on every message tick. +> - **v1.13.2** β€” Map display fixes prevented Leaflet from being initialized on hidden +> zero-size containers, removing a source of repeated failed bootstrap retries and +> associated DOM churn. +> - **v1.13.3** β€” Active panel timer gating reduced the 500 ms dashboard update work to +> only the currently visible panel, cutting unnecessary UI updates and background +> redraw load substantially β€” especially noticeable over VPN or on slower hardware. +> - **v1.13.4** β€” Room Server event classification fix and sender name resolution removed +> redundant fallback processing paths and reduced per-tick contact lookup overhead. +> +> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching, +> lower CPU usage during idle operation, and more stable map rendering. + +--- ## [1.13.4] - 2026-03-12 β€” Room Server message classification fix ### Fixed @@ -17,17 +49,7 @@ - No intended behavioural change for ordinary DMs or channel messages. --- - -# CHANGELOG - - - -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/). - --- -<<<<<<< HEAD ## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating ### Changed @@ -87,7 +109,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver - No breaking changes outside the three files listed above --- ->>>>>>> b76eacf1119026c49c25d2811a6d713da8f8e01b ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added From 71a5ebca74cefdc549329b2357fda131c015defb Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 05:25:07 +0100 Subject: [PATCH 28/39] fix: route back-button and map popup flicker (#1.13.5) - Replace two fixed-destination back-buttons on the route page with a single arrow_back button using window.history.back(), so navigation always returns to the calling screen (Messages or Archive). - Guard setIcon() and setPopupContent() in applyDevice/applyContacts behind isPopupOpen() to prevent popup flickering on the 500 ms update tick. - Set fadeAnimation: false and markerZoomAnimation: false on both Leaflet map instances (main map and route map) to eliminate popup flash on first click, particularly noticeable on Raspberry Pi. --- CHANGELOG.md | 18 ++++++++++++++++++ meshcore_gui/config.py | 2 +- meshcore_gui/gui/route_page.py | 8 ++------ meshcore_gui/static/leaflet_map_panel.js | 18 ++++++++++++++---- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94004de..11beeb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > 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.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 diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index cfe4d5b..ab034ac 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.4" +VERSION: str = "1.13.5" # ============================================================================== diff --git a/meshcore_gui/gui/route_page.py b/meshcore_gui/gui/route_page.py index 93e97e9..d56374c 100644 --- a/meshcore_gui/gui/route_page.py +++ b/meshcore_gui/gui/route_page.py @@ -143,12 +143,8 @@ class RoutePage: with ui.header().classes('items-center px-4 py-2 shadow-md'): ui.button( icon='arrow_back', - on_click=lambda: ui.navigate.to('/'), - ).props('flat round dense color=white').tooltip('Back to Dashboard') - ui.button( - icon='history', - on_click=lambda: ui.navigate.to('/archive'), - ).props('flat round dense color=white').tooltip('Back to Archive') + on_click=lambda: ui.run_javascript('window.history.back()'), + ).props('flat round dense color=white').tooltip('Back') ui.label('πŸ—ΊοΈ MeshCore Route').classes( 'text-lg font-bold domca-header-text' ).style("font-family: 'JetBrains Mono', monospace") diff --git a/meshcore_gui/static/leaflet_map_panel.js b/meshcore_gui/static/leaflet_map_panel.js index a845f4d..dc0000d 100644 --- a/meshcore_gui/static/leaflet_map_panel.js +++ b/meshcore_gui/static/leaflet_map_panel.js @@ -81,6 +81,8 @@ maxZoom: 19, zoomControl: true, preferCanvas: true, + fadeAnimation: false, + markerZoomAnimation: false, }); const state = { @@ -289,8 +291,11 @@ } state.deviceMarker.setLatLng(latLng); - state.deviceMarker.setIcon(icon); - state.deviceMarker.setPopupContent(popupHtml); + const devicePopupOpen = state.deviceMarker.isPopupOpen(); + if (!devicePopupOpen) { + state.deviceMarker.setIcon(icon); + state.deviceMarker.setPopupContent(popupHtml); + } state.deviceMarker.options.title = 'πŸ“‘ ' + device.name; } @@ -318,8 +323,11 @@ } existing.setLatLng(latLng); - existing.setIcon(markerIcon); - existing.setPopupContent(popupHtml); + const contactPopupOpen = existing.isPopupOpen(); + if (!contactPopupOpen) { + existing.setIcon(markerIcon); + existing.setPopupContent(popupHtml); + } existing.options.title = markerTitle; if (!state.layers.contacts.hasLayer(existing)) { state.layers.contacts.addLayer(existing); @@ -577,6 +585,8 @@ maxZoom: 19, zoomControl: true, preferCanvas: true, + fadeAnimation: false, + markerZoomAnimation: false, }); host.__meshcoreRouteMap = map; From e3bd422dfd7e1a4d3775832dd89596e29c000f75 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:05:30 +0100 Subject: [PATCH 29/39] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- CHANGELOG.md | 25 ++ meshcore_gui/config.py | 43 +- meshcore_gui/gui/dashboard.py | 21 + meshcore_gui/gui/panels/__init__.py | 1 + meshcore_gui/gui/panels/bbs_panel.py | 310 ++++++++++++++ meshcore_gui/services/bbs_service.py | 610 +++++++++++++++++++++++++++ meshcore_gui/services/bot.py | 35 +- 7 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/services/bbs_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11beeb6..be12974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > 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_service.py`** β€” SQLite-backed BBS persistence layer. + - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. + - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`. + - `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel for the dashboard. + - Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen). + - Region filter (shown only when the active channel has regions configured). + - Category filter (all or specific). + - Scrollable message list with timestamp, sender, category and optional region tag. + - Post form: region select (conditional), category select, text input, Send button. + - Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`. +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsPanel` registered as standalone panel `'bbs'`; menu item `πŸ“‹ BBS` added to the drawer. +- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +### Not changed +- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services. +- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels. + +--- + ## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes ### Fixed diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index ab034ac..6b6a1dd 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.5" +VERSION: str = "1.14.0" # ============================================================================== @@ -388,3 +388,44 @@ RXLOG_RETENTION_DAYS: int = 7 # Retention period for contacts (in days). # Contacts not seen for longer than this are removed from cache. CONTACT_RETENTION_DAYS: int = 90 + + +# ============================================================================== +# BBS β€” Bulletin Board System +# ============================================================================== + +# One entry per BBS-enabled channel. Each entry configures: +# channel β€” MeshCore channel index (never use channel 0). +# name β€” Human-readable channel name shown in the BBS panel. +# regions β€” Optional list of region tags; empty list = no region filtering. +# categories β€” List of valid category tags for this channel. +# allowed_keys β€” Whitelist of sender public keys (hex strings). +# Empty list = only channel security applies (all keys allowed). +# retention_hours β€” How long messages are kept before automatic deletion. + +BBS_CHANNELS: List[Dict] = [ + { + "channel": 2, + "name": "NoodNet Zwolle", + "regions": ["Zwolle", "Dalfsen", "OV-Algemeen"], + "categories": ["MEDISCH", "LOGISTIEK", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 3, + "name": "NoodNet OV", + "regions": [], + "categories": ["STATUS", "ALGEMEEN", "INFRA"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 4, + "name": "Dalfsen", + "regions": [], + "categories": ["MEDISCH", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 24, + }, +] diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index d7cef43..e9876ac 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -16,6 +16,7 @@ from meshcore_gui import config from meshcore_gui.core.protocols import SharedDataReader from meshcore_gui.gui.panels import ( ActionsPanel, + BbsPanel, ContactsPanel, DevicePanel, MapPanel, @@ -24,6 +25,7 @@ from meshcore_gui.gui.panels import ( RxLogPanel, ) from meshcore_gui.gui.archive_page import ArchivePage +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 @@ -264,6 +266,7 @@ _STANDALONE_ITEMS = [ ('\U0001f4e1', 'DEVICE', 'device'), ('\u26a1', 'ACTIONS', 'actions'), ('\U0001f4ca', 'RX LOG', 'rxlog'), + ('\U0001f4cb', 'BBS', 'bbs'), ] _EXT_LINKS = config.EXT_LINKS @@ -295,6 +298,13 @@ class DashboardPage: self._pin_store = pin_store self._room_password_store = room_password_store + # BBS service (singleton, shared with bot routing) + from meshcore_gui import config as _cfg + self._bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler( + self._bbs_service, _cfg.BBS_CHANNELS + ) + # Panels (created fresh on each render) self._device: DevicePanel | None = None self._contacts: ContactsPanel | None = None @@ -303,6 +313,7 @@ class DashboardPage: self._actions: ActionsPanel | None = None self._rxlog: RxLogPanel | None = None self._room_server: RoomServerPanel | None = None + self._bbs: BbsPanel | None = None # Header status label self._status_label = None @@ -349,6 +360,8 @@ class DashboardPage: self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) + from meshcore_gui import config as _cfg + self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) @@ -509,6 +522,7 @@ class DashboardPage: ('actions', self._actions), ('rxlog', self._rxlog), ('rooms', self._room_server), + ('bbs', self._bbs), ] for panel_id, panel_obj in panel_defs: @@ -735,6 +749,9 @@ class DashboardPage: 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) @@ -817,6 +834,10 @@ class DashboardPage: 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() diff --git a/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/gui/panels/__init__.py index 018396d..f9245f4 100644 --- a/meshcore_gui/gui/panels/__init__.py +++ b/meshcore_gui/gui/panels/__init__.py @@ -15,3 +15,4 @@ from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 +from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py new file mode 100644 index 0000000..2a55ea5 --- /dev/null +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -0,0 +1,310 @@ +"""BBS panel β€” offline Bulletin Board System viewer and post form.""" + +from typing import Callable, Dict, List, Optional + +from nicegui import ui + +from meshcore_gui.config import debug_print +from meshcore_gui.services.bbs_service import BbsMessage, BbsService + + +class BbsPanel: + """BBS panel: channel selector, region/category filters, message list and post form. + + All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`. + No direct SQLite access in this class (SOLID: SRP / DIP). + + Args: + put_command: Callable to enqueue a command dict for the worker. + bbs_service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + def __init__( + self, + put_command: Callable[[Dict], None], + bbs_service: BbsService, + channels_config: List[Dict], + ) -> None: + self._put_command = put_command + self._service = bbs_service + self._channels_config = channels_config + + # Indexed for fast lookup + self._channels_by_idx: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # UI state + self._active_channel_idx: int = ( + channels_config[0]["channel"] if channels_config else 0 + ) + self._active_region: Optional[str] = None + self._active_category: Optional[str] = None + + # UI element references + self._msg_list_container = None + self._region_select = None + self._region_row = None + self._category_select = None + self._text_input = None + self._post_region_select = None + self._post_region_row = None + self._post_category_select = None + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete BBS panel layout.""" + with ui.card().classes('w-full'): + ui.label('πŸ“‹ BBS β€” Bulletin Board System').classes('font-bold text-gray-600') + + # ── Channel selector ────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Channel:').classes('text-sm text-gray-600') + for cfg in self._channels_config: + idx = cfg["channel"] + name = cfg["name"] + ui.button( + name, + on_click=lambda i=idx: self._select_channel(i), + ).props('flat no-caps').classes('text-xs') + + ui.separator() + + # ── Filter row ──────────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Filter:').classes('text-sm text-gray-600') + + # Region filter (hidden when channel has no regions) + self._region_row = ui.row().classes('items-center gap-2') + with self._region_row: + ui.label('Region:').classes('text-xs text-gray-600') + self._region_select = ui.select( + options=[], + value=None, + on_change=lambda e: self._on_region_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + # Category filter + with ui.row().classes('items-center gap-2'): + ui.label('Category:').classes('text-xs text-gray-600') + self._category_select = ui.select( + options=[], + value=None, + on_change=lambda e: self._on_category_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + ui.button('πŸ”„ Refresh', on_click=self._refresh_messages).props('flat no-caps').classes('text-xs') + + ui.separator() + + # ── Message list ────────────────────────────────────────── + self._msg_list_container = ui.column().classes( + 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + ) + + ui.separator() + + # ── Post form ───────────────────────────────────────────── + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('Post:').classes('text-sm text-gray-600') + + # Post region select (hidden when channel has no regions) + 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') + + # Post category select + 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') + + ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') + + # Initial render for the default channel + self._select_channel(self._active_channel_idx) + + # ------------------------------------------------------------------ + # Channel selection + # ------------------------------------------------------------------ + + def _select_channel(self, channel_idx: int) -> None: + """Switch the active channel and rebuild filter options. + + Args: + channel_idx: MeshCore channel index to activate. + """ + self._active_channel_idx = channel_idx + self._active_region = None + self._active_category = None + + cfg = self._channels_by_idx.get(channel_idx, {}) + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg.get("categories", []) + + # Region filter visibility + has_regions = bool(regions) + if self._region_row: + self._region_row.set_visibility(has_regions) + if self._post_region_row: + self._post_region_row.set_visibility(has_regions) + + # Populate region selects + region_opts = ["(all)"] + regions + if self._region_select: + self._region_select.options = region_opts + self._region_select.value = "(all)" + if self._post_region_select: + self._post_region_select.options = regions + self._post_region_select.value = regions[0] if regions else None + + # Populate category selects + cat_opts = ["(all)"] + categories + if self._category_select: + self._category_select.options = cat_opts + self._category_select.value = "(all)" + if self._post_category_select: + self._post_category_select.options = categories + self._post_category_select.value = categories[0] if categories else None + + self._refresh_messages() + + # ------------------------------------------------------------------ + # Filter callbacks + # ------------------------------------------------------------------ + + def _on_region_filter(self, value: Optional[str]) -> None: + """Handle region filter change. + + Args: + value: Selected region string, or ``'(all)'``. + """ + self._active_region = None if (not value or value == "(all)") else value + self._refresh_messages() + + def _on_category_filter(self, value: Optional[str]) -> None: + """Handle category filter change. + + Args: + value: Selected category string, or ``'(all)'``. + """ + self._active_category = None if (not value or value == "(all)") else value + self._refresh_messages() + + # ------------------------------------------------------------------ + # Message list refresh + # ------------------------------------------------------------------ + + def _refresh_messages(self) -> None: + """Query the BBS service and rebuild the message list UI.""" + if not self._msg_list_container: + return + + messages = self._service.get_all_messages( + channel=self._active_channel_idx, + region=self._active_region, + category=self._active_category, + ) + + self._msg_list_container.clear() + with self._msg_list_container: + if not messages: + ui.label('No messages.').classes('text-xs text-gray-400 italic') + for msg in messages: + self._render_message_row(msg) + + def _render_message_row(self, msg: BbsMessage) -> None: + """Render a single message row in the message list. + + Args: + msg: ``BbsMessage`` to display. + """ + 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 gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500') + ui.label(msg.text).classes('text-sm') + + # ------------------------------------------------------------------ + # Post + # ------------------------------------------------------------------ + + def _on_post(self) -> None: + """Handle the Send button: validate inputs and post a BBS message.""" + cfg = self._channels_by_idx.get(self._active_channel_idx, {}) + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg.get("categories", []) + + 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 (categories[0] if categories else "") + ) + if not category: + ui.notify("Please select a category.", type="warning") + return + + region = "" + if regions and self._post_region_select: + region = self._post_region_select.value or "" + + # Build and persist the message (GUI post β€” sender is the local device) + msg = BbsMessage( + channel=self._active_channel_idx, + region=region, + category=category, + sender="Me", + sender_key="", + text=text, + ) + self._service.post_message(msg) + + # Optionally also broadcast via the mesh (put_command enqueues for worker) + region_part = f"{region} " if region else "" + mesh_text = f"!bbs post {region_part}{category} {text}" + self._put_command({ + "action": "send_message", + "channel": self._active_channel_idx, + "text": mesh_text, + }) + + debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}") + + if self._text_input: + self._text_input.value = "" + self._refresh_messages() + ui.notify("Message posted.", type="positive") + + # ------------------------------------------------------------------ + # External update hook (called from dashboard timer) + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Called by the dashboard timer. Refreshes if new data arrived. + + Currently a lightweight no-op: the BBS panel refreshes on user + interaction. Override for real-time auto-refresh if desired. + + Args: + data: SharedData snapshot (unused; kept for interface consistency). + """ + # No-op: BBS data is local SQLite, not pushed via SharedData. + # Active refresh only happens on user action or channel switch. diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py new file mode 100644 index 0000000..18b2a6a --- /dev/null +++ b/meshcore_gui/services/bbs_service.py @@ -0,0 +1,610 @@ +""" +Offline Bulletin Board System (BBS) service for MeshCore GUI. + +Stores BBS messages in a local SQLite database, one table per channel. +Each channel is configured via ``BBS_CHANNELS`` in ``config.py``. + +Architecture +~~~~~~~~~~~~ +- ``BbsService`` β€” persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` β€” parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. + +Thread safety +~~~~~~~~~~~~~ +SQLite connections are created in the calling thread. The service uses +``check_same_thread=False`` combined with an internal ``threading.Lock`` +so it is safe to call from both the GUI thread and the worker thread. + +Storage location +~~~~~~~~~~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). +""" + +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 + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +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. + region: Region tag (empty string when channel has no regions). + category: Category tag (e.g. ``'MEDISCH'``). + 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. + Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``. + """ + + def __init__(self, db_path: Path = BBS_DB_PATH) -> None: + self._db_path = db_path + self._lock = threading.Lock() + self._init_db() + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + + 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(""" + 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 a new SQLite connection (check_same_thread=False).""" + 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 msg id={msg.id} ch={msg.channel} " + f"cat={msg.category} sender={msg.sender}" + ) + return msg.id + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def get_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, + ) -> List[BbsMessage]: + """Return the *limit* most recent messages for a channel. + + Args: + channel: MeshCore channel index. + region: Optional region filter (exact match; ``None`` = all). + category: Optional category filter (exact match; ``None`` = all). + limit: Maximum number of messages to return. + + Returns: + List of ``BbsMessage`` objects, newest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + 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 [ + 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], + ) + for row in rows + ] + + def get_all_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + ) -> List[BbsMessage]: + """Return all messages for a channel (oldest first) for the GUI panel. + + Args: + channel: MeshCore channel index. + region: Optional region filter. + category: Optional category filter. + + Returns: + List of ``BbsMessage`` objects, oldest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + 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 [ + 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], + ) + for row in rows + ] + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def purge_expired(self, channel: int, retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a channel. + + Args: + channel: MeshCore channel index. + retention_hours: Messages older than this are deleted. + + Returns: + Number of rows deleted. + """ + cutoff = ( + datetime.now(timezone.utc) - timedelta(hours=retention_hours) + ).isoformat() + + with self._lock: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", + (channel, cutoff), + ) + conn.commit() + deleted = cur.rowcount + if deleted: + debug_print( + f"BBS: purged {deleted} expired messages from ch={channel}" + ) + return deleted + + def purge_all_expired(self, channels_config: List[Dict]) -> None: + """Run retention cleanup for all configured channels. + + Args: + channels_config: List of channel config dicts from ``BBS_CHANNELS``. + """ + for cfg in channels_config: + self.purge_expired(cfg["channel"], cfg["retention_hours"]) + + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- + +class BbsCommandHandler: + """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + + One handler is shared across all configured channels. Channel context + is passed per call so the handler is stateless. + + Args: + service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + # Maximum messages returned per !bbs read call + READ_LIMIT: int = 5 + + def __init__( + self, + service: BbsService, + channels_config: List[Dict], + ) -> None: + self._service = service + # Index by channel number for O(1) lookup + self._channels: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # ------------------------------------------------------------------ + # 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``). + + Returns ``None`` when the message is not a BBS command, the channel + is not configured, or the sender fails the whitelist check. + + 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 + + cfg = self._channels.get(channel_idx) + if cfg is None: + return None # Channel not configured β€” ignore + + # Whitelist check + allowed = cfg.get("allowed_keys", []) + if allowed and sender_key not in allowed: + debug_print( + f"BBS: silently dropping msg from {sender} " + f"(key not in whitelist for ch={channel_idx})" + ) + return None # Silent drop β€” no error reply + + parts = text.split(None, 1) + args = parts[1].strip() if len(parts) > 1 else "" + + return self._dispatch(cfg, sender, sender_key, args) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Route to the appropriate sub-command handler. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs ``. + + Returns: + Reply string (always non-empty). + """ + 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(cfg, sender, sender_key, rest) + if sub == "read": + return self._handle_read(cfg, rest) + if sub == "help" or not sub: + return self._handle_help(cfg) + + return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + + # ------------------------------------------------------------------ + # Sub-command: post + # ------------------------------------------------------------------ + + def _handle_post( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Handle ``!bbs post [region] [category] [text]``. + + When the channel has regions, the first token is the region, + the second is the category, and the remainder is the text. + Without regions, the first token is the category and the + remainder is the text. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs post ``. + + Returns: + Confirmation or error message string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split(None, 2) if args else [] + + if regions: + # Syntax: !bbs post [region] [category] [text] + 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] + + region_upper = region.upper() + valid_regions = [r.upper() for r in regions] + if region_upper not in valid_regions: + return ( + f"Invalid region '{region}'. " + f"Valid: {', '.join(regions)}" + ) + # Normalise to configured casing + region = regions[valid_regions.index(region_upper)] + + category_upper = category.upper() + valid_cats = [c.upper() for c in categories] + if category_upper not in valid_cats: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + else: + # Syntax: !bbs post [category] [text] + if len(tokens) < 2: + return ( + f"Usage: !bbs post [category] [text] | " + f"Categories: {', '.join(categories)}" + ) + region = "" + category, text = tokens[0], tokens[1] + + category_upper = category.upper() + valid_cats = [c.upper() for c in categories] + if category_upper not in valid_cats: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + + msg = BbsMessage( + channel=cfg["channel"], + 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]}" + + # ------------------------------------------------------------------ + # Sub-command: read + # ------------------------------------------------------------------ + + def _handle_read(self, cfg: Dict, args: str) -> str: + """Handle ``!bbs read [region] [category]``. + + With regions: ``!bbs read`` / ``!bbs read [region]`` / + ``!bbs read [region] [category]`` + Without regions: ``!bbs read`` / ``!bbs read [category]`` + + Args: + cfg: Channel configuration dict. + args: Everything after ``!bbs read ``. + + Returns: + Formatted message list or error string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split() if args else [] + + region: Optional[str] = None + category: Optional[str] = None + + if regions: + valid_regions_upper = [r.upper() for r in regions] + valid_cats_upper = [c.upper() for c in categories] + + if len(tokens) >= 1: + tok0 = tokens[0].upper() + if tok0 in valid_regions_upper: + region = regions[valid_regions_upper.index(tok0)] + if len(tokens) >= 2: + tok1 = tokens[1].upper() + if tok1 in valid_cats_upper: + category = categories[valid_cats_upper.index(tok1)] + else: + return ( + f"Invalid category '{tokens[1]}'. " + f"Valid: {', '.join(categories)}" + ) + else: + return ( + f"Invalid region '{tokens[0]}'. " + f"Valid: {', '.join(regions)}" + ) + else: + valid_cats_upper = [c.upper() for c in categories] + if len(tokens) >= 1: + tok0 = tokens[0].upper() + if tok0 in valid_cats_upper: + category = categories[valid_cats_upper.index(tok0)] + else: + return ( + f"Invalid category '{tokens[0]}'. " + f"Valid: {', '.join(categories)}" + ) + + messages = self._service.get_messages( + cfg["channel"], 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", " ") # YYYY-MM-DD HH:MM + 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) + + # ------------------------------------------------------------------ + # Sub-command: help + # ------------------------------------------------------------------ + + def _handle_help(self, cfg: Dict) -> str: + """Return a compact command reference for this channel. + + Args: + cfg: Channel configuration dict. + + Returns: + Help string (single line). + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + name = cfg.get("name", f"ch{cfg['channel']}") + + if regions: + return ( + f"BBS [{name}] | " + f"!bbs post [region] [cat] [text] | " + f"!bbs read [region] [cat] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + return ( + f"BBS [{name}] | " + f"!bbs post [cat] [text] | " + f"!bbs read [cat] | " + f"Categories: {', '.join(categories)}" + ) diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 6e4465a..9279a72 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -10,11 +10,21 @@ 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 Callable, Dict, List, Optional +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 @@ -81,11 +91,13 @@ class MeshBot: 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, @@ -129,6 +141,27 @@ class MeshBot: 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: From d24d38f543e8aeda521a526441406c1f651f3a4a Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:23:06 +0100 Subject: [PATCH 30/39] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- CHANGELOG.md | 32 +- meshcore_gui/config.py | 42 +-- meshcore_gui/gui/dashboard.py | 10 +- meshcore_gui/gui/panels/bbs_panel.py | 401 ++++++++++++++++------ meshcore_gui/services/bbs_config_store.py | 232 +++++++++++++ meshcore_gui/services/bbs_service.py | 212 ++++-------- 6 files changed, 608 insertions(+), 321 deletions(-) create mode 100644 meshcore_gui/services/bbs_config_store.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be12974..9d542cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,25 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) ### Added +- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`. - πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. - - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`. - - `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply. -- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel for the dashboard. - - Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen). - - Region filter (shown only when the active channel has regions configured). - - Category filter (all or specific). - - Scrollable message list with timestamp, sender, category and optional region tag. - - Post form: region select (conditional), category select, text input, Send button. - - Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`. -- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsPanel` registered as standalone panel `'bbs'`; menu item `πŸ“‹ BBS` added to the drawer. + - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`. + - `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. + - Channel-selector (alleen enabled BBS channels). + - Regio-filter (alleen zichtbaar als channel regio's heeft). + - Categorie-filter. + - Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label. + - Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop. + - Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen. + - **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt. + +### Changed +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vΓ³Γ³r keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `πŸ“‹ BBS` toegevoegd aan de drawer. - πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. ### Not changed -- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services. -- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels. +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services. +- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels. --- diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index 6b6a1dd..d7c3dc5 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -390,42 +390,6 @@ RXLOG_RETENTION_DAYS: int = 7 CONTACT_RETENTION_DAYS: int = 90 -# ============================================================================== -# BBS β€” Bulletin Board System -# ============================================================================== - -# One entry per BBS-enabled channel. Each entry configures: -# channel β€” MeshCore channel index (never use channel 0). -# name β€” Human-readable channel name shown in the BBS panel. -# regions β€” Optional list of region tags; empty list = no region filtering. -# categories β€” List of valid category tags for this channel. -# allowed_keys β€” Whitelist of sender public keys (hex strings). -# Empty list = only channel security applies (all keys allowed). -# retention_hours β€” How long messages are kept before automatic deletion. - -BBS_CHANNELS: List[Dict] = [ - { - "channel": 2, - "name": "NoodNet Zwolle", - "regions": ["Zwolle", "Dalfsen", "OV-Algemeen"], - "categories": ["MEDISCH", "LOGISTIEK", "STATUS", "ALGEMEEN"], - "allowed_keys": [], - "retention_hours": 48, - }, - { - "channel": 3, - "name": "NoodNet OV", - "regions": [], - "categories": ["STATUS", "ALGEMEEN", "INFRA"], - "allowed_keys": [], - "retention_hours": 48, - }, - { - "channel": 4, - "name": "Dalfsen", - "regions": [], - "categories": ["MEDISCH", "STATUS", "ALGEMEEN"], - "allowed_keys": [], - "retention_hours": 24, - }, -] +# BBS channel configuration is managed at runtime via BbsConfigStore. +# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json +# and edited through the BBS Settings panel in the GUI. diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index e9876ac..411e67c 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -25,6 +25,7 @@ from meshcore_gui.gui.panels import ( RxLogPanel, ) from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_config_store import BbsConfigStore from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -298,11 +299,11 @@ class DashboardPage: self._pin_store = pin_store self._room_password_store = room_password_store - # BBS service (singleton, shared with bot routing) - from meshcore_gui import config as _cfg + # 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, _cfg.BBS_CHANNELS + self._bbs_service, self._bbs_config_store ) # Panels (created fresh on each render) @@ -360,8 +361,7 @@ class DashboardPage: self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) - from meshcore_gui import config as _cfg - self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS) + self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 2a55ea5..c642ad8 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -1,56 +1,64 @@ -"""BBS panel β€” offline Bulletin Board System viewer and post form.""" +"""BBS panel -- offline Bulletin Board System viewer, post form and settings.""" 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 ( + BbsConfigStore, + DEFAULT_CATEGORIES, + DEFAULT_RETENTION_HOURS, +) from meshcore_gui.services.bbs_service import BbsMessage, BbsService class BbsPanel: - """BBS panel: channel selector, region/category filters, message list and post form. + """BBS panel: channel selector, filters, message list, post form and settings. - All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`. + The settings section lists all active device channels (from SharedData) + and lets the user enable/disable BBS per channel and configure + categories, regions and retention. Configuration is persisted via + BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json. + + All data access goes through BbsService and BbsConfigStore. No direct SQLite access in this class (SOLID: SRP / DIP). Args: - put_command: Callable to enqueue a command dict for the worker. - bbs_service: Shared ``BbsService`` instance. - channels_config: ``BBS_CHANNELS`` list from ``config.py``. + 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, - channels_config: List[Dict], + config_store: BbsConfigStore, ) -> None: self._put_command = put_command self._service = bbs_service - self._channels_config = channels_config + self._config_store = config_store - # Indexed for fast lookup - self._channels_by_idx: Dict[int, Dict] = { - cfg["channel"]: cfg for cfg in channels_config - } - - # UI state - self._active_channel_idx: int = ( - channels_config[0]["channel"] if channels_config else 0 - ) + # Active view state + self._active_channel_idx: Optional[int] = None self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI element references + # UI element references -- message view self._msg_list_container = None - self._region_select = None self._region_row = None + self._region_select = None self._category_select = None self._text_input = None - self._post_region_select = None self._post_region_row = None + self._post_region_select = None self._post_category_select = None + self._channel_btn_row = None + + # UI element references -- settings + self._settings_container = None + self._last_device_channels: List[Dict] = [] # ------------------------------------------------------------------ # Render @@ -59,26 +67,20 @@ class BbsPanel: def render(self) -> None: """Build the complete BBS panel layout.""" with ui.card().classes('w-full'): - ui.label('πŸ“‹ BBS β€” Bulletin Board System').classes('font-bold text-gray-600') + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - # ── Channel selector ────────────────────────────────────── - with ui.row().classes('w-full items-center gap-4'): + # ---- Channel selector ----------------------------------- + self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._channel_btn_row: ui.label('Channel:').classes('text-sm text-gray-600') - for cfg in self._channels_config: - idx = cfg["channel"] - name = cfg["name"] - ui.button( - name, - on_click=lambda i=idx: self._select_channel(i), - ).props('flat no-caps').classes('text-xs') + # Populated by _rebuild_channel_buttons() ui.separator() - # ── Filter row ──────────────────────────────────────────── - with ui.row().classes('w-full items-center gap-4'): + # ---- Filter row ---------------------------------------- + with ui.row().classes('w-full items-center gap-4 flex-wrap'): ui.label('Filter:').classes('text-sm text-gray-600') - # Region filter (hidden when channel has no regions) self._region_row = ui.row().classes('items-center gap-2') with self._region_row: ui.label('Region:').classes('text-xs text-gray-600') @@ -88,7 +90,6 @@ class BbsPanel: on_change=lambda e: self._on_region_filter(e.value), ).classes('text-xs').style('min-width: 120px') - # Category filter with ui.row().classes('items-center gap-2'): ui.label('Category:').classes('text-xs text-gray-600') self._category_select = ui.select( @@ -97,48 +98,84 @@ class BbsPanel: on_change=lambda e: self._on_category_filter(e.value), ).classes('text-xs').style('min-width: 120px') - ui.button('πŸ”„ Refresh', on_click=self._refresh_messages).props('flat no-caps').classes('text-xs') + ui.button( + 'Refresh', on_click=self._refresh_messages + ).props('flat no-caps').classes('text-xs') ui.separator() - # ── Message list ────────────────────────────────────────── + # ---- Message list -------------------------------------- self._msg_list_container = ui.column().classes( 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' ) ui.separator() - # ── Post form ───────────────────────────────────────────── + # ---- Post form ----------------------------------------- with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') - # Post region select (hidden when channel has no regions) 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', + options=[], label='Region', ).classes('text-xs').style('min-width: 110px') - # Post category select self._post_category_select = ui.select( - options=[], - label='Category', + options=[], label='Category', ).classes('text-xs').style('min-width: 110px') self._text_input = ui.input( - placeholder='Message text…', + placeholder='Message text...', ).classes('flex-grow text-sm') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # Initial render for the default channel - self._select_channel(self._active_channel_idx) + # ---- Settings card ----------------------------------------- + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.label( + 'Enable BBS on a channel to allow !bbs commands and store messages.' + ).classes('text-xs text-gray-500') + ui.separator() + self._settings_container = ui.column().classes('w-full gap-2') + with self._settings_container: + ui.label('Waiting for device channels...').classes( + 'text-xs text-gray-400 italic' + ) # ------------------------------------------------------------------ - # Channel selection + # Channel selector (message view) # ------------------------------------------------------------------ + def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None: + """Rebuild the channel selector buttons for enabled BBS channels. + + Args: + enabled_channels: List of enabled channel config dicts. + """ + if not self._channel_btn_row: + return + self._channel_btn_row.clear() + with self._channel_btn_row: + ui.label('Channel:').classes('text-sm text-gray-600') + if not enabled_channels: + ui.label('No BBS channels configured.').classes( + 'text-xs text-gray-400 italic' + ) + return + for cfg in enabled_channels: + idx = cfg['channel'] + name = cfg['name'] + ui.button( + name, + on_click=lambda i=idx: self._select_channel(i), + ).props('flat no-caps').classes('text-xs') + + # Auto-select first channel when none is active yet + if self._active_channel_idx is None and enabled_channels: + self._select_channel(enabled_channels[0]['channel']) + def _select_channel(self, channel_idx: int) -> None: """Switch the active channel and rebuild filter options. @@ -149,31 +186,28 @@ class BbsPanel: self._active_region = None self._active_category = None - cfg = self._channels_by_idx.get(channel_idx, {}) - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg.get("categories", []) + cfg = self._config_store.get_channel(channel_idx) or {} + regions: List[str] = cfg.get('regions', []) + categories: List[str] = cfg.get('categories', []) - # Region filter visibility has_regions = bool(regions) if self._region_row: self._region_row.set_visibility(has_regions) if self._post_region_row: self._post_region_row.set_visibility(has_regions) - # Populate region selects - region_opts = ["(all)"] + regions + region_opts = ['(all)'] + regions if self._region_select: self._region_select.options = region_opts - self._region_select.value = "(all)" + self._region_select.value = '(all)' if self._post_region_select: self._post_region_select.options = regions self._post_region_select.value = regions[0] if regions else None - # Populate category selects - cat_opts = ["(all)"] + categories + cat_opts = ['(all)'] + categories if self._category_select: self._category_select.options = cat_opts - self._category_select.value = "(all)" + self._category_select.value = '(all)' if self._post_category_select: self._post_category_select.options = categories self._post_category_select.value = categories[0] if categories else None @@ -185,21 +219,11 @@ class BbsPanel: # ------------------------------------------------------------------ def _on_region_filter(self, value: Optional[str]) -> None: - """Handle region filter change. - - Args: - value: Selected region string, or ``'(all)'``. - """ - self._active_region = None if (not value or value == "(all)") else value + self._active_region = None if (not value or value == '(all)') else value self._refresh_messages() def _on_category_filter(self, value: Optional[str]) -> None: - """Handle category filter change. - - Args: - value: Selected category string, or ``'(all)'``. - """ - self._active_category = None if (not value or value == "(all)") else value + self._active_category = None if (not value or value == '(all)') else value self._refresh_messages() # ------------------------------------------------------------------ @@ -210,30 +234,31 @@ class BbsPanel: """Query the BBS service and rebuild the message list UI.""" if not self._msg_list_container: return - - messages = self._service.get_all_messages( - channel=self._active_channel_idx, - region=self._active_region, - category=self._active_category, - ) - self._msg_list_container.clear() with self._msg_list_container: + if self._active_channel_idx is None: + ui.label('Select a channel above.').classes('text-xs text-gray-400 italic') + return + messages = self._service.get_all_messages( + channel=self._active_channel_idx, + region=self._active_region, + 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: - """Render a single message row in the message list. + """Render a single message row. Args: - msg: ``BbsMessage`` to display. + msg: BbsMessage to display. """ - ts = msg.timestamp[:16].replace("T", " ") - region_label = f" [{msg.region}]" if msg.region else "" - header = f"{ts} {msg.sender} [{msg.category}]{region_label}" - + 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 gap-0 py-1 border-b border-gray-200'): ui.label(header).classes('text-xs text-gray-500') ui.label(msg.text).classes('text-sm') @@ -244,67 +269,221 @@ class BbsPanel: def _on_post(self) -> None: """Handle the Send button: validate inputs and post a BBS message.""" - cfg = self._channels_by_idx.get(self._active_channel_idx, {}) - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg.get("categories", []) + if self._active_channel_idx is None: + ui.notify('Select a channel first.', type='warning') + return - text = (self._text_input.value or "").strip() if self._text_input else "" + cfg = self._config_store.get_channel(self._active_channel_idx) or {} + regions: List[str] = cfg.get('regions', []) + categories: List[str] = cfg.get('categories', []) + + text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify("Message text cannot be empty.", type="warning") + ui.notify('Message text cannot be empty.', type='warning') return category = ( self._post_category_select.value - if self._post_category_select - else (categories[0] if categories else "") + if self._post_category_select else (categories[0] if categories else '') ) if not category: - ui.notify("Please select a category.", type="warning") + ui.notify('Please select a category.', type='warning') return - region = "" + region = '' if regions and self._post_region_select: - region = self._post_region_select.value or "" + region = self._post_region_select.value or '' - # Build and persist the message (GUI post β€” sender is the local device) msg = BbsMessage( channel=self._active_channel_idx, region=region, category=category, - sender="Me", - sender_key="", + sender='Me', + sender_key='', text=text, ) self._service.post_message(msg) - # Optionally also broadcast via the mesh (put_command enqueues for worker) - region_part = f"{region} " if region else "" - mesh_text = f"!bbs post {region_part}{category} {text}" + # Broadcast on the mesh channel + region_part = f'{region} ' if region else '' + mesh_text = f'!bbs post {region_part}{category} {text}' self._put_command({ - "action": "send_message", - "channel": self._active_channel_idx, - "text": mesh_text, + 'action': 'send_message', + 'channel': self._active_channel_idx, + 'text': mesh_text, }) - - debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}") + debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}') if self._text_input: - self._text_input.value = "" + self._text_input.value = '' self._refresh_messages() - ui.notify("Message posted.", type="positive") + ui.notify('Message posted.', type='positive') + + # ------------------------------------------------------------------ + # Settings panel + # ------------------------------------------------------------------ + + def _rebuild_settings(self, device_channels: List[Dict]) -> None: + """Rebuild the settings rows for all device channels. + + Called from update() when the device channel list changes. + + Args: + device_channels: Channel list from SharedData snapshot. + """ + if not self._settings_container: + return + self._settings_container.clear() + with self._settings_container: + if not device_channels: + ui.label('No channels received from device yet.').classes( + 'text-xs text-gray-400 italic' + ) + return + for ch in device_channels: + self._render_settings_row(ch) + + def _render_settings_row(self, device_ch: Dict) -> None: + """Render one settings row for a single device channel. + + Args: + device_ch: Channel dict from SharedData (keys: idx, name). + """ + ch_idx = device_ch.get('idx', device_ch.get('index', 0)) + ch_name = device_ch.get('name', f'Ch {ch_idx}') + bbs_cfg = self._config_store.get_channel(ch_idx) + is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False + + with ui.expansion( + f'[{ch_idx}] {ch_name}', + value=False, + ).classes('w-full').props('dense'): + + with ui.column().classes('w-full gap-2 p-2'): + + # Enable / disable toggle + enable_cb = ui.checkbox( + 'Enable BBS on this channel', + value=is_enabled, + ) + + # Categories input + cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES) + cats_input = ui.input( + label='Categories (comma-separated)', + value=cats_val, + ).classes('w-full text-xs') + + # Regions input + regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else '' + regions_input = ui.input( + label='Regions (comma-separated, leave empty for none)', + value=regions_val, + ).classes('w-full text-xs') + + # Retention + ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS) + retention_input = ui.input( + label='Retention (hours)', + value=ret_val, + ).classes('w-full text-xs').style('max-width: 160px') + + # Whitelist + wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else '' + whitelist_input = ui.input( + label='Allowed keys (comma-separated hex, leave empty for all)', + value=wl_val, + ).classes('w-full text-xs') + + # Save button + def _save( + idx=ch_idx, + name=ch_name, + cb=enable_cb, + cats=cats_input, + regs=regions_input, + ret=retention_input, + wl=whitelist_input, + ) -> None: + categories = [ + c.strip().upper() + for c in (cats.value or '').split(',') + if c.strip() + ] + regions = [ + r.strip() + for r in (regs.value or '').split(',') + if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wl.value or '').split(',') + if k.strip() + ] + cfg_entry = { + 'channel': idx, + 'name': name, + 'enabled': cb.value, + 'categories': categories if categories else list(DEFAULT_CATEGORIES), + 'regions': regions, + 'retention_hours': retention_hours, + 'allowed_keys': allowed_keys, + } + self._config_store.set_channel(cfg_entry) + debug_print( + f'BBS settings: saved ch={idx} enabled={cb.value} ' + f'cats={categories} regions={regions}' + ) + ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive') + # Refresh channel buttons and message view + self._refresh_after_settings_save() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + + def _refresh_after_settings_save(self) -> None: + """Rebuild the channel selector buttons after a settings save.""" + enabled = self._config_store.get_enabled_channels() + self._rebuild_channel_buttons(enabled) + # Reset active channel if it was disabled + if self._active_channel_idx is not None: + cfg = self._config_store.get_channel(self._active_channel_idx) + if not cfg or not cfg.get('enabled', False): + self._active_channel_idx = None + if self._msg_list_container: + self._msg_list_container.clear() + with self._msg_list_container: + ui.label('Select a channel above.').classes( + 'text-xs text-gray-400 italic' + ) # ------------------------------------------------------------------ # External update hook (called from dashboard timer) # ------------------------------------------------------------------ def update(self, data: Dict) -> None: - """Called by the dashboard timer. Refreshes if new data arrived. + """Called by the dashboard timer with the SharedData snapshot. - Currently a lightweight no-op: the BBS panel refreshes on user - interaction. Override for real-time auto-refresh if desired. + Rebuilds the settings panel when the device channel list changes. Args: - data: SharedData snapshot (unused; kept for interface consistency). + data: SharedData snapshot dict. """ - # No-op: BBS data is local SQLite, not pushed via SharedData. - # Active refresh only happens on user action or channel switch. + device_channels = data.get('channels', []) + + # Rebuild settings only when the channel list changes + ch_fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels + ) + last_fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels + ) + if ch_fingerprint != last_fingerprint: + self._last_device_channels = device_channels + self._rebuild_settings(device_channels) + # Also rebuild channel buttons (config may have changed) + enabled = self._config_store.get_enabled_channels() + self._rebuild_channel_buttons(enabled) diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py new file mode 100644 index 0000000..1e43842 --- /dev/null +++ b/meshcore_gui/services/bbs_config_store.py @@ -0,0 +1,232 @@ +""" +BBS channel configuration store for MeshCore GUI. + +Persists BBS channel configuration to +``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive +restarts and are managed outside of ``config.py``. + +On first use the file is created with an empty channel list. +The GUI populates it when the user enables BBS on a device channel. + +Thread safety +~~~~~~~~~~~~~ +All methods acquire an internal ``threading.Lock``. +""" + +import json +import threading +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +# --------------------------------------------------------------------------- +# Storage location +# --------------------------------------------------------------------------- + +BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" +BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" + +CONFIG_VERSION: int = 1 + +# --------------------------------------------------------------------------- +# Default values applied when a channel is first enabled +# --------------------------------------------------------------------------- + +DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] +DEFAULT_REGIONS: List[str] = [] +DEFAULT_RETENTION_HOURS: int = 48 + + +class BbsConfigStore: + """Persistent store for BBS channel 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._data: Dict = {"version": CONFIG_VERSION, "channels": []} + self._load() + + # ------------------------------------------------------------------ + # Load / save + # ------------------------------------------------------------------ + + def _load(self) -> None: + """Load config from disk; create defaults if file is absent.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + + if not self._path.exists(): + self._save_unlocked() + debug_print("BBS config: created new config file") + return + + try: + raw = self._path.read_text(encoding="utf-8") + data = json.loads(raw) + if data.get("version") == CONFIG_VERSION: + self._data = data + debug_print( + f"BBS config: loaded {len(self._data.get('channels', []))} channels" + ) + else: + debug_print( + f"BBS config: version mismatch " + f"(got {data.get('version')}, expected {CONFIG_VERSION}) β€” using defaults" + ) + except (json.JSONDecodeError, OSError) as exc: + debug_print(f"BBS config: load error ({exc}) β€” using defaults") + + def _save_unlocked(self) -> None: + """Write config to disk. MUST be called with self._lock held.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".tmp") + tmp.write_text( + json.dumps(self._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() + + # ------------------------------------------------------------------ + # Channel management + # ------------------------------------------------------------------ + + def get_channels(self) -> List[Dict]: + """Return a copy of all configured channels (enabled and disabled). + + Returns: + List of channel config dicts. + """ + with self._lock: + return [ch.copy() for ch in self._data.get("channels", [])] + + def get_enabled_channels(self) -> List[Dict]: + """Return only channels with ``enabled: true``. + + Returns: + List of enabled channel config dicts. + """ + with self._lock: + return [ + ch.copy() + for ch in self._data.get("channels", []) + if ch.get("enabled", False) + ] + + def get_channel(self, channel_idx: int) -> Optional[Dict]: + """Return config for a single channel index, or ``None``. + + Args: + channel_idx: MeshCore channel index. + + Returns: + Channel config dict copy, or ``None`` if not found. + """ + with self._lock: + for ch in self._data.get("channels", []): + if ch.get("channel") == channel_idx: + return ch.copy() + return None + + def set_channel(self, channel_cfg: Dict) -> None: + """Insert or update a channel configuration entry. + + The channel is identified by the ``channel`` key in *channel_cfg*. + If an entry with the same index exists it is replaced; otherwise + a new entry is appended. + + Args: + channel_cfg: Channel config dict (must contain ``'channel'``). + """ + idx = channel_cfg["channel"] + with self._lock: + channels = self._data.setdefault("channels", []) + for i, ch in enumerate(channels): + if ch.get("channel") == idx: + channels[i] = channel_cfg.copy() + self._save_unlocked() + debug_print(f"BBS config: updated ch={idx}") + return + channels.append(channel_cfg.copy()) + self._save_unlocked() + debug_print(f"BBS config: added ch={idx}") + + def enable_channel( + self, + channel_idx: int, + name: str, + *, + categories: Optional[List[str]] = None, + regions: Optional[List[str]] = None, + retention_hours: int = DEFAULT_RETENTION_HOURS, + allowed_keys: Optional[List[str]] = None, + ) -> None: + """Enable BBS on a device channel, creating a default config if needed. + + If the channel already exists its ``enabled`` flag is set to + ``True`` and other fields are left as-is. Pass explicit keyword + arguments to override any field on a new channel. + + Args: + channel_idx: MeshCore channel index. + name: Human-readable channel name. + categories: Category list (defaults to ``DEFAULT_CATEGORIES``). + regions: Region list (defaults to empty β€” no regions). + retention_hours: Retention in hours (default 48). + allowed_keys: Sender key whitelist (default empty = all allowed). + """ + existing = self.get_channel(channel_idx) + if existing: + existing["enabled"] = True + self.set_channel(existing) + else: + self.set_channel({ + "channel": channel_idx, + "name": name, + "enabled": True, + "categories": categories if categories is not None else list(DEFAULT_CATEGORIES), + "regions": regions if regions is not None else list(DEFAULT_REGIONS), + "retention_hours": retention_hours, + "allowed_keys": allowed_keys if allowed_keys is not None else [], + }) + + def disable_channel(self, channel_idx: int) -> None: + """Set ``enabled: false`` for a channel without removing its config. + + Args: + channel_idx: MeshCore channel index. + """ + existing = self.get_channel(channel_idx) + if existing: + existing["enabled"] = False + self.set_channel(existing) + debug_print(f"BBS config: disabled ch={channel_idx}") + + def update_channel_field( + self, channel_idx: int, field: str, value + ) -> bool: + """Update a single field on an existing channel entry. + + Args: + channel_idx: MeshCore channel index. + field: Field name to update. + value: New value. + + Returns: + ``True`` if the channel was found and updated, ``False`` otherwise. + """ + existing = self.get_channel(channel_idx) + if not existing: + return False + existing[field] = value + self.set_channel(existing) + return True diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 18b2a6a..2286c08 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -2,7 +2,9 @@ Offline Bulletin Board System (BBS) service for MeshCore GUI. Stores BBS messages in a local SQLite database, one table per channel. -Each channel is configured via ``BBS_CHANNELS`` in ``config.py``. +Channel configuration is managed by +:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and +persisted to ``~/.meshcore-gui/bbs/bbs_config.json``. Architecture ~~~~~~~~~~~~ @@ -19,6 +21,7 @@ so it is safe to call from both the GUI thread and the worker thread. Storage location ~~~~~~~~~~~~~~~~ ``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore). """ import sqlite3 @@ -94,6 +97,8 @@ class BbsService: """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, @@ -182,7 +187,10 @@ class BbsService: Returns: List of ``BbsMessage`` objects, newest first. """ - query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + query = ( + "SELECT id, channel, region, category, sender, sender_key, text, timestamp " + "FROM bbs_messages WHERE channel = ?" + ) params: list = [channel] if region: @@ -199,19 +207,7 @@ class BbsService: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - 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], - ) - for row in rows - ] + return [self._row_to_msg(row) for row in rows] def get_all_messages( self, @@ -229,7 +225,10 @@ class BbsService: Returns: List of ``BbsMessage`` objects, oldest first. """ - query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + query = ( + "SELECT id, channel, region, category, sender, sender_key, text, timestamp " + "FROM bbs_messages WHERE channel = ?" + ) params: list = [channel] if region: @@ -245,19 +244,20 @@ class BbsService: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - 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], - ) - for row in rows - ] + return [self._row_to_msg(row) for row 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 @@ -295,7 +295,7 @@ class BbsService: """Run retention cleanup for all configured channels. Args: - channels_config: List of channel config dicts from ``BBS_CHANNELS``. + channels_config: List of channel config dicts. """ for cfg in channels_config: self.purge_expired(cfg["channel"], cfg["retention_hours"]) @@ -308,27 +308,28 @@ class BbsService: class BbsCommandHandler: """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - One handler is shared across all configured channels. Channel context - is passed per call so the handler is stateless. + Channel configuration is read live from the supplied + :class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` + so that changes made in the GUI take effect immediately without + restarting the application. Args: - service: Shared ``BbsService`` instance. - channels_config: ``BBS_CHANNELS`` list from ``config.py``. + service: Shared ``BbsService`` instance. + config_store: ``BbsConfigStore`` instance for live channel config. """ - # Maximum messages returned per !bbs read call READ_LIMIT: int = 5 - def __init__( - self, - service: BbsService, - channels_config: List[Dict], - ) -> None: + def __init__(self, service: BbsService, config_store) -> None: self._service = service - # Index by channel number for O(1) lookup - self._channels: Dict[int, Dict] = { - cfg["channel"]: cfg for cfg in channels_config - } + self._config_store = config_store + + def _get_cfg(self, channel_idx: int) -> Optional[Dict]: + """Return enabled channel config, or ``None``.""" + cfg = self._config_store.get_channel(channel_idx) + if cfg and cfg.get("enabled", False): + return cfg + return None # ------------------------------------------------------------------ # Public entry point @@ -343,9 +344,6 @@ class BbsCommandHandler: ) -> Optional[str]: """Parse an incoming message and return a reply string (or ``None``). - Returns ``None`` when the message is not a BBS command, the channel - is not configured, or the sender fails the whitelist check. - Args: channel_idx: MeshCore channel index the message arrived on. sender: Display name of the sender. @@ -359,9 +357,9 @@ class BbsCommandHandler: if not text.lower().startswith("!bbs"): return None - cfg = self._channels.get(channel_idx) + cfg = self._get_cfg(channel_idx) if cfg is None: - return None # Channel not configured β€” ignore + return None # Whitelist check allowed = cfg.get("allowed_keys", []) @@ -370,35 +368,17 @@ class BbsCommandHandler: f"BBS: silently dropping msg from {sender} " f"(key not in whitelist for ch={channel_idx})" ) - return None # Silent drop β€” no error reply + return None parts = text.split(None, 1) args = parts[1].strip() if len(parts) > 1 else "" - return self._dispatch(cfg, sender, sender_key, args) # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ - def _dispatch( - self, - cfg: Dict, - sender: str, - sender_key: str, - args: str, - ) -> str: - """Route to the appropriate sub-command handler. - - Args: - cfg: Channel configuration dict. - sender: Display name of the sender. - sender_key: Public key of the sender. - args: Everything after ``!bbs ``. - - Returns: - Reply string (always non-empty). - """ + def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: sub = args.split(None, 1)[0].lower() if args else "" rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" @@ -408,42 +388,18 @@ class BbsCommandHandler: return self._handle_read(cfg, rest) if sub == "help" or not sub: return self._handle_help(cfg) - return f"Unknown command '{sub}'. {self._handle_help(cfg)}" # ------------------------------------------------------------------ # Sub-command: post # ------------------------------------------------------------------ - def _handle_post( - self, - cfg: Dict, - sender: str, - sender_key: str, - args: str, - ) -> str: - """Handle ``!bbs post [region] [category] [text]``. - - When the channel has regions, the first token is the region, - the second is the category, and the remainder is the text. - Without regions, the first token is the category and the - remainder is the text. - - Args: - cfg: Channel configuration dict. - sender: Display name of the sender. - sender_key: Public key of the sender. - args: Everything after ``!bbs post ``. - - Returns: - Confirmation or error message string. - """ + def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] tokens = args.split(None, 2) if args else [] if regions: - # Syntax: !bbs post [region] [category] [text] if len(tokens) < 3: return ( f"Usage: !bbs post [region] [category] [text] | " @@ -451,27 +407,17 @@ class BbsCommandHandler: f"Categories: {', '.join(categories)}" ) region, category, text = tokens[0], tokens[1], tokens[2] - region_upper = region.upper() valid_regions = [r.upper() for r in regions] if region_upper not in valid_regions: - return ( - f"Invalid region '{region}'. " - f"Valid: {', '.join(regions)}" - ) - # Normalise to configured casing + return f"Invalid region '{region}'. Valid: {', '.join(regions)}" region = regions[valid_regions.index(region_upper)] - category_upper = category.upper() valid_cats = [c.upper() for c in categories] if category_upper not in valid_cats: - return ( - f"Invalid category '{category}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" category = categories[valid_cats.index(category_upper)] else: - # Syntax: !bbs post [category] [text] if len(tokens) < 2: return ( f"Usage: !bbs post [category] [text] | " @@ -479,14 +425,10 @@ class BbsCommandHandler: ) region = "" category, text = tokens[0], tokens[1] - category_upper = category.upper() valid_cats = [c.upper() for c in categories] if category_upper not in valid_cats: - return ( - f"Invalid category '{category}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" category = categories[valid_cats.index(category_upper)] msg = BbsMessage( @@ -506,19 +448,6 @@ class BbsCommandHandler: # ------------------------------------------------------------------ def _handle_read(self, cfg: Dict, args: str) -> str: - """Handle ``!bbs read [region] [category]``. - - With regions: ``!bbs read`` / ``!bbs read [region]`` / - ``!bbs read [region] [category]`` - Without regions: ``!bbs read`` / ``!bbs read [category]`` - - Args: - cfg: Channel configuration dict. - args: Everything after ``!bbs read ``. - - Returns: - Formatted message list or error string. - """ regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] tokens = args.split() if args else [] @@ -529,7 +458,6 @@ class BbsCommandHandler: if regions: valid_regions_upper = [r.upper() for r in regions] valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: tok0 = tokens[0].upper() if tok0 in valid_regions_upper: @@ -539,15 +467,9 @@ class BbsCommandHandler: if tok1 in valid_cats_upper: category = categories[valid_cats_upper.index(tok1)] else: - return ( - f"Invalid category '{tokens[1]}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" else: - return ( - f"Invalid region '{tokens[0]}'. " - f"Valid: {', '.join(regions)}" - ) + return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" else: valid_cats_upper = [c.upper() for c in categories] if len(tokens) >= 1: @@ -555,14 +477,10 @@ class BbsCommandHandler: if tok0 in valid_cats_upper: category = categories[valid_cats_upper.index(tok0)] else: - return ( - f"Invalid category '{tokens[0]}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" messages = self._service.get_messages( - cfg["channel"], region=region, category=category, - limit=self.READ_LIMIT, + cfg["channel"], region=region, category=category, limit=self.READ_LIMIT, ) if not messages: @@ -570,11 +488,9 @@ class BbsCommandHandler: lines = [] for m in messages: - ts = m.timestamp[:16].replace("T", " ") # YYYY-MM-DD HH:MM + 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}" - ) + lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}") return "\n".join(lines) # ------------------------------------------------------------------ @@ -582,18 +498,9 @@ class BbsCommandHandler: # ------------------------------------------------------------------ def _handle_help(self, cfg: Dict) -> str: - """Return a compact command reference for this channel. - - Args: - cfg: Channel configuration dict. - - Returns: - Help string (single line). - """ regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] name = cfg.get("name", f"ch{cfg['channel']}") - if regions: return ( f"BBS [{name}] | " @@ -608,3 +515,4 @@ class BbsCommandHandler: f"!bbs read [cat] | " f"Categories: {', '.join(categories)}" ) + From 430628c945fc10ba45b5c9efb7d9e2f05921cd97 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:37:10 +0100 Subject: [PATCH 31/39] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- meshcore_gui/CHANGELOG.md | 922 ++++++++++++++++++ meshcore_gui/meshcore_gui/config.py | 395 ++++++++ meshcore_gui/meshcore_gui/gui/dashboard.py | 850 ++++++++++++++++ .../meshcore_gui/gui/panels/__init__.py | 18 + .../meshcore_gui/gui/panels/bbs_panel.py | 535 ++++++++++ .../meshcore_gui/services/bbs_config_store.py | 302 ++++++ .../meshcore_gui/services/bbs_service.py | 468 +++++++++ meshcore_gui/meshcore_gui/services/bot.py | 221 +++++ 8 files changed, 3711 insertions(+) create mode 100644 meshcore_gui/CHANGELOG.md create mode 100644 meshcore_gui/meshcore_gui/config.py create mode 100644 meshcore_gui/meshcore_gui/gui/dashboard.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/__init__.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_config_store.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_service.py create mode 100644 meshcore_gui/meshcore_gui/services/bot.py diff --git a/meshcore_gui/CHANGELOG.md b/meshcore_gui/CHANGELOG.md new file mode 100644 index 0000000..6df97ad --- /dev/null +++ b/meshcore_gui/CHANGELOG.md @@ -0,0 +1,922 @@ + + +# CHANGELOG + + + +All notable changes to MeshCore GUI are documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). + + +--- + +> **πŸ“ˆ Performance note β€” v1.13.1 through v1.13.4** +> Although versions 1.13.1–1.13.4 were released as targeted bugfix releases, the +> cumulative effect of the fixes delivered a significant performance improvement: +> +> - **v1.13.1** β€” Bot non-response fix eliminated a silent failure path that caused +> repeated dedup-marked command re-evaluation on every message tick. +> - **v1.13.2** β€” Map display fixes prevented Leaflet from being initialized on hidden +> zero-size containers, removing a source of repeated failed bootstrap retries and +> associated DOM churn. +> - **v1.13.3** β€” Active panel timer gating reduced the 500 ms dashboard update work to +> only the currently visible panel, cutting unnecessary UI updates and background +> redraw load substantially β€” especially noticeable over VPN or on slower hardware. +> - **v1.13.4** β€” Room Server event classification fix and sender name resolution removed +> redundant fallback processing paths and reduced per-tick contact lookup overhead. +> +> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching, +> lower CPU usage during idle operation, and more stable map rendering. + +--- +## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) + +### Added +- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`. +- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. + - Board-selector (knoppen per geconfigureerd board). + - Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft). + - Scrollbare berichtenlijst over alle channels van het actieve board. + - Post-formulier: post op het eerste channel van het board. + - **Settings-sectie**: boards aanmaken (naam β†’ Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieΓ«n, regio's, retentie, whitelist, Save en Delete. + +### Changed +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd; versie `1.14.0`. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `πŸ“‹ BBS` drawer-item. +- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +### Storage +``` +~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) +~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +``` + +### Not changed +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. + +--- + +## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes + +### Fixed +- πŸ›  **Route page back-button navigated to main menu regardless of origin** β€” the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page. +- πŸ›  **Map marker popup flickered on every 500 ms update tick** β€” the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible. +- πŸ›  **Map marker popup appeared with a flicker/flash on first click (main map and route map)** β€” Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts. + +### Changed +- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`. +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” `applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`. +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.5`. + +### Impact +- Back navigation from the route page now always returns to the correct origin screen. +- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed. +- Popup opening is instant on both maps; no animation artefacts on low-power hardware. + +--- +## [1.13.4] - 2026-03-12 β€” Room Server message classification fix + +### Fixed +- πŸ›  **Incoming room messages from other participants could be misclassified as normal DMs** β€” `CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`. +- πŸ›  **Incoming room traffic could be attached to the wrong key** β€” room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`. +- πŸ›  **Room login UI could stay out of sync with the actual server-confirmed state** β€” `LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key. +- πŸ›  **Room Server panel showed hex codes instead of sender names** β€” when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known. + +### Changed +- πŸ”„ `meshcore_gui/ble/events.py` β€” Broadened room payload parsing and added payload-key debug logging for incoming room traffic. +- πŸ”„ `meshcore_gui/ble/worker.py` β€” `LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history. +- πŸ”„ `meshcore_gui/config.py` β€” Version kept at `1.13.4`. + +### Impact +- Keeps the existing Room Server panel logic intact. +- Fix is limited to room event classification and room login confirmation handling. +- No intended behavioural change for ordinary DMs or channel messages. + +--- +--- +## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating + +### Changed +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick +- πŸ”„ `meshcore_gui/gui/panels/map_panel.py` β€” Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.3` + +### Fixed +- πŸ›  **Hidden panels still refreshed every 500 ms** β€” Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active +- πŸ›  **Map bootstrap activity while panel is not visible** β€” Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic +- πŸ›  **Slow navigation over VPN** β€” Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel + +### Impact +- Faster panel switching because the selected panel gets one direct refresh immediately +- Lower background UI/update load on dashboard level, especially when the map panel is not active +- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage +- No intended functional regression for route maps or visible panel behaviour + +--- +## [1.13.2] - 2026-03-11 β€” Map Display Bugfix + +### Fixed +- πŸ›  **MAP panel blank when contacts list is empty at startup** β€” dashboard update loop + had two separate conditional map-update blocks that both silently stopped firing after + tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and + remained blank indefinitely. +- πŸ›  **Leaflet map initialized on hidden (zero-size) container** β€” `processPending` in + the browser runtime called `L.map()` on the host element while it was still + `display:none` (Vue v-show, panel not yet visible). This produced a broken 0Γ—0 map + that never recovered because `ensureMap` returned the cached broken state on all + subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`: + initialization is deferred until the host has real dimensions. +- πŸ›  **Route map container had no height** β€” `route_page.py` used the Tailwind class + `h-96` for the Leaflet host `
`. 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 `
` 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 1–2 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 `_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
`. 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 `
_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 "` +- 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 + +--- + + + +## [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/
.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 + + +- βœ… **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 (10–75s 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 +``` + +--- + + + +## [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 + +--- + + + +## [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` + +--- + + + +## [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/
_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)* + + + + + +- βœ… **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 + + + +- βœ… **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 + + + +### 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/
_messages.json` +- `~/.meshcore-gui/archive/
_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 diff --git a/meshcore_gui/meshcore_gui/config.py b/meshcore_gui/meshcore_gui/config.py new file mode 100644 index 0000000..d7c3dc5 --- /dev/null +++ b/meshcore_gui/meshcore_gui/config.py @@ -0,0 +1,395 @@ +""" +Application configuration for MeshCore GUI. + +Contains only global runtime settings. +Bot configuration lives in :mod:`meshcore_gui.services.bot`. +UI display constants live in :mod:`meshcore_gui.gui.constants`. + +The ``DEBUG`` flag defaults to False and can be activated at startup +with the ``--debug-on`` command-line option. + +Debug output is written to both stdout and a rotating log file at +``~/.meshcore-gui/logs/meshcore_gui.log``. +""" + +import json +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Any, Dict, List + + +# ============================================================================== +# VERSION +# ============================================================================== + + +VERSION: str = "1.14.0" + + +# ============================================================================== +# OPERATOR / LANDING PAGE +# ============================================================================== + +# Operator callsign shown on the landing page SVG and drawer footer. +# Change this to your own callsign (e.g. "PE1HVH", "PE1HVH/MIT"). +OPERATOR_CALLSIGN: str = "PE1HVH" + +# Path to the landing page SVG file. +# The placeholder ``{callsign}`` inside the SVG is replaced at runtime +# with ``OPERATOR_CALLSIGN``. +# +# Default: the bundled DOMCA splash (static/landing_default.svg). +# To use a custom SVG, point this to your own file, e.g.: +# LANDING_SVG_PATH = DATA_DIR / "landing.svg" +LANDING_SVG_PATH: Path = Path(__file__).parent / "static" / "landing_default.svg" + + +# ============================================================================== +# MAP DEFAULTS +# ============================================================================== + +# Default map centre used as the initial view *before* the device reports +# its own GPS position. Once the device advertises a valid adv_lat/adv_lon +# pair, every map will re-centre on the device's actual location. +# +# Change these values to match the location of your device / station. +# Current default: Zwolle, The Netherlands (52.5168, 6.0830). +DEFAULT_MAP_CENTER: tuple[float, float] = (52.5168, 6.0830) + +# Default zoom level for all Leaflet maps (higher = more zoomed in). +DEFAULT_MAP_ZOOM: int = 9 + + + +# ============================================================================== +# DIRECTORY STRUCTURE +# ============================================================================== + +# Base data directory β€” all persistent data lives under this root. +# Existing services (cache, pins, archive) each define their own +# sub-directory; this constant centralises the root for new consumers. +DATA_DIR: Path = Path.home() / ".meshcore-gui" + +# Log directory for debug and error log files. +LOG_DIR: Path = DATA_DIR / "logs" + +# Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total). +LOG_FILE: Path = LOG_DIR / "meshcore_gui.log" + + +def set_log_file_for_device(device_id: str) -> None: + """Set the log file name based on the device identifier. + + Transforms ``F0:9E:9E:75:A3:01`` into + ``~/.meshcore-gui/logs/F0_9E_9E_75_A3_01_meshcore_gui.log`` and + ``/dev/ttyUSB0`` into ``~/.meshcore-gui/logs/_dev_ttyUSB0_meshcore_gui.log``. + + Must be called **before** the first ``debug_print()`` call so the + lazy logger initialisation picks up the correct path. + """ + global LOG_FILE + safe_name = ( + device_id + .replace("literal:", "") + .replace(":", "_") + .replace("/", "_") + ) + LOG_FILE = LOG_DIR / f"{safe_name}_meshcore_gui.log" + +# Maximum size per log file in bytes (5 MB). +LOG_MAX_BYTES: int = 5 * 1024 * 1024 + +# Number of rotated backup files to keep. +LOG_BACKUP_COUNT: int = 3 + + +# ============================================================================== +# DEBUG +# ============================================================================== + +DEBUG: bool = False + +# Internal file logger β€” initialised lazily on first debug_print() call. +_file_logger: logging.Logger | None = None + + +def _init_file_logger() -> logging.Logger: + """Create and configure the rotating file logger (called once).""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("meshcore_gui.debug") + logger.setLevel(logging.DEBUG) + logger.propagate = False + + handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + handler.setFormatter( + logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + ) + logger.addHandler(handler) + return logger + + +def _caller_module() -> str: + """Return a short module label for the calling code. + + Walks two frames up (debug_print -> caller) and extracts the + module ``__name__``. The common ``meshcore_gui.`` prefix is + stripped for brevity, e.g. ``ble.worker`` instead of + ``meshcore_gui.ble.worker``. + """ + frame = sys._getframe(2) # 0=_caller_module, 1=debug_print, 2=actual caller + module = frame.f_globals.get("__name__", "") + if module.startswith("meshcore_gui."): + module = module[len("meshcore_gui."):] + return module + + +def _init_meshcore_logger() -> None: + """Route meshcore library debug output to our rotating log file. + + The meshcore library uses ``logging.getLogger("meshcore")`` throughout, + but never attaches a handler. Without this function all library-level + debug output (raw send/receive, event dispatching, command flow) + is silently dropped because Python's root logger only forwards + WARNING and above. + + Call once at startup (or lazily from ``debug_print``) so that + ``MESHCORE_LIB_DEBUG=True`` actually produces visible output. + """ + LOG_DIR.mkdir(parents=True, exist_ok=True) + + mc_logger = logging.getLogger("meshcore") + # Guard against duplicate handlers on repeated calls + if any(isinstance(h, RotatingFileHandler) for h in mc_logger.handlers): + return + + handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + handler.setFormatter( + logging.Formatter( + "%(asctime)s LIB [%(name)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + mc_logger.addHandler(handler) + + # Also add a stdout handler so library output appears in the console + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter( + logging.Formatter( + "%(asctime)s LIB [%(name)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + mc_logger.addHandler(stdout_handler) + + +def debug_print(msg: str) -> None: + """Print a debug message when ``DEBUG`` is enabled. + + Output goes to both stdout and the rotating log file. + The calling module name is automatically included so that + exception context is immediately clear, e.g.:: + + DEBUG [ble.worker]: send_appstart attempt 3 exception: TimeoutError + """ + global _file_logger + + if not DEBUG: + return + + module = _caller_module() + formatted = f"DEBUG [{module}]: {msg}" + + # stdout (existing behaviour, now with module tag) + print(formatted) + + # Rotating log file + if _file_logger is None: + _file_logger = _init_file_logger() + # Also wire up the meshcore library logger so MESHCORE_LIB_DEBUG + # output actually appears in the same log file + stdout. + _init_meshcore_logger() + _file_logger.debug(formatted) + + +def pp(obj: Any, indent: int = 2) -> str: + """Pretty-format a dict, list, or other object for debug output. + + Use inside f-strings:: + + debug_print(f"payload={pp(r.payload)}") + + Dicts/lists get indented JSON; everything else falls back to repr(). + """ + if isinstance(obj, (dict, list)): + try: + return json.dumps(obj, indent=indent, default=str, ensure_ascii=False) + except (TypeError, ValueError): + return repr(obj) + return repr(obj) + + +def debug_data(label: str, obj: Any) -> None: + """Print a labelled data structure with pretty indentation. + + Combines a header line with pretty-printed data below it:: + + debug_data("get_contacts result", r.payload) + + Output:: + + DEBUG [worker]: get_contacts result ↓ + { + "name": "PE1HVH", + "contacts": 629, + ... + } + """ + if not DEBUG: + return + formatted = pp(obj) + # Single-line values stay on the same line + if '\n' not in formatted: + debug_print(f"{label}: {formatted}") + else: + # Multi-line: indent each line for readability + indented = '\n'.join(f" {line}" for line in formatted.splitlines()) + debug_print(f"{label} ↓\n{indented}") + + +# ============================================================================== +# CHANNELS +# ============================================================================== + +# Maximum number of channel slots to probe on the device. +# MeshCore supports up to 8 channels (indices 0-7). +MAX_CHANNELS: int = 8 + +# Enable or disable caching of the channel list to disk. +# When False (default), channels are always fetched fresh from the +# device at startup, guaranteeing the GUI always reflects the actual +# device configuration. When True, channels are loaded from cache +# for instant GUI population and then refreshed from the device. +# Note: channel *keys* (for packet decryption) are always cached +# regardless of this setting. +CHANNEL_CACHE_ENABLED: bool = False + + +# ============================================================================== +# BOT DEVICE NAME +# ============================================================================== + +# Fixed device name applied when the BOT checkbox is enabled. +# The original device name is saved and restored when BOT is disabled. +BOT_DEVICE_NAME: str = "ZwolsBotje" + +# Default device name used as fallback when restoring from BOT mode +# and no original name was saved (e.g. after a restart). +DEVICE_NAME: str = "PE1HVH T1000e" + + +# ============================================================================== +# CACHE / REFRESH +# ============================================================================== + +# Default timeout (seconds) for meshcore command responses. +# Increase if you see frequent 'no_event_received' errors during startup. +DEFAULT_TIMEOUT: float = 10.0 + +# Enable debug logging inside the meshcore library itself. +# When True, raw send/receive data and event parsing are logged. +MESHCORE_LIB_DEBUG: bool = True + +# ============================================================================== +# TRANSPORT MODE (auto-detected from CLI argument) +# ============================================================================== + +# "serial" or "ble" β€” set at startup by main() based on the device argument. +TRANSPORT: str = "serial" + + +def is_ble_address(device_id: str) -> bool: + """Detect whether *device_id* looks like a BLE MAC address. + + Heuristic: + - Starts with ``literal:`` β†’ BLE + - Matches ``XX:XX:XX:XX:XX:XX`` (6 colon-separated hex pairs) β†’ BLE + - Everything else (``/dev/…``, ``COM…``) β†’ Serial + """ + if device_id.lower().startswith("literal:"): + return True + parts = device_id.split(":") + if len(parts) == 6 and all(len(p) == 2 for p in parts): + try: + for p in parts: + int(p, 16) + return True + except ValueError: + pass + return False +TRANSPORT: str = "serial" + +# Serial connection defaults. +SERIAL_BAUDRATE: int = 115200 +SERIAL_CX_DELAY: float = 0.1 + +# BLE connection defaults. +# BLE pairing PIN for the MeshCore device (T1000e default: 123456). +# Used by the built-in D-Bus agent to answer pairing requests +# automatically β€” eliminates the need for bt-agent.service. +BLE_PIN: str = "123456" + +# Maximum number of reconnect attempts after a disconnect. +RECONNECT_MAX_RETRIES: int = 5 + +# Base delay in seconds between reconnect attempts (multiplied by +# attempt number for linear backoff: 5s, 10s, 15s, 20s, 25s). +RECONNECT_BASE_DELAY: float = 5.0 + +# Interval in seconds between periodic contact refreshes from the device. +# Contacts are merged (new/changed contacts update the cache; contacts +# only present in cache are kept so offline nodes are preserved). +CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes + +# ============================================================================== +# EXTERNAL LINKS (drawer menu) +# ============================================================================== + +EXT_LINKS = [ + ('MeshCore', 'https://meshcore.co.uk'), + ('Handleiding', 'https://www.pe1hvh.nl/pdf/MeshCore_Complete_Handleiding.pdf'), + ('Netwerk kaart', 'https://meshcore.co.uk/map'), + ('LocalMesh NL', 'https://www.localmesh.nl/'), +] +# ============================================================================== +# ARCHIVE / RETENTION +# ============================================================================== + +# Retention period for archived messages (in days). +# Messages older than this are automatically removed during cleanup. +MESSAGE_RETENTION_DAYS: int = 30 + +# Retention period for RX log entries (in days). +# RX log entries older than this are automatically removed during cleanup. +RXLOG_RETENTION_DAYS: int = 7 + +# Retention period for contacts (in days). +# Contacts not seen for longer than this are removed from cache. +CONTACT_RETENTION_DAYS: int = 90 + + +# BBS channel configuration is managed at runtime via BbsConfigStore. +# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json +# and edited through the BBS Settings panel in the GUI. diff --git a/meshcore_gui/meshcore_gui/gui/dashboard.py b/meshcore_gui/meshcore_gui/gui/dashboard.py new file mode 100644 index 0000000..411e67c --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/dashboard.py @@ -0,0 +1,850 @@ +""" +Main dashboard page for MeshCore GUI. + +Thin orchestrator that owns the layout and the 500 ms update timer. +All visual content is delegated to individual panel classes in +:mod:`meshcore_gui.gui.panels`. +""" + +import logging +from urllib.parse import urlencode + +from nicegui import ui + +from meshcore_gui import config + +from meshcore_gui.core.protocols import SharedDataReader +from meshcore_gui.gui.panels import ( + ActionsPanel, + BbsPanel, + ContactsPanel, + DevicePanel, + MapPanel, + MessagesPanel, + RoomServerPanel, + RxLogPanel, +) +from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_config_store import BbsConfigStore +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService +from meshcore_gui.services.pin_store import PinStore +from meshcore_gui.services.room_password_store import RoomPasswordStore + + +# Suppress the harmless "Client has been deleted" warning that NiceGUI +# emits when a browser tab is refreshed while a ui.timer is active. +class _DeletedClientFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return 'Client has been deleted' not in record.getMessage() + +logging.getLogger('nicegui').addFilter(_DeletedClientFilter()) + + +# ── DOMCA Theme ────────────────────────────────────────────────────── +# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar. +# Dark/light variable sets switch via Quasar's body--dark / body--light classes. + +_DOMCA_HEAD = ''' + + + + + + + + + +''' + +# ── 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 ( + '' + 'Landing SVG not found: {path.name}' + '' + ) + + +# ── 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() diff --git a/meshcore_gui/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/meshcore_gui/gui/panels/__init__.py new file mode 100644 index 0000000..f9245f4 --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/panels/__init__.py @@ -0,0 +1,18 @@ +""" +Individual dashboard panels β€” each panel is a single-responsibility class. + +Re-exports all panels for convenient importing:: + + from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ... +""" + +from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401 +from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401 +from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401 +from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401 +from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401 +from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 +from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 +from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 +from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 +from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py new file mode 100644 index 0000000..0282a59 --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -0,0 +1,535 @@ +"""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 + + +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" + + +class BbsPanel: + """BBS panel: board selector, filters, message list, post form and settings. + + The settings section lets users create, configure and delete boards. + Each board can span one or more device channels (from SharedData). + Configuration is persisted via BbsConfigStore. + + 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_region: Optional[str] = None + self._active_category: Optional[str] = None + + # UI refs -- message view + self._board_btn_row = None + self._region_row = None + self._region_select = None + self._category_select = None + self._text_input = None + self._post_region_row = None + self._post_region_select = None + self._post_category_select = None + self._msg_list_container = None + + # UI refs -- settings + self._boards_settings_container = None + self._new_board_name_input = None + self._new_board_channel_checks: Dict[int, object] = {} + + # Cached device channels (updated by update()) + self._device_channels: List[Dict] = [] + self._last_ch_fingerprint: tuple = () + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete BBS panel layout.""" + # ---- Message view card -------------------------------------- + with ui.card().classes('w-full'): + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + + self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + + ui.separator() + + with ui.row().classes('w-full items-center gap-4 flex-wrap'): + ui.label('Filter:').classes('text-sm text-gray-600') + + self._region_row = ui.row().classes('items-center gap-2') + with self._region_row: + ui.label('Region:').classes('text-xs text-gray-600') + self._region_select = ui.select( + options=[], value=None, + on_change=lambda e: self._on_region_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + with ui.row().classes('items-center gap-2'): + ui.label('Category:').classes('text-xs text-gray-600') + self._category_select = ui.select( + options=[], value=None, + on_change=lambda e: self._on_category_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + ui.button( + 'Refresh', on_click=self._refresh_messages, + ).props('flat no-caps').classes('text-xs') + + ui.separator() + + self._msg_list_container = ui.column().classes( + 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + ) + + ui.separator() + + 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') + + ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') + + # ---- Settings card ------------------------------------------ + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.label( + 'Create boards and assign device channels. ' + 'One board can cover multiple channels.' + ).classes('text-xs text-gray-500') + ui.separator() + + # New board form + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('New board:').classes('text-sm text-gray-600') + self._new_board_name_input = ui.input( + placeholder='Board name...', + ).classes('text-xs').style('min-width: 160px') + ui.button( + 'Create', on_click=self._on_create_board, + ).props('no-caps').classes('text-xs') + + ui.separator() + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + ui.label('No boards configured yet.').classes( + 'text-xs text-gray-400 italic' + ) + + # Initial render + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # Board selector (message view) + # ------------------------------------------------------------------ + + 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() + boards = self._config_store.get_boards() + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + if not boards: + ui.label('No boards configured.').classes( + 'text-xs text-gray-400 italic' + ) + return + for board in boards: + ui.button( + board.name, + on_click=lambda b=board: self._select_board(b), + ).props('flat no-caps').classes('text-xs') + + # Auto-select first board if none active or active was deleted + 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]) + + def _select_board(self, board: BbsBoard) -> None: + """Activate a board and rebuild filter selects. + + Args: + board: Board to activate. + """ + self._active_board = board + self._active_region = None + self._active_category = None + + has_regions = bool(board.regions) + if self._region_row: + self._region_row.set_visibility(has_regions) + if self._post_region_row: + self._post_region_row.set_visibility(has_regions) + + region_opts = ['(all)'] + board.regions + if self._region_select: + self._region_select.options = region_opts + self._region_select.value = '(all)' + 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 + + cat_opts = ['(all)'] + board.categories + if self._category_select: + self._category_select.options = cat_opts + self._category_select.value = '(all)' + 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._refresh_messages() + + # ------------------------------------------------------------------ + # Filters + # ------------------------------------------------------------------ + + def _on_region_filter(self, value: Optional[str]) -> None: + self._active_region = None if (not value or value == '(all)') else value + self._refresh_messages() + + def _on_category_filter(self, value: Optional[str]) -> None: + self._active_category = None if (not value or value == '(all)') else value + self._refresh_messages() + + # ------------------------------------------------------------------ + # 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=self._active_region, + 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 gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500') + ui.label(msg.text).classes('text-sm') + + # ------------------------------------------------------------------ + # 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 '' + + # Post on first assigned channel (primary channel for outgoing) + 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') + + # ------------------------------------------------------------------ + # Settings -- board list + # ------------------------------------------------------------------ + + def _rebuild_boards_settings(self) -> None: + """Rebuild the settings section for all configured boards.""" + if not self._boards_settings_container: + return + self._boards_settings_container.clear() + boards = self._config_store.get_boards() + with self._boards_settings_container: + if not boards: + ui.label('No boards configured yet.').classes( + 'text-xs text-gray-400 italic' + ) + return + for board in boards: + self._render_board_settings_row(board) + + def _render_board_settings_row(self, board: BbsBoard) -> None: + """Render one settings expansion for a single board. + + Args: + board: Board to render. + """ + with ui.expansion( + board.name, value=False, + ).classes('w-full').props('dense'): + with ui.column().classes('w-full gap-2 p-2'): + + # Name + name_input = ui.input( + label='Board name', value=board.name, + ).classes('w-full text-xs') + + # Channel assignment + ui.label('Channels (select which device channels belong to this board):').classes( + 'text-xs text-gray-600' + ) + ch_checks: Dict[int, object] = {} + with ui.row().classes('flex-wrap gap-2'): + if not self._device_channels: + ui.label('No device channels known yet.').classes( + 'text-xs text-gray-400 italic' + ) + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {ch_name}', + value=idx in board.channels, + ).classes('text-xs') + ch_checks[idx] = cb + + # Categories + cats_input = ui.input( + label='Categories (comma-separated)', + value=', '.join(board.categories), + ).classes('w-full text-xs') + + # Regions + regions_input = ui.input( + label='Regions (comma-separated, leave empty for none)', + value=', '.join(board.regions), + ).classes('w-full text-xs') + + # Retention + retention_input = ui.input( + label='Retention (hours)', + value=str(board.retention_hours), + ).classes('text-xs').style('max-width: 160px') + + # Whitelist + wl_input = ui.input( + label='Allowed keys (comma-separated hex, empty = all)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + with ui.row().classes('gap-2'): + def _save( + bid=board.id, + ni=name_input, + cc=ch_checks, + ci=cats_input, + ri=regions_input, + ret=retention_input, + wli=wl_input, + ) -> None: + new_name = (ni.value or '').strip() or bid + selected_channels = [ + idx for idx, cb in cc.items() if cb.value + ] + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + regions = [ + r.strip() + for r in (ri.value or '').split(',') if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wli.value or '').split(',') if k.strip() + ] + updated = BbsBoard( + id=bid, + name=new_name, + channels=selected_channels, + categories=categories, + regions=regions, + retention_hours=retention_hours, + allowed_keys=allowed_keys, + ) + self._config_store.set_board(updated) + debug_print( + f'BBS settings: saved board {bid} ' + f'channels={selected_channels}' + ) + ui.notify(f'Board "{new_name}" saved.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + def _delete(bid=board.id, bname=board.name) -> None: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: deleted board {bid}') + ui.notify(f'Board "{bname}" deleted.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + ui.button( + 'Delete', on_click=_delete, + ).props('no-caps flat color=negative').classes('text-xs') + + # ------------------------------------------------------------------ + # Settings -- create new board + # ------------------------------------------------------------------ + + def _on_create_board(self) -> None: + """Handle the Create button for a new board.""" + name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' + if not name: + ui.notify('Enter a board name first.', type='warning') + return + + board_id = _slug(name) + # Make id unique if needed + base_id = board_id + counter = 2 + while self._config_store.board_id_exists(board_id): + board_id = f'{base_id}_{counter}' + counter += 1 + + board = BbsBoard( + id=board_id, + name=name, + channels=[], + categories=list(DEFAULT_CATEGORIES), + regions=[], + retention_hours=DEFAULT_RETENTION_HOURS, + allowed_keys=[], + ) + self._config_store.set_board(board) + debug_print(f'BBS settings: created board {board_id}') + if self._new_board_name_input: + self._new_board_name_input.value = '' + ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # External update hook + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Called by the dashboard timer with the SharedData snapshot. + + Rebuilds the settings channel checkboxes when the device channel + list changes. + + 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_boards_settings() diff --git a/meshcore_gui/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/meshcore_gui/services/bbs_config_store.py new file mode 100644 index 0000000..c05727b --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bbs_config_store.py @@ -0,0 +1,302 @@ +""" +BBS board configuration store for MeshCore GUI. + +Persists BBS board configuration to +``~/.meshcore-gui/bbs/bbs_config.json``. + +A **board** groups one or more MeshCore channel indices into a single +bulletin board. Messages posted on any of the board's channels are +visible in the board view. This supports two usage patterns: + +- One board per channel (classic per-channel BBS) +- One board spanning multiple channels (shared bulletin board) + +Config version history +~~~~~~~~~~~~~~~~~~~~~~ +v1 β€” per-channel config (list of channels with enabled flag). +v2 β€” board-based config (list of boards, each with a channels list). + Automatic migration from v1 on first load. + +Thread safety +~~~~~~~~~~~~~ +All public methods acquire an internal ``threading.Lock``. +""" + +import json +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" +BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" + +CONFIG_VERSION: int = 2 + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] +DEFAULT_REGIONS: List[str] = [] +DEFAULT_RETENTION_HOURS: int = 48 + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsBoard: + """A BBS board grouping one or more MeshCore channels. + + Attributes: + id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``). + name: Human-readable board name. + channels: List of MeshCore channel indices assigned to this board. + categories: Valid category tags for this board. + regions: Optional region tags; empty = no region filtering. + retention_hours: Message retention period in hours. + allowed_keys: Sender public key whitelist (empty = all allowed). + """ + + id: str + name: str + channels: List[int] = field(default_factory=list) + categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES)) + regions: List[str] = field(default_factory=list) + retention_hours: int = DEFAULT_RETENTION_HOURS + allowed_keys: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict: + """Serialise to a JSON-compatible dict.""" + return { + "id": self.id, + "name": self.name, + "channels": list(self.channels), + "categories": list(self.categories), + "regions": list(self.regions), + "retention_hours": self.retention_hours, + "allowed_keys": list(self.allowed_keys), + } + + @staticmethod + def from_dict(d: Dict) -> "BbsBoard": + """Deserialise from a config dict.""" + return BbsBoard( + id=d.get("id", ""), + name=d.get("name", ""), + channels=list(d.get("channels", [])), + categories=list(d.get("categories", DEFAULT_CATEGORIES)), + regions=list(d.get("regions", [])), + retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(d.get("allowed_keys", [])), + ) + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +class BbsConfigStore: + """Persistent store for BBS board configuration. + + Args: + config_path: Path to the JSON config file. + Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``. + """ + + def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: + self._path = config_path + self._lock = threading.Lock() + self._boards: List[BbsBoard] = [] + self._load() + + # ------------------------------------------------------------------ + # Load / save + # ------------------------------------------------------------------ + + def _load(self) -> None: + """Load config from disk; migrate v1 β†’ v2 if needed.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + + if not self._path.exists(): + self._save_unlocked() + debug_print("BBS config: created new config file (v2)") + return + + try: + raw = self._path.read_text(encoding="utf-8") + data = json.loads(raw) + version = data.get("version", 1) + + if version == CONFIG_VERSION: + self._boards = [ + BbsBoard.from_dict(b) for b in data.get("boards", []) + ] + debug_print(f"BBS config: loaded {len(self._boards)} boards") + + elif version == 1: + # Migrate: each v1 channel β†’ one board + self._boards = self._migrate_v1(data.get("channels", [])) + self._save_unlocked() + debug_print( + f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" + ) + else: + debug_print( + f"BBS config: unknown version {version}, using empty config" + ) + + except (json.JSONDecodeError, OSError) as exc: + debug_print(f"BBS config: load error ({exc}), using empty config") + + @staticmethod + def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]: + """Convert v1 per-channel entries to v2 boards. + + Only enabled channels are migrated. + + Args: + v1_channels: List of v1 channel config dicts. + + Returns: + List of ``BbsBoard`` instances. + """ + boards = [] + for ch in v1_channels: + if not ch.get("enabled", False): + continue + idx = ch.get("channel", 0) + board_id = f"ch{idx}" + boards.append(BbsBoard( + id=board_id, + name=ch.get("name", f"Channel {idx}"), + channels=[idx], + categories=list(ch.get("categories", DEFAULT_CATEGORIES)), + regions=list(ch.get("regions", [])), + retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(ch.get("allowed_keys", [])), + )) + return boards + + def _save_unlocked(self) -> None: + """Write config to disk. MUST be called with self._lock held.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + data = { + "version": CONFIG_VERSION, + "boards": [b.to_dict() for b in self._boards], + } + tmp = self._path.with_suffix(".tmp") + tmp.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + tmp.replace(self._path) + + def save(self) -> None: + """Flush current configuration to disk.""" + with self._lock: + self._save_unlocked() + + # ------------------------------------------------------------------ + # Board queries + # ------------------------------------------------------------------ + + def get_boards(self) -> List[BbsBoard]: + """Return a copy of all configured boards. + + Returns: + List of ``BbsBoard`` instances. + """ + with self._lock: + return list(self._boards) + + def get_board(self, board_id: str) -> Optional[BbsBoard]: + """Return a board by its id, or ``None``. + + Args: + board_id: Board identifier string. + + Returns: + ``BbsBoard`` instance or ``None``. + """ + with self._lock: + for b in self._boards: + if b.id == board_id: + return BbsBoard.from_dict(b.to_dict()) + return None + + def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: + """Return the first board that includes *channel_idx*, or ``None``. + + Used by ``BbsCommandHandler`` to route incoming mesh commands. + + Args: + channel_idx: MeshCore channel index. + + Returns: + ``BbsBoard`` instance or ``None``. + """ + with self._lock: + for b in self._boards: + if channel_idx in b.channels: + return BbsBoard.from_dict(b.to_dict()) + return None + + # ------------------------------------------------------------------ + # Board management + # ------------------------------------------------------------------ + + def set_board(self, board: BbsBoard) -> None: + """Insert or replace a board (matched by ``board.id``). + + Args: + board: ``BbsBoard`` to persist. + """ + with self._lock: + for i, b in enumerate(self._boards): + if b.id == board.id: + self._boards[i] = BbsBoard.from_dict(board.to_dict()) + self._save_unlocked() + debug_print(f"BBS config: updated board '{board.id}'") + return + self._boards.append(BbsBoard.from_dict(board.to_dict())) + self._save_unlocked() + debug_print(f"BBS config: added board '{board.id}'") + + def delete_board(self, board_id: str) -> bool: + """Remove a board by id. + + Args: + board_id: Board identifier to remove. + + Returns: + ``True`` if removed, ``False`` if not found. + """ + with self._lock: + before = len(self._boards) + self._boards = [b for b in self._boards if b.id != board_id] + if len(self._boards) < before: + self._save_unlocked() + debug_print(f"BBS config: deleted board '{board_id}'") + return True + return False + + def board_id_exists(self, board_id: str) -> bool: + """Check whether a board id is already in use. + + Args: + board_id: Board identifier to check. + + Returns: + ``True`` if a board with this id exists. + """ + with self._lock: + return any(b.id == board_id for b in self._boards) diff --git a/meshcore_gui/meshcore_gui/services/bbs_service.py b/meshcore_gui/meshcore_gui/services/bbs_service.py new file mode 100644 index 0000000..52b7968 --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bbs_service.py @@ -0,0 +1,468 @@ +""" +Offline Bulletin Board System (BBS) service for MeshCore GUI. + +Stores BBS messages in a local SQLite database. Messages are keyed by +their originating MeshCore channel index. A **board** (see +:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or +more channel indices to a single bulletin board, so queries are always +issued as ``WHERE channel IN (...)``. + +Architecture +~~~~~~~~~~~~ +- ``BbsService`` -- persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. + +Thread safety +~~~~~~~~~~~~~ +SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by +multiple application instances (e.g. 800 MHz + 433 MHz on one Pi). + +Storage +~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) +""" + +import sqlite3 +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" +BBS_DB_PATH = BBS_DIR / "bbs_messages.db" + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsMessage: + """A single BBS message. + + Attributes: + id: Database row id (``None`` before insert). + channel: MeshCore channel index the message arrived on. + region: Region tag (empty string when board has no regions). + category: Category tag. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Message body. + timestamp: UTC ISO-8601 timestamp string. + """ + + channel: int + region: str + category: str + sender: str + sender_key: str + text: str + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + id: Optional[int] = None + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + +class BbsService: + """SQLite-backed BBS storage service. + + Args: + db_path: Path to the SQLite database file. + """ + + def __init__(self, db_path: Path = BBS_DB_PATH) -> None: + self._db_path = db_path + self._lock = threading.Lock() + self._init_db() + + def _init_db(self) -> None: + """Create the database directory and schema if not present.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=3000") + conn.execute(""" + CREATE TABLE IF NOT EXISTS bbs_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel INTEGER NOT NULL, + region TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + sender TEXT NOT NULL, + sender_key TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + timestamp TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)" + ) + conn.commit() + debug_print(f"BBS: database ready at {self._db_path}") + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(str(self._db_path), check_same_thread=False) + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def post_message(self, msg: BbsMessage) -> int: + """Insert a BBS message and return its row id. + + Args: + msg: ``BbsMessage`` dataclass to persist. + + Returns: + Assigned ``rowid`` (also set on ``msg.id``). + """ + with self._lock: + with self._connect() as conn: + cur = conn.execute( + """INSERT INTO bbs_messages + (channel, region, category, sender, sender_key, text, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (msg.channel, msg.region, msg.category, + msg.sender, msg.sender_key, msg.text, msg.timestamp), + ) + conn.commit() + msg.id = cur.lastrowid + debug_print( + f"BBS: posted id={msg.id} ch={msg.channel} " + f"cat={msg.category} sender={msg.sender}" + ) + return msg.id + + # ------------------------------------------------------------------ + # Read (channels is a list to support multi-channel boards) + # ------------------------------------------------------------------ + + def get_messages( + self, + channels: List[int], + region: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, + ) -> List[BbsMessage]: + """Return the *limit* most recent messages for a set of channels. + + Args: + channels: MeshCore channel indices to query (board's channel list). + region: Optional region filter. + category: Optional category filter. + limit: Maximum number of messages to return. + + Returns: + List of ``BbsMessage`` objects, newest first. + """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) + query = ( + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" + ) + params: list = list(channels) + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + return [self._row_to_msg(r) for r in rows] + + def get_all_messages( + self, + channels: List[int], + region: Optional[str] = None, + category: Optional[str] = None, + ) -> List[BbsMessage]: + """Return all messages for a set of channels (oldest first). + + Args: + channels: MeshCore channel indices to query. + region: Optional region filter. + category: Optional category filter. + + Returns: + List of ``BbsMessage`` objects, oldest first. + """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) + query = ( + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" + ) + params: list = list(channels) + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + query += " ORDER BY timestamp ASC" + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + return [self._row_to_msg(r) for r in rows] + + @staticmethod + def _row_to_msg(row: tuple) -> BbsMessage: + return BbsMessage( + id=row[0], channel=row[1], region=row[2], category=row[3], + sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7], + ) + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def purge_expired(self, channels: List[int], retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a set of channels. + + Args: + channels: MeshCore channel indices to purge. + retention_hours: Messages older than this are deleted. + + Returns: + Number of rows deleted. + """ + if not channels: + return 0 + cutoff = ( + datetime.now(timezone.utc) - timedelta(hours=retention_hours) + ).isoformat() + placeholders = ",".join("?" * len(channels)) + with self._lock: + with self._connect() as conn: + cur = conn.execute( + f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?", + list(channels) + [cutoff], + ) + conn.commit() + deleted = cur.rowcount + if deleted: + debug_print( + f"BBS: purged {deleted} expired messages from ch={channels}" + ) + return deleted + + def purge_all_expired(self, boards) -> None: + """Run retention cleanup for all boards. + + Args: + boards: Iterable of ``BbsBoard`` instances. + """ + for board in boards: + self.purge_expired(board.channels, board.retention_hours) + + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- + +class BbsCommandHandler: + """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + + Looks up the board for the incoming channel via ``BbsConfigStore`` + so that a single board spanning multiple channels handles commands + from all of them. + + Args: + service: Shared ``BbsService`` instance. + config_store: ``BbsConfigStore`` instance for live board config. + """ + + READ_LIMIT: int = 5 + + def __init__(self, service: BbsService, config_store) -> None: + self._service = service + self._config_store = config_store + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def handle( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Parse an incoming message and return a reply string (or ``None``). + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string, or ``None`` if no reply should be sent. + """ + text = (text or "").strip() + if not text.lower().startswith("!bbs"): + return None + + board = self._config_store.get_board_for_channel(channel_idx) + if board is None: + return None + + # Whitelist check + if board.allowed_keys and sender_key not in board.allowed_keys: + debug_print( + f"BBS: silently dropping msg from {sender} " + f"(key not in whitelist for board '{board.id}')" + ) + return None + + parts = text.split(None, 1) + args = parts[1].strip() if len(parts) > 1 else "" + return self._dispatch(board, channel_idx, sender, sender_key, args) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch(self, board, channel_idx, sender, sender_key, args): + sub = args.split(None, 1)[0].lower() if args else "" + rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" + if sub == "post": + return self._handle_post(board, channel_idx, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown command '{sub}'. {self._handle_help(board)}" + + # ------------------------------------------------------------------ + # post + # ------------------------------------------------------------------ + + def _handle_post(self, board, channel_idx, sender, sender_key, args): + regions = board.regions + categories = board.categories + tokens = args.split(None, 2) if args else [] + + if regions: + if len(tokens) < 3: + return ( + f"Usage: !bbs post [region] [category] [text] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + region, category, text = tokens[0], tokens[1], tokens[2] + valid_r = [r.upper() for r in regions] + if region.upper() not in valid_r: + return f"Invalid region '{region}'. Valid: {', '.join(regions)}" + region = regions[valid_r.index(region.upper())] + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" + category = categories[valid_c.index(category.upper())] + else: + if len(tokens) < 2: + return ( + f"Usage: !bbs post [category] [text] | " + f"Categories: {', '.join(categories)}" + ) + region = "" + category, text = tokens[0], tokens[1] + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" + category = categories[valid_c.index(category.upper())] + + msg = BbsMessage( + channel=channel_idx, + region=region, category=category, + sender=sender, sender_key=sender_key, text=text, + ) + self._service.post_message(msg) + region_label = f" [{region}]" if region else "" + return f"Posted [{category}]{region_label}: {text[:60]}" + + # ------------------------------------------------------------------ + # read + # ------------------------------------------------------------------ + + def _handle_read(self, board, args): + regions = board.regions + categories = board.categories + tokens = args.split() if args else [] + region = None + category = None + + if regions: + valid_r = [r.upper() for r in regions] + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_r: + region = regions[valid_r.index(tokens[0].upper())] + if len(tokens) >= 2: + if tokens[1].upper() in valid_c: + category = categories[valid_c.index(tokens[1].upper())] + else: + return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" + else: + return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" + else: + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_c: + category = categories[valid_c.index(tokens[0].upper())] + else: + return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" + + messages = self._service.get_messages( + board.channels, region=region, category=category, limit=self.READ_LIMIT, + ) + if not messages: + return "BBS: no messages found." + lines = [] + for m in messages: + ts = m.timestamp[:16].replace("T", " ") + region_label = f"[{m.region}] " if m.region else "" + lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # help + # ------------------------------------------------------------------ + + def _handle_help(self, board) -> str: + cats = ", ".join(board.categories) + if board.regions: + regs = ", ".join(board.regions) + return ( + f"BBS [{board.name}] | " + f"!bbs post [region] [cat] [text] | " + f"!bbs read [region] [cat] | " + f"Regions: {regs} | Categories: {cats}" + ) + return ( + f"BBS [{board.name}] | " + f"!bbs post [cat] [text] | " + f"!bbs read [cat] | " + f"Categories: {cats}" + ) diff --git a/meshcore_gui/meshcore_gui/services/bot.py b/meshcore_gui/meshcore_gui/services/bot.py new file mode 100644 index 0000000..9279a72 --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bot.py @@ -0,0 +1,221 @@ +""" +Keyword-triggered auto-reply bot for MeshCore GUI. + +Extracted from SerialWorker to satisfy the Single Responsibility Principle. +The bot listens on a configured channel and replies to messages that +contain recognised keywords. + +Open/Closed +~~~~~~~~~~~ +New keywords are added via ``BotConfig.keywords`` (data) without +modifying the ``MeshBot`` class (code). Custom matching strategies +can be implemented by subclassing and overriding ``_match_keyword``. + +BBS integration +~~~~~~~~~~~~~~~ +``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a +:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one +is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is +``None`` (default), BBS routing is simply skipped. +""" + +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler + +from meshcore_gui.config import debug_print + + +# ============================================================================== +# Bot defaults (previously in config.py) +# ============================================================================== + +# Channel indices the bot listens on (must match device channels). +BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot + +# Display name prepended to every bot reply. +BOT_NAME: str = "ZwolsBotje" + +# Minimum seconds between two bot replies (prevents reply-storms). +BOT_COOLDOWN_SECONDS: float = 5.0 + +# Keyword β†’ reply template mapping. +# Available variables: {bot}, {sender}, {snr}, {path} +# The bot checks whether the incoming message text *contains* the keyword +# (case-insensitive). First match wins. +BOT_KEYWORDS: Dict[str, str] = { + 'test': '@[{sender}], rcvd | SNR {snr} | {path}', + 'ping': 'Pong!', + 'help': 'test, ping, help', +} + + +@dataclass +class BotConfig: + """Configuration for :class:`MeshBot`. + + Attributes: + channels: Channel indices to listen on. + name: Display name prepended to replies. + cooldown_seconds: Minimum seconds between replies. + keywords: Keyword β†’ reply template mapping. + """ + + channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS)) + name: str = BOT_NAME + cooldown_seconds: float = BOT_COOLDOWN_SECONDS + keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS)) + + +class MeshBot: + """Keyword-triggered auto-reply bot. + + The bot checks incoming messages against a set of keyword β†’ template + pairs. When a keyword is found (case-insensitive substring match, + first match wins), the template is expanded and queued as a channel + message via *command_sink*. + + Args: + config: Bot configuration. + command_sink: Callable that enqueues a command dict for the + worker (typically ``shared.put_command``). + enabled_check: Callable that returns ``True`` when the bot is + enabled (typically ``shared.is_bot_enabled``). + """ + + def __init__( + self, + config: BotConfig, + command_sink: Callable[[Dict], None], + enabled_check: Callable[[], bool], + bbs_handler: Optional["BbsCommandHandler"] = None, + ) -> None: + self._config = config + self._sink = command_sink + self._enabled = enabled_check + self._last_reply: float = 0.0 + self._bbs_handler = bbs_handler + + def check_and_reply( + self, + sender: str, + text: str, + channel_idx: Optional[int], + snr: Optional[float], + path_len: int, + path_hashes: Optional[List[str]] = None, + ) -> None: + """Evaluate an incoming message and queue a reply if appropriate. + + Guards (in order): + 1. Bot is enabled (checkbox in GUI). + 2. Message is on the configured channel. + 3. Sender is not the bot itself. + 4. Sender name does not end with ``'Bot'`` (prevent loops). + 5. Cooldown period has elapsed. + 6. Message text contains a recognised keyword. + """ + # Guard 1: enabled? + if not self._enabled(): + return + + # Guard 2: correct channel? + if channel_idx not in self._config.channels: + return + + # Guard 3: own messages? + if sender == "Me" or (text and text.startswith(self._config.name)): + return + + # Guard 4: other bots? + if sender and sender.rstrip().lower().endswith("bot"): + debug_print(f"BOT: skipping message from other bot '{sender}'") + return + + # Guard 5: cooldown? + now = time.time() + if now - self._last_reply < self._config.cooldown_seconds: + debug_print("BOT: cooldown active, skipping") + return + + # BBS routing: delegate !bbs commands to BbsCommandHandler + if self._bbs_handler is not None: + text_stripped = (text or "").strip() + if text_stripped.lower().startswith("!bbs"): + bbs_reply = self._bbs_handler.handle( + channel_idx=channel_idx, + sender=sender, + sender_key="", # sender_key not available at this call-site + text=text_stripped, + ) + if bbs_reply is not None: + self._last_reply = now + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": bbs_reply, + "_bot": True, + }) + debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}") + return # Do not fall through to keyword matching + + # Guard 6: keyword match + template = self._match_keyword(text) + if template is None: + return + + # Build reply + path_str = self._format_path(path_len, path_hashes) + snr_str = f"{snr:.1f}" if snr is not None else "?" + reply = template.format( + bot=self._config.name, + sender=sender or "?", + snr=snr_str, + path=path_str, + ) + + self._last_reply = now + + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": reply, + "_bot": True, + }) + debug_print(f"BOT: queued reply to '{sender}': {reply}") + + # ------------------------------------------------------------------ + # Extension point (OCP) + # ------------------------------------------------------------------ + + def _match_keyword(self, text: str) -> Optional[str]: + """Return the reply template for the first matching keyword. + + Override this method for custom matching strategies (regex, + exact match, priority ordering, etc.). + + Returns: + Template string, or ``None`` if no keyword matched. + """ + text_lower = (text or "").lower() + for keyword, template in self._config.keywords.items(): + if keyword in text_lower: + return template + return None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_path( + path_len: int, + path_hashes: Optional[List[str]], + ) -> str: + """Format path info as ``path(N); ``path(0)``.""" + if not path_len: + return "path(0)" + return f"path({path_len})" From 21e266ceb5f72617370092e76cf5c068619508a6 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:43:50 +0100 Subject: [PATCH 32/39] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- CHANGELOG.md | 34 +- meshcore_gui/gui/panels/bbs_panel.py | 496 ++++++++++++---------- meshcore_gui/services/bbs_config_store.py | 334 +++++++++------ meshcore_gui/services/bbs_service.py | 290 ++++++------- 4 files changed, 610 insertions(+), 544 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d542cb..6df97ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,29 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) ### Added -- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`. -- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. - - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. - - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`. - - `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding. +- πŸ†• **`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. - - Channel-selector (alleen enabled BBS channels). - - Regio-filter (alleen zichtbaar als channel regio's heeft). - - Categorie-filter. - - Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label. - - Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop. - - Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen. - - **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt. + - 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` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vΓ³Γ³r keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `πŸ“‹ BBS` toegevoegd aan de drawer. +- πŸ”„ **`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. -- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels. +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. --- diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index c642ad8..0282a59 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -1,11 +1,13 @@ -"""BBS panel -- offline Bulletin Board System viewer, post form and settings.""" +"""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, @@ -13,16 +15,24 @@ from meshcore_gui.services.bbs_config_store import ( from meshcore_gui.services.bbs_service import BbsMessage, BbsService +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" + + class BbsPanel: - """BBS panel: channel selector, filters, message list, post form and settings. + """BBS panel: board selector, filters, message list, post form and settings. - The settings section lists all active device channels (from SharedData) - and lets the user enable/disable BBS per channel and configure - categories, regions and retention. Configuration is persisted via - BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json. - - All data access goes through BbsService and BbsConfigStore. - No direct SQLite access in this class (SOLID: SRP / DIP). + The settings section lets users create, configure and delete boards. + Each board can span one or more device channels (from SharedData). + Configuration is persisted via BbsConfigStore. Args: put_command: Callable to enqueue a command dict for the worker. @@ -41,12 +51,12 @@ class BbsPanel: self._config_store = config_store # Active view state - self._active_channel_idx: Optional[int] = None + self._active_board: Optional[BbsBoard] = None self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI element references -- message view - self._msg_list_container = None + # UI refs -- message view + self._board_btn_row = None self._region_row = None self._region_select = None self._category_select = None @@ -54,11 +64,16 @@ class BbsPanel: self._post_region_row = None self._post_region_select = None self._post_category_select = None - self._channel_btn_row = None + self._msg_list_container = None - # UI element references -- settings - self._settings_container = None - self._last_device_channels: List[Dict] = [] + # UI refs -- settings + self._boards_settings_container = None + self._new_board_name_input = None + self._new_board_channel_checks: Dict[int, object] = {} + + # Cached device channels (updated by update()) + self._device_channels: List[Dict] = [] + self._last_ch_fingerprint: tuple = () # ------------------------------------------------------------------ # Render @@ -66,18 +81,16 @@ class BbsPanel: def render(self) -> None: """Build the complete BBS panel layout.""" + # ---- Message view card -------------------------------------- with ui.card().classes('w-full'): ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - # ---- Channel selector ----------------------------------- - self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - # Populated by _rebuild_channel_buttons() + self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') ui.separator() - # ---- Filter row ---------------------------------------- with ui.row().classes('w-full items-center gap-4 flex-wrap'): ui.label('Filter:').classes('text-sm text-gray-600') @@ -85,33 +98,29 @@ class BbsPanel: with self._region_row: ui.label('Region:').classes('text-xs text-gray-600') self._region_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_region_filter(e.value), ).classes('text-xs').style('min-width: 120px') with ui.row().classes('items-center gap-2'): ui.label('Category:').classes('text-xs text-gray-600') self._category_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_category_filter(e.value), ).classes('text-xs').style('min-width: 120px') ui.button( - 'Refresh', on_click=self._refresh_messages + 'Refresh', on_click=self._refresh_messages, ).props('flat no-caps').classes('text-xs') ui.separator() - # ---- Message list -------------------------------------- self._msg_list_container = ui.column().classes( 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' ) ui.separator() - # ---- Post form ----------------------------------------- with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') @@ -131,91 +140,100 @@ class BbsPanel: ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ----------------------------------------- + # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') ui.label( - 'Enable BBS on a channel to allow !bbs commands and store messages.' + 'Create boards and assign device channels. ' + 'One board can cover multiple channels.' ).classes('text-xs text-gray-500') ui.separator() - self._settings_container = ui.column().classes('w-full gap-2') - with self._settings_container: - ui.label('Waiting for device channels...').classes( + + # New board form + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('New board:').classes('text-sm text-gray-600') + self._new_board_name_input = ui.input( + placeholder='Board name...', + ).classes('text-xs').style('min-width: 160px') + ui.button( + 'Create', on_click=self._on_create_board, + ).props('no-caps').classes('text-xs') + + ui.separator() + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) + # Initial render + self._rebuild_board_buttons() + self._rebuild_boards_settings() + # ------------------------------------------------------------------ - # Channel selector (message view) + # Board selector (message view) # ------------------------------------------------------------------ - def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None: - """Rebuild the channel selector buttons for enabled BBS channels. - - Args: - enabled_channels: List of enabled channel config dicts. - """ - if not self._channel_btn_row: + def _rebuild_board_buttons(self) -> None: + """Rebuild board selector buttons from current config.""" + if not self._board_btn_row: return - self._channel_btn_row.clear() - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - if not enabled_channels: - ui.label('No BBS channels configured.').classes( + self._board_btn_row.clear() + boards = self._config_store.get_boards() + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + if not boards: + ui.label('No boards configured.').classes( 'text-xs text-gray-400 italic' ) return - for cfg in enabled_channels: - idx = cfg['channel'] - name = cfg['name'] + for board in boards: ui.button( - name, - on_click=lambda i=idx: self._select_channel(i), + board.name, + on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first channel when none is active yet - if self._active_channel_idx is None and enabled_channels: - self._select_channel(enabled_channels[0]['channel']) + # Auto-select first board if none active or active was deleted + 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]) - def _select_channel(self, channel_idx: int) -> None: - """Switch the active channel and rebuild filter options. + def _select_board(self, board: BbsBoard) -> None: + """Activate a board and rebuild filter selects. Args: - channel_idx: MeshCore channel index to activate. + board: Board to activate. """ - self._active_channel_idx = channel_idx + self._active_board = board self._active_region = None self._active_category = None - cfg = self._config_store.get_channel(channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) - - has_regions = bool(regions) + has_regions = bool(board.regions) if self._region_row: self._region_row.set_visibility(has_regions) if self._post_region_row: self._post_region_row.set_visibility(has_regions) - region_opts = ['(all)'] + regions + region_opts = ['(all)'] + board.regions if self._region_select: self._region_select.options = region_opts self._region_select.value = '(all)' if self._post_region_select: - self._post_region_select.options = regions - self._post_region_select.value = regions[0] if regions else None + self._post_region_select.options = board.regions + self._post_region_select.value = board.regions[0] if board.regions else None - cat_opts = ['(all)'] + categories + cat_opts = ['(all)'] + board.categories if self._category_select: self._category_select.options = cat_opts self._category_select.value = '(all)' if self._post_category_select: - self._post_category_select.options = categories - self._post_category_select.value = categories[0] if categories else None + self._post_category_select.options = board.categories + self._post_category_select.value = board.categories[0] if board.categories else None self._refresh_messages() # ------------------------------------------------------------------ - # Filter callbacks + # Filters # ------------------------------------------------------------------ def _on_region_filter(self, value: Optional[str]) -> None: @@ -227,20 +245,24 @@ class BbsPanel: self._refresh_messages() # ------------------------------------------------------------------ - # Message list refresh + # Message list # ------------------------------------------------------------------ def _refresh_messages(self) -> None: - """Query the BBS service and rebuild the message list UI.""" if not self._msg_list_container: return self._msg_list_container.clear() with self._msg_list_container: - if self._active_channel_idx is None: - ui.label('Select a channel above.').classes('text-xs text-gray-400 italic') + 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( - channel=self._active_channel_idx, + channels=self._active_board.channels, region=self._active_region, category=self._active_category, ) @@ -251,11 +273,6 @@ class BbsPanel: self._render_message_row(msg) def _render_message_row(self, msg: BbsMessage) -> None: - """Render a single message row. - - Args: - msg: BbsMessage to display. - """ ts = msg.timestamp[:16].replace('T', ' ') region_label = f' [{msg.region}]' if msg.region else '' header = f'{ts} {msg.sender} [{msg.category}]{region_label}' @@ -268,14 +285,12 @@ class BbsPanel: # ------------------------------------------------------------------ def _on_post(self) -> None: - """Handle the Send button: validate inputs and post a BBS message.""" - if self._active_channel_idx is None: - ui.notify('Select a channel first.', type='warning') + 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 - - cfg = self._config_store.get_channel(self._active_channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) text = (self._text_input.value or '').strip() if self._text_input else '' if not text: @@ -283,36 +298,38 @@ class BbsPanel: return category = ( - self._post_category_select.value - if self._post_category_select else (categories[0] if categories else '') + 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 regions and self._post_region_select: + if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' + # Post on first assigned channel (primary channel for outgoing) + target_channel = self._active_board.channels[0] + msg = BbsMessage( - channel=self._active_channel_idx, - region=region, - category=category, - sender='Me', - sender_key='', - text=text, + channel=target_channel, + region=region, category=category, + sender='Me', sender_key='', text=text, ) self._service.post_message(msg) - # Broadcast on the mesh channel region_part = f'{region} ' if region else '' mesh_text = f'!bbs post {region_part}{category} {text}' self._put_command({ 'action': 'send_message', - 'channel': self._active_channel_idx, + 'channel': target_channel, 'text': mesh_text, }) - debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}') + 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 = '' @@ -320,170 +337,199 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings panel + # Settings -- board list # ------------------------------------------------------------------ - def _rebuild_settings(self, device_channels: List[Dict]) -> None: - """Rebuild the settings rows for all device channels. - - Called from update() when the device channel list changes. - - Args: - device_channels: Channel list from SharedData snapshot. - """ - if not self._settings_container: + def _rebuild_boards_settings(self) -> None: + """Rebuild the settings section for all configured boards.""" + if not self._boards_settings_container: return - self._settings_container.clear() - with self._settings_container: - if not device_channels: - ui.label('No channels received from device yet.').classes( + self._boards_settings_container.clear() + boards = self._config_store.get_boards() + with self._boards_settings_container: + if not boards: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) return - for ch in device_channels: - self._render_settings_row(ch) + for board in boards: + self._render_board_settings_row(board) - def _render_settings_row(self, device_ch: Dict) -> None: - """Render one settings row for a single device channel. + def _render_board_settings_row(self, board: BbsBoard) -> None: + """Render one settings expansion for a single board. Args: - device_ch: Channel dict from SharedData (keys: idx, name). + board: Board to render. """ - ch_idx = device_ch.get('idx', device_ch.get('index', 0)) - ch_name = device_ch.get('name', f'Ch {ch_idx}') - bbs_cfg = self._config_store.get_channel(ch_idx) - is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False - with ui.expansion( - f'[{ch_idx}] {ch_name}', - value=False, + board.name, value=False, ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): - # Enable / disable toggle - enable_cb = ui.checkbox( - 'Enable BBS on this channel', - value=is_enabled, - ) - - # Categories input - cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES) - cats_input = ui.input( - label='Categories (comma-separated)', - value=cats_val, + # Name + name_input = ui.input( + label='Board name', value=board.name, ).classes('w-full text-xs') - # Regions input - regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else '' + # Channel assignment + ui.label('Channels (select which device channels belong to this board):').classes( + 'text-xs text-gray-600' + ) + ch_checks: Dict[int, object] = {} + with ui.row().classes('flex-wrap gap-2'): + if not self._device_channels: + ui.label('No device channels known yet.').classes( + 'text-xs text-gray-400 italic' + ) + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {ch_name}', + value=idx in board.channels, + ).classes('text-xs') + ch_checks[idx] = cb + + # Categories + cats_input = ui.input( + label='Categories (comma-separated)', + value=', '.join(board.categories), + ).classes('w-full text-xs') + + # Regions regions_input = ui.input( label='Regions (comma-separated, leave empty for none)', - value=regions_val, + value=', '.join(board.regions), ).classes('w-full text-xs') # Retention - ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS) retention_input = ui.input( label='Retention (hours)', - value=ret_val, - ).classes('w-full text-xs').style('max-width: 160px') + value=str(board.retention_hours), + ).classes('text-xs').style('max-width: 160px') # Whitelist - wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else '' - whitelist_input = ui.input( - label='Allowed keys (comma-separated hex, leave empty for all)', - value=wl_val, + wl_input = ui.input( + label='Allowed keys (comma-separated hex, empty = all)', + value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Save button - def _save( - idx=ch_idx, - name=ch_name, - cb=enable_cb, - cats=cats_input, - regs=regions_input, - ret=retention_input, - wl=whitelist_input, - ) -> None: - categories = [ - c.strip().upper() - for c in (cats.value or '').split(',') - if c.strip() - ] - regions = [ - r.strip() - for r in (regs.value or '').split(',') - if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wl.value or '').split(',') - if k.strip() - ] - cfg_entry = { - 'channel': idx, - 'name': name, - 'enabled': cb.value, - 'categories': categories if categories else list(DEFAULT_CATEGORIES), - 'regions': regions, - 'retention_hours': retention_hours, - 'allowed_keys': allowed_keys, - } - self._config_store.set_channel(cfg_entry) - debug_print( - f'BBS settings: saved ch={idx} enabled={cb.value} ' - f'cats={categories} regions={regions}' - ) - ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive') - # Refresh channel buttons and message view - self._refresh_after_settings_save() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - - def _refresh_after_settings_save(self) -> None: - """Rebuild the channel selector buttons after a settings save.""" - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) - # Reset active channel if it was disabled - if self._active_channel_idx is not None: - cfg = self._config_store.get_channel(self._active_channel_idx) - if not cfg or not cfg.get('enabled', False): - self._active_channel_idx = None - if self._msg_list_container: - self._msg_list_container.clear() - with self._msg_list_container: - ui.label('Select a channel above.').classes( - 'text-xs text-gray-400 italic' + with ui.row().classes('gap-2'): + def _save( + bid=board.id, + ni=name_input, + cc=ch_checks, + ci=cats_input, + ri=regions_input, + ret=retention_input, + wli=wl_input, + ) -> None: + new_name = (ni.value or '').strip() or bid + selected_channels = [ + idx for idx, cb in cc.items() if cb.value + ] + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + regions = [ + r.strip() + for r in (ri.value or '').split(',') if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wli.value or '').split(',') if k.strip() + ] + updated = BbsBoard( + id=bid, + name=new_name, + channels=selected_channels, + categories=categories, + regions=regions, + retention_hours=retention_hours, + allowed_keys=allowed_keys, ) + self._config_store.set_board(updated) + debug_print( + f'BBS settings: saved board {bid} ' + f'channels={selected_channels}' + ) + ui.notify(f'Board "{new_name}" saved.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + def _delete(bid=board.id, bname=board.name) -> None: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: deleted board {bid}') + ui.notify(f'Board "{bname}" deleted.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + ui.button( + 'Delete', on_click=_delete, + ).props('no-caps flat color=negative').classes('text-xs') # ------------------------------------------------------------------ - # External update hook (called from dashboard timer) + # Settings -- create new board + # ------------------------------------------------------------------ + + def _on_create_board(self) -> None: + """Handle the Create button for a new board.""" + name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' + if not name: + ui.notify('Enter a board name first.', type='warning') + return + + board_id = _slug(name) + # Make id unique if needed + base_id = board_id + counter = 2 + while self._config_store.board_id_exists(board_id): + board_id = f'{base_id}_{counter}' + counter += 1 + + board = BbsBoard( + id=board_id, + name=name, + channels=[], + categories=list(DEFAULT_CATEGORIES), + regions=[], + retention_hours=DEFAULT_RETENTION_HOURS, + allowed_keys=[], + ) + self._config_store.set_board(board) + debug_print(f'BBS settings: created board {board_id}') + if self._new_board_name_input: + self._new_board_name_input.value = '' + ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # External update hook # ------------------------------------------------------------------ def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings panel when the device channel list changes. + Rebuilds the settings channel checkboxes when the device channel + list changes. Args: data: SharedData snapshot dict. """ device_channels = data.get('channels', []) - - # Rebuild settings only when the channel list changes - ch_fingerprint = tuple( + fingerprint = tuple( (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels ) - last_fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels - ) - if ch_fingerprint != last_fingerprint: - self._last_device_channels = device_channels - self._rebuild_settings(device_channels) - # Also rebuild channel buttons (config may have changed) - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_boards_settings() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index 1e43842..c05727b 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -1,36 +1,46 @@ """ -BBS channel configuration store for MeshCore GUI. +BBS board configuration store for MeshCore GUI. -Persists BBS channel configuration to -``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive -restarts and are managed outside of ``config.py``. +Persists BBS board configuration to +``~/.meshcore-gui/bbs/bbs_config.json``. -On first use the file is created with an empty channel list. -The GUI populates it when the user enables BBS on a device channel. +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 methods acquire an internal ``threading.Lock``. +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 location +# Storage # --------------------------------------------------------------------------- BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" -CONFIG_VERSION: int = 1 +CONFIG_VERSION: int = 2 # --------------------------------------------------------------------------- -# Default values applied when a channel is first enabled +# Defaults # --------------------------------------------------------------------------- DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] @@ -38,8 +48,64 @@ 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 channel configuration. + """Persistent store for BBS board configuration. Args: config_path: Path to the JSON config file. @@ -49,7 +115,7 @@ class BbsConfigStore: def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: self._path = config_path self._lock = threading.Lock() - self._data: Dict = {"version": CONFIG_VERSION, "channels": []} + self._boards: List[BbsBoard] = [] self._load() # ------------------------------------------------------------------ @@ -57,36 +123,79 @@ class BbsConfigStore: # ------------------------------------------------------------------ def _load(self) -> None: - """Load config from disk; create defaults if file is absent.""" + """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") + debug_print("BBS config: created new config file (v2)") return try: raw = self._path.read_text(encoding="utf-8") data = json.loads(raw) - if data.get("version") == CONFIG_VERSION: - self._data = data + 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: loaded {len(self._data.get('channels', []))} channels" + f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" ) else: debug_print( - f"BBS config: version mismatch " - f"(got {data.get('version')}, expected {CONFIG_VERSION}) β€” using defaults" + f"BBS config: unknown version {version}, using empty config" ) + except (json.JSONDecodeError, OSError) as exc: - debug_print(f"BBS config: load error ({exc}) β€” using defaults") + 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(self._data, indent=2, ensure_ascii=False), + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8", ) tmp.replace(self._path) @@ -97,136 +206,97 @@ class BbsConfigStore: self._save_unlocked() # ------------------------------------------------------------------ - # Channel management + # Board queries # ------------------------------------------------------------------ - def get_channels(self) -> List[Dict]: - """Return a copy of all configured channels (enabled and disabled). + def get_boards(self) -> List[BbsBoard]: + """Return a copy of all configured boards. Returns: - List of channel config dicts. + List of ``BbsBoard`` instances. """ with self._lock: - return [ch.copy() for ch in self._data.get("channels", [])] + return list(self._boards) - def get_enabled_channels(self) -> List[Dict]: - """Return only channels with ``enabled: true``. - - Returns: - List of enabled channel config dicts. - """ - with self._lock: - return [ - ch.copy() - for ch in self._data.get("channels", []) - if ch.get("enabled", False) - ] - - def get_channel(self, channel_idx: int) -> Optional[Dict]: - """Return config for a single channel index, or ``None``. + def get_board(self, board_id: str) -> Optional[BbsBoard]: + """Return a board by its id, or ``None``. Args: - channel_idx: MeshCore channel index. + board_id: Board identifier string. Returns: - Channel config dict copy, or ``None`` if not found. + ``BbsBoard`` instance or ``None``. """ with self._lock: - for ch in self._data.get("channels", []): - if ch.get("channel") == channel_idx: - return ch.copy() + for b in self._boards: + if b.id == board_id: + return BbsBoard.from_dict(b.to_dict()) return None - def set_channel(self, channel_cfg: Dict) -> None: - """Insert or update a channel configuration entry. + def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: + """Return the first board that includes *channel_idx*, or ``None``. - The channel is identified by the ``channel`` key in *channel_cfg*. - If an entry with the same index exists it is replaced; otherwise - a new entry is appended. - - Args: - channel_cfg: Channel config dict (must contain ``'channel'``). - """ - idx = channel_cfg["channel"] - with self._lock: - channels = self._data.setdefault("channels", []) - for i, ch in enumerate(channels): - if ch.get("channel") == idx: - channels[i] = channel_cfg.copy() - self._save_unlocked() - debug_print(f"BBS config: updated ch={idx}") - return - channels.append(channel_cfg.copy()) - self._save_unlocked() - debug_print(f"BBS config: added ch={idx}") - - def enable_channel( - self, - channel_idx: int, - name: str, - *, - categories: Optional[List[str]] = None, - regions: Optional[List[str]] = None, - retention_hours: int = DEFAULT_RETENTION_HOURS, - allowed_keys: Optional[List[str]] = None, - ) -> None: - """Enable BBS on a device channel, creating a default config if needed. - - If the channel already exists its ``enabled`` flag is set to - ``True`` and other fields are left as-is. Pass explicit keyword - arguments to override any field on a new channel. - - Args: - channel_idx: MeshCore channel index. - name: Human-readable channel name. - categories: Category list (defaults to ``DEFAULT_CATEGORIES``). - regions: Region list (defaults to empty β€” no regions). - retention_hours: Retention in hours (default 48). - allowed_keys: Sender key whitelist (default empty = all allowed). - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = True - self.set_channel(existing) - else: - self.set_channel({ - "channel": channel_idx, - "name": name, - "enabled": True, - "categories": categories if categories is not None else list(DEFAULT_CATEGORIES), - "regions": regions if regions is not None else list(DEFAULT_REGIONS), - "retention_hours": retention_hours, - "allowed_keys": allowed_keys if allowed_keys is not None else [], - }) - - def disable_channel(self, channel_idx: int) -> None: - """Set ``enabled: false`` for a channel without removing its config. + Used by ``BbsCommandHandler`` to route incoming mesh commands. Args: channel_idx: MeshCore channel index. - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = False - self.set_channel(existing) - debug_print(f"BBS config: disabled ch={channel_idx}") - - def update_channel_field( - self, channel_idx: int, field: str, value - ) -> bool: - """Update a single field on an existing channel entry. - - Args: - channel_idx: MeshCore channel index. - field: Field name to update. - value: New value. Returns: - ``True`` if the channel was found and updated, ``False`` otherwise. + ``BbsBoard`` instance or ``None``. """ - existing = self.get_channel(channel_idx) - if not existing: - return False - existing[field] = value - self.set_channel(existing) - return True + 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) diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 2286c08..52b7968 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -1,27 +1,27 @@ """ Offline Bulletin Board System (BBS) service for MeshCore GUI. -Stores BBS messages in a local SQLite database, one table per channel. -Channel configuration is managed by -:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and -persisted to ``~/.meshcore-gui/bbs/bbs_config.json``. +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. +- ``BbsService`` -- persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. Thread safety ~~~~~~~~~~~~~ -SQLite connections are created in the calling thread. The service uses -``check_same_thread=False`` combined with an internal ``threading.Lock`` -so it is safe to call from both the GUI thread and the worker thread. +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 location -~~~~~~~~~~~~~~~~ -``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). -``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore). +Storage +~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) """ import sqlite3 @@ -33,10 +33,6 @@ from typing import Dict, List, Optional from meshcore_gui.config import debug_print -# --------------------------------------------------------------------------- -# Storage -# --------------------------------------------------------------------------- - BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" BBS_DB_PATH = BBS_DIR / "bbs_messages.db" @@ -51,9 +47,9 @@ class BbsMessage: Attributes: id: Database row id (``None`` before insert). - channel: MeshCore channel index. - region: Region tag (empty string when channel has no regions). - category: Category tag (e.g. ``'MEDISCH'``). + 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. @@ -81,7 +77,6 @@ class BbsService: Args: db_path: Path to the SQLite database file. - Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``. """ def __init__(self, db_path: Path = BBS_DB_PATH) -> None: @@ -89,10 +84,6 @@ class BbsService: self._lock = threading.Lock() self._init_db() - # ------------------------------------------------------------------ - # Initialisation - # ------------------------------------------------------------------ - def _init_db(self) -> None: """Create the database directory and schema if not present.""" BBS_DIR.mkdir(parents=True, exist_ok=True) @@ -121,10 +112,7 @@ class BbsService: debug_print(f"BBS: database ready at {self._db_path}") def _connect(self) -> sqlite3.Connection: - """Return a new SQLite connection (check_same_thread=False).""" - return sqlite3.connect( - str(self._db_path), check_same_thread=False - ) + return sqlite3.connect(str(self._db_path), check_same_thread=False) # ------------------------------------------------------------------ # Write @@ -142,163 +130,150 @@ class BbsService: 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, - ), + """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 msg id={msg.id} ch={msg.channel} " + f"BBS: posted id={msg.id} ch={msg.channel} " f"cat={msg.category} sender={msg.sender}" ) return msg.id # ------------------------------------------------------------------ - # Read + # Read (channels is a list to support multi-channel boards) # ------------------------------------------------------------------ def get_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, limit: int = 5, ) -> List[BbsMessage]: - """Return the *limit* most recent messages for a channel. + """Return the *limit* most recent messages for a set of channels. Args: - channel: MeshCore channel index. - region: Optional region filter (exact match; ``None`` = all). - category: Optional category filter (exact match; ``None`` = all). + 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 = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + 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(row) for row in rows] + return [self._row_to_msg(r) for r in rows] def get_all_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, ) -> List[BbsMessage]: - """Return all messages for a channel (oldest first) for the GUI panel. + """Return all messages for a set of channels (oldest first). Args: - channel: MeshCore channel index. + 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 = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + 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(row) for row in rows] + 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], + 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, channel: int, retention_hours: int) -> int: - """Delete messages older than *retention_hours* for a channel. + def purge_expired(self, channels: List[int], retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a set of channels. Args: - channel: MeshCore channel index. + 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( - "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", - (channel, cutoff), + 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={channel}" + f"BBS: purged {deleted} expired messages from ch={channels}" ) return deleted - def purge_all_expired(self, channels_config: List[Dict]) -> None: - """Run retention cleanup for all configured channels. + def purge_all_expired(self, boards) -> None: + """Run retention cleanup for all boards. Args: - channels_config: List of channel config dicts. + boards: Iterable of ``BbsBoard`` instances. """ - for cfg in channels_config: - self.purge_expired(cfg["channel"], cfg["retention_hours"]) + for board in boards: + self.purge_expired(board.channels, board.retention_hours) # --------------------------------------------------------------------------- @@ -308,14 +283,13 @@ class BbsService: class BbsCommandHandler: """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - Channel configuration is read live from the supplied - :class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` - so that changes made in the GUI take effect immediately without - restarting the application. + 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 channel config. + config_store: ``BbsConfigStore`` instance for live board config. """ READ_LIMIT: int = 5 @@ -324,13 +298,6 @@ class BbsCommandHandler: self._service = service self._config_store = config_store - def _get_cfg(self, channel_idx: int) -> Optional[Dict]: - """Return enabled channel config, or ``None``.""" - cfg = self._config_store.get_channel(channel_idx) - if cfg and cfg.get("enabled", False): - return cfg - return None - # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ @@ -357,46 +324,44 @@ class BbsCommandHandler: if not text.lower().startswith("!bbs"): return None - cfg = self._get_cfg(channel_idx) - if cfg is None: + board = self._config_store.get_board_for_channel(channel_idx) + if board is None: return None # Whitelist check - allowed = cfg.get("allowed_keys", []) - if allowed and sender_key not in allowed: + 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 ch={channel_idx})" + 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(cfg, sender, sender_key, args) + return self._dispatch(board, channel_idx, sender, sender_key, args) # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ - def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: + 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(cfg, sender, sender_key, rest) + return self._handle_post(board, channel_idx, sender, sender_key, rest) if sub == "read": - return self._handle_read(cfg, rest) + return self._handle_read(board, rest) if sub == "help" or not sub: - return self._handle_help(cfg) - return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + return self._handle_help(board) + return f"Unknown command '{sub}'. {self._handle_help(board)}" # ------------------------------------------------------------------ - # Sub-command: post + # post # ------------------------------------------------------------------ - def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + 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: @@ -407,16 +372,14 @@ class BbsCommandHandler: f"Categories: {', '.join(categories)}" ) region, category, text = tokens[0], tokens[1], tokens[2] - region_upper = region.upper() - valid_regions = [r.upper() for r in regions] - if region_upper not in valid_regions: + 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_regions.index(region_upper)] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + 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_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] else: if len(tokens) < 2: return ( @@ -425,67 +388,57 @@ class BbsCommandHandler: ) region = "" category, text = tokens[0], tokens[1] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + 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_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] msg = BbsMessage( - channel=cfg["channel"], - region=region, - category=category, - sender=sender, - sender_key=sender_key, - text=text, + 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]}" # ------------------------------------------------------------------ - # Sub-command: read + # read # ------------------------------------------------------------------ - def _handle_read(self, cfg: Dict, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + def _handle_read(self, board, args): + regions = board.regions + categories = board.categories tokens = args.split() if args else [] - - region: Optional[str] = None - category: Optional[str] = None + region = None + category = None if regions: - valid_regions_upper = [r.upper() for r in regions] - valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_regions_upper: - region = regions[valid_regions_upper.index(tok0)] + 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: - tok1 = tokens[1].upper() - if tok1 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok1)] + 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_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok0)] + 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( - cfg["channel"], region=region, category=category, limit=self.READ_LIMIT, + 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", " ") @@ -494,25 +447,22 @@ class BbsCommandHandler: return "\n".join(lines) # ------------------------------------------------------------------ - # Sub-command: help + # help # ------------------------------------------------------------------ - def _handle_help(self, cfg: Dict) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] - name = cfg.get("name", f"ch{cfg['channel']}") - if regions: + def _handle_help(self, board) -> str: + cats = ", ".join(board.categories) + if board.regions: + regs = ", ".join(board.regions) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [region] [cat] [text] | " f"!bbs read [region] [cat] | " - f"Regions: {', '.join(regions)} | " - f"Categories: {', '.join(categories)}" + f"Regions: {regs} | Categories: {cats}" ) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [cat] [text] | " f"!bbs read [cat] | " - f"Categories: {', '.join(categories)}" + f"Categories: {cats}" ) - From 395db80c97d0ba2abe2c4201f36db96032430667 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 16:38:33 +0100 Subject: [PATCH 33/39] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/gui/panels/bbs_panel.py | 385 +++++++++--------- .../meshcore_gui/gui/panels/bbs_panel.py | 385 +++++++++--------- 2 files changed, 402 insertions(+), 368 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 0282a59..554f02b 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -30,9 +30,10 @@ def _slug(name: str) -> str: class BbsPanel: """BBS panel: board selector, filters, message list, post form and settings. - The settings section lets users create, configure and delete boards. - Each board can span one or more device channels (from SharedData). - Configuration is persisted via BbsConfigStore. + The settings section automatically derives one board per device channel. + Boards are enabled/disabled per channel; no manual board creation needed. + Advanced options (regions, allowed keys, channel combining) are hidden + in a collapsible section for administrator use. Args: put_command: Callable to enqueue a command dict for the worker. @@ -68,8 +69,6 @@ class BbsPanel: # UI refs -- settings self._boards_settings_container = None - self._new_board_name_input = None - self._new_board_channel_checks: Dict[int, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -143,26 +142,11 @@ class BbsPanel: # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.label( - 'Create boards and assign device channels. ' - 'One board can cover multiple channels.' - ).classes('text-xs text-gray-500') ui.separator() - # New board form - with ui.row().classes('w-full items-center gap-2 flex-wrap'): - ui.label('New board:').classes('text-sm text-gray-600') - self._new_board_name_input = ui.input( - placeholder='Board name...', - ).classes('text-xs').style('min-width: 160px') - ui.button( - 'Create', on_click=self._on_create_board, - ).props('no-caps').classes('text-xs') - - ui.separator() self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('No boards configured yet.').classes( + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) @@ -183,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No boards configured.').classes( + ui.label('Geen actieve boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -254,10 +238,10 @@ class BbsPanel: 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') + ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('No channels assigned to this board.').classes( + ui.label('Geen kanalen gekoppeld aan dit board.').classes( 'text-xs text-gray-400 italic' ) return @@ -267,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('No messages.').classes('text-xs text-gray-400 italic') + ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -286,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Select a board first.', type='warning') + ui.notify('Selecteer eerst een board.', type='warning') return if not self._active_board.channels: - ui.notify('No channels assigned to this board.', type='warning') + ui.notify('Geen kanalen gekoppeld aan dit 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') + ui.notify('Berichttekst mag niet leeg zijn.', type='warning') return category = ( @@ -302,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Please select a category.', type='warning') + ui.notify('Selecteer een categorie.', type='warning') return region = '' @@ -334,183 +318,216 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Message posted.', type='positive') + ui.notify('Bericht geplaatst.', type='positive') # ------------------------------------------------------------------ - # Settings -- board list + # Settings -- channel list (standard view) # ------------------------------------------------------------------ def _rebuild_boards_settings(self) -> None: - """Rebuild the settings section for all configured boards.""" + """Rebuild settings: one row per device channel + collapsed advanced section.""" if not self._boards_settings_container: return self._boards_settings_container.clear() - boards = self._config_store.get_boards() with self._boards_settings_container: - if not boards: - ui.label('No boards configured yet.').classes( + if not self._device_channels: + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) return - for board in boards: - self._render_board_settings_row(board) - def _render_board_settings_row(self, board: BbsBoard) -> None: - """Render one settings expansion for a single board. + # Standard view: one row per channel + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + # Advanced section (collapsed) + with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): + ui.label("Regio's en sleutellijst per kanaal").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( + 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + ).classes('text-xs text-gray-400 italic') + + def _render_channel_settings_row(self, ch: Dict) -> None: + """Render the standard settings row for a single device channel. + + Shows enable toggle, categories, retention and a Save button. Args: - board: Board to render. + ch: Device channel dict with 'idx'/'index' and 'name' keys. """ - with ui.expansion( - board.name, value=False, - ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): + 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) - # Name - name_input = ui.input( - label='Board name', value=board.name, - ).classes('w-full text-xs') + 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) - # Channel assignment - ui.label('Channels (select which device channels belong to this board):').classes( - 'text-xs text-gray-600' + with ui.card().classes('w-full p-2'): + # Header row: channel name + active toggle + 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: '● Actief', False: 'β—‹ Uit'}, + value=is_active, + ).classes('text-xs') + + # Categories + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + # Retention + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input(value=retention_value).classes('text-xs').style( + 'max-width: 80px' ) - ch_checks: Dict[int, object] = {} + ui.label('uur').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 + # Preserve extra combined channels and advanced fields if board existed + 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: kanaal {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + else: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') + ui.notify(f'{bname} uitgeschakeld.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + + # ------------------------------------------------------------------ + # Settings -- advanced section (collapsed) + # ------------------------------------------------------------------ + + def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: + """Render the advanced settings block for a single active channel. + + Shows regions, allowed keys and optional channel combining. + + 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="Regio's (komma-gescheiden)", + value=', '.join(board.regions), + ).classes('w-full text-xs') + + wl_input = ui.input( + label='Toegestane sleutels (leeg = iedereen op het kanaal)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + # Combine with other channels + 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('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): - if not self._device_channels: - ui.label('No device channels known yet.').classes( - 'text-xs text-gray-400 italic' - ) - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') + 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'[{idx}] {ch_name}', - value=idx in board.channels, + f'[{other_idx}] {other_name}', + value=other_idx in board.channels, ).classes('text-xs') - ch_checks[idx] = cb + ch_checks[other_idx] = cb - # Categories - cats_input = ui.input( - label='Categories (comma-separated)', - value=', '.join(board.categories), - ).classes('w-full text-xs') + 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('Zet het kanaal eerst op Actief.', 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 (geavanceerd): {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() - # Regions - regions_input = ui.input( - label='Regions (comma-separated, leave empty for none)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - # Retention - retention_input = ui.input( - label='Retention (hours)', - value=str(board.retention_hours), - ).classes('text-xs').style('max-width: 160px') - - # Whitelist - wl_input = ui.input( - label='Allowed keys (comma-separated hex, empty = all)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - with ui.row().classes('gap-2'): - def _save( - bid=board.id, - ni=name_input, - cc=ch_checks, - ci=cats_input, - ri=regions_input, - ret=retention_input, - wli=wl_input, - ) -> None: - new_name = (ni.value or '').strip() or bid - selected_channels = [ - idx for idx, cb in cc.items() if cb.value - ] - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - regions = [ - r.strip() - for r in (ri.value or '').split(',') if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wli.value or '').split(',') if k.strip() - ] - updated = BbsBoard( - id=bid, - name=new_name, - channels=selected_channels, - categories=categories, - regions=regions, - retention_hours=retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print( - f'BBS settings: saved board {bid} ' - f'channels={selected_channels}' - ) - ui.notify(f'Board "{new_name}" saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - def _delete(bid=board.id, bname=board.name) -> None: - self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None - debug_print(f'BBS settings: deleted board {bid}') - ui.notify(f'Board "{bname}" deleted.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - ui.button( - 'Delete', on_click=_delete, - ).props('no-caps flat color=negative').classes('text-xs') - - # ------------------------------------------------------------------ - # Settings -- create new board - # ------------------------------------------------------------------ - - def _on_create_board(self) -> None: - """Handle the Create button for a new board.""" - name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' - if not name: - ui.notify('Enter a board name first.', type='warning') - return - - board_id = _slug(name) - # Make id unique if needed - base_id = board_id - counter = 2 - while self._config_store.board_id_exists(board_id): - board_id = f'{base_id}_{counter}' - counter += 1 - - board = BbsBoard( - id=board_id, - name=name, - channels=[], - categories=list(DEFAULT_CATEGORIES), - regions=[], - retention_hours=DEFAULT_RETENTION_HOURS, - allowed_keys=[], - ) - self._config_store.set_board(board) - debug_print(f'BBS settings: created board {board_id}') - if self._new_board_name_input: - self._new_board_name_input.value = '' - ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.separator() # ------------------------------------------------------------------ # External update hook @@ -519,8 +536,8 @@ class BbsPanel: def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings channel checkboxes when the device channel - list changes. + Rebuilds the settings channel list when the device channel list + changes. Args: data: SharedData snapshot dict. diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 0282a59..554f02b 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -30,9 +30,10 @@ def _slug(name: str) -> str: class BbsPanel: """BBS panel: board selector, filters, message list, post form and settings. - The settings section lets users create, configure and delete boards. - Each board can span one or more device channels (from SharedData). - Configuration is persisted via BbsConfigStore. + The settings section automatically derives one board per device channel. + Boards are enabled/disabled per channel; no manual board creation needed. + Advanced options (regions, allowed keys, channel combining) are hidden + in a collapsible section for administrator use. Args: put_command: Callable to enqueue a command dict for the worker. @@ -68,8 +69,6 @@ class BbsPanel: # UI refs -- settings self._boards_settings_container = None - self._new_board_name_input = None - self._new_board_channel_checks: Dict[int, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -143,26 +142,11 @@ class BbsPanel: # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.label( - 'Create boards and assign device channels. ' - 'One board can cover multiple channels.' - ).classes('text-xs text-gray-500') ui.separator() - # New board form - with ui.row().classes('w-full items-center gap-2 flex-wrap'): - ui.label('New board:').classes('text-sm text-gray-600') - self._new_board_name_input = ui.input( - placeholder='Board name...', - ).classes('text-xs').style('min-width: 160px') - ui.button( - 'Create', on_click=self._on_create_board, - ).props('no-caps').classes('text-xs') - - ui.separator() self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('No boards configured yet.').classes( + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) @@ -183,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No boards configured.').classes( + ui.label('Geen actieve boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -254,10 +238,10 @@ class BbsPanel: 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') + ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('No channels assigned to this board.').classes( + ui.label('Geen kanalen gekoppeld aan dit board.').classes( 'text-xs text-gray-400 italic' ) return @@ -267,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('No messages.').classes('text-xs text-gray-400 italic') + ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -286,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Select a board first.', type='warning') + ui.notify('Selecteer eerst een board.', type='warning') return if not self._active_board.channels: - ui.notify('No channels assigned to this board.', type='warning') + ui.notify('Geen kanalen gekoppeld aan dit 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') + ui.notify('Berichttekst mag niet leeg zijn.', type='warning') return category = ( @@ -302,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Please select a category.', type='warning') + ui.notify('Selecteer een categorie.', type='warning') return region = '' @@ -334,183 +318,216 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Message posted.', type='positive') + ui.notify('Bericht geplaatst.', type='positive') # ------------------------------------------------------------------ - # Settings -- board list + # Settings -- channel list (standard view) # ------------------------------------------------------------------ def _rebuild_boards_settings(self) -> None: - """Rebuild the settings section for all configured boards.""" + """Rebuild settings: one row per device channel + collapsed advanced section.""" if not self._boards_settings_container: return self._boards_settings_container.clear() - boards = self._config_store.get_boards() with self._boards_settings_container: - if not boards: - ui.label('No boards configured yet.').classes( + if not self._device_channels: + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) return - for board in boards: - self._render_board_settings_row(board) - def _render_board_settings_row(self, board: BbsBoard) -> None: - """Render one settings expansion for a single board. + # Standard view: one row per channel + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + # Advanced section (collapsed) + with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): + ui.label("Regio's en sleutellijst per kanaal").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( + 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + ).classes('text-xs text-gray-400 italic') + + def _render_channel_settings_row(self, ch: Dict) -> None: + """Render the standard settings row for a single device channel. + + Shows enable toggle, categories, retention and a Save button. Args: - board: Board to render. + ch: Device channel dict with 'idx'/'index' and 'name' keys. """ - with ui.expansion( - board.name, value=False, - ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): + 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) - # Name - name_input = ui.input( - label='Board name', value=board.name, - ).classes('w-full text-xs') + 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) - # Channel assignment - ui.label('Channels (select which device channels belong to this board):').classes( - 'text-xs text-gray-600' + with ui.card().classes('w-full p-2'): + # Header row: channel name + active toggle + 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: '● Actief', False: 'β—‹ Uit'}, + value=is_active, + ).classes('text-xs') + + # Categories + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + # Retention + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input(value=retention_value).classes('text-xs').style( + 'max-width: 80px' ) - ch_checks: Dict[int, object] = {} + ui.label('uur').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 + # Preserve extra combined channels and advanced fields if board existed + 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: kanaal {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + else: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') + ui.notify(f'{bname} uitgeschakeld.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + + # ------------------------------------------------------------------ + # Settings -- advanced section (collapsed) + # ------------------------------------------------------------------ + + def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: + """Render the advanced settings block for a single active channel. + + Shows regions, allowed keys and optional channel combining. + + 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="Regio's (komma-gescheiden)", + value=', '.join(board.regions), + ).classes('w-full text-xs') + + wl_input = ui.input( + label='Toegestane sleutels (leeg = iedereen op het kanaal)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + # Combine with other channels + 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('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): - if not self._device_channels: - ui.label('No device channels known yet.').classes( - 'text-xs text-gray-400 italic' - ) - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') + 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'[{idx}] {ch_name}', - value=idx in board.channels, + f'[{other_idx}] {other_name}', + value=other_idx in board.channels, ).classes('text-xs') - ch_checks[idx] = cb + ch_checks[other_idx] = cb - # Categories - cats_input = ui.input( - label='Categories (comma-separated)', - value=', '.join(board.categories), - ).classes('w-full text-xs') + 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('Zet het kanaal eerst op Actief.', 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 (geavanceerd): {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() - # Regions - regions_input = ui.input( - label='Regions (comma-separated, leave empty for none)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - # Retention - retention_input = ui.input( - label='Retention (hours)', - value=str(board.retention_hours), - ).classes('text-xs').style('max-width: 160px') - - # Whitelist - wl_input = ui.input( - label='Allowed keys (comma-separated hex, empty = all)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - with ui.row().classes('gap-2'): - def _save( - bid=board.id, - ni=name_input, - cc=ch_checks, - ci=cats_input, - ri=regions_input, - ret=retention_input, - wli=wl_input, - ) -> None: - new_name = (ni.value or '').strip() or bid - selected_channels = [ - idx for idx, cb in cc.items() if cb.value - ] - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - regions = [ - r.strip() - for r in (ri.value or '').split(',') if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wli.value or '').split(',') if k.strip() - ] - updated = BbsBoard( - id=bid, - name=new_name, - channels=selected_channels, - categories=categories, - regions=regions, - retention_hours=retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print( - f'BBS settings: saved board {bid} ' - f'channels={selected_channels}' - ) - ui.notify(f'Board "{new_name}" saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - def _delete(bid=board.id, bname=board.name) -> None: - self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None - debug_print(f'BBS settings: deleted board {bid}') - ui.notify(f'Board "{bname}" deleted.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - ui.button( - 'Delete', on_click=_delete, - ).props('no-caps flat color=negative').classes('text-xs') - - # ------------------------------------------------------------------ - # Settings -- create new board - # ------------------------------------------------------------------ - - def _on_create_board(self) -> None: - """Handle the Create button for a new board.""" - name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' - if not name: - ui.notify('Enter a board name first.', type='warning') - return - - board_id = _slug(name) - # Make id unique if needed - base_id = board_id - counter = 2 - while self._config_store.board_id_exists(board_id): - board_id = f'{base_id}_{counter}' - counter += 1 - - board = BbsBoard( - id=board_id, - name=name, - channels=[], - categories=list(DEFAULT_CATEGORIES), - regions=[], - retention_hours=DEFAULT_RETENTION_HOURS, - allowed_keys=[], - ) - self._config_store.set_board(board) - debug_print(f'BBS settings: created board {board_id}') - if self._new_board_name_input: - self._new_board_name_input.value = '' - ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.separator() # ------------------------------------------------------------------ # External update hook @@ -519,8 +536,8 @@ class BbsPanel: def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings channel checkboxes when the device channel - list changes. + Rebuilds the settings channel list when the device channel list + changes. Args: data: SharedData snapshot dict. From 7d61b7ddd26b601080b5b8f046dfd15bf53e3219 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 16:47:34 +0100 Subject: [PATCH 34/39] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/gui/panels/bbs_panel.py | 60 +++++++++---------- .../meshcore_gui/gui/panels/bbs_panel.py | 60 +++++++++---------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 554f02b..41adb22 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -146,7 +146,7 @@ class BbsPanel: self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) @@ -167,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('Geen actieve boards.').classes( + ui.label('No active boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -238,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('Geen kanalen gekoppeld aan dit board.').classes( + ui.label('No channels assigned to this board.').classes( 'text-xs text-gray-400 italic' ) return @@ -251,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') + ui.label('No messages.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -270,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Selecteer eerst een board.', type='warning') + ui.notify('Select a board first.', type='warning') return if not self._active_board.channels: - ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') + 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('Berichttekst mag niet leeg zijn.', type='warning') + ui.notify('Message text cannot be empty.', type='warning') return category = ( @@ -286,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Selecteer een categorie.', type='warning') + ui.notify('Please select a category.', type='warning') return region = '' @@ -318,7 +318,7 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Bericht geplaatst.', type='positive') + ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ # Settings -- channel list (standard view) @@ -331,7 +331,7 @@ class BbsPanel: self._boards_settings_container.clear() with self._boards_settings_container: if not self._device_channels: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) return @@ -343,8 +343,8 @@ class BbsPanel: ui.separator() # Advanced section (collapsed) - with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): - ui.label("Regio's en sleutellijst per kanaal").classes( + 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 @@ -356,7 +356,7 @@ class BbsPanel: advanced_any = True if not advanced_any: ui.label( - 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + 'Enable at least one channel to see advanced options.' ).classes('text-xs text-gray-400 italic') def _render_channel_settings_row(self, ch: Dict) -> None: @@ -381,22 +381,22 @@ class BbsPanel: 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: '● Actief', False: 'β—‹ Uit'}, + {True: '● Active', False: 'β—‹ Off'}, value=is_active, ).classes('text-xs') # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + 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') # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + 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('uur').classes('text-xs text-gray-600') + ui.label('hrs').classes('text-xs text-gray-600') def _save( bid=board_id, @@ -431,18 +431,18 @@ class BbsPanel: allowed_keys=existing.allowed_keys if existing else [], ) self._config_store.set_board(updated) - debug_print(f'BBS settings: kanaal {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings: channel {bid} saved') + ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) if self._active_board and self._active_board.id == bid: self._active_board = None - debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') - ui.notify(f'{bname} uitgeschakeld.', type='warning') + debug_print(f'BBS settings: channel {bid} disabled') + ui.notify(f'{bname} disabled.', type='warning') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') # ------------------------------------------------------------------ # Settings -- advanced section (collapsed) @@ -465,12 +465,12 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regio's (komma-gescheiden)", + label="Regions (comma-separated)", value=', '.join(board.regions), ).classes('w-full text-xs') wl_input = ui.input( - label='Toegestane sleutels (leeg = iedereen op het kanaal)', + label='Allowed keys (empty = everyone on the channel)', value=', '.join(board.allowed_keys), ).classes('w-full text-xs') @@ -481,7 +481,7 @@ class BbsPanel: ] ch_checks: Dict[int, object] = {} if other_channels: - ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') + 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)) @@ -502,7 +502,7 @@ class BbsPanel: ) -> None: existing = self._config_store.get_board(bid) if existing is None: - ui.notify('Zet het kanaal eerst op Actief.', type='warning') + ui.notify('Enable this channel first.', type='warning') return regions = [ r.strip() for r in (ri.value or '').split(',') if r.strip() @@ -521,12 +521,12 @@ class BbsPanel: allowed_keys=allowed_keys, ) self._config_store.set_board(updated) - debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings (advanced): {bid} saved') + ui.notify(f'{bname} saved.', type='positive') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() # ------------------------------------------------------------------ diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 554f02b..41adb22 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -146,7 +146,7 @@ class BbsPanel: self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) @@ -167,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('Geen actieve boards.').classes( + ui.label('No active boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -238,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('Geen kanalen gekoppeld aan dit board.').classes( + ui.label('No channels assigned to this board.').classes( 'text-xs text-gray-400 italic' ) return @@ -251,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') + ui.label('No messages.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -270,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Selecteer eerst een board.', type='warning') + ui.notify('Select a board first.', type='warning') return if not self._active_board.channels: - ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') + 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('Berichttekst mag niet leeg zijn.', type='warning') + ui.notify('Message text cannot be empty.', type='warning') return category = ( @@ -286,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Selecteer een categorie.', type='warning') + ui.notify('Please select a category.', type='warning') return region = '' @@ -318,7 +318,7 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Bericht geplaatst.', type='positive') + ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ # Settings -- channel list (standard view) @@ -331,7 +331,7 @@ class BbsPanel: self._boards_settings_container.clear() with self._boards_settings_container: if not self._device_channels: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) return @@ -343,8 +343,8 @@ class BbsPanel: ui.separator() # Advanced section (collapsed) - with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): - ui.label("Regio's en sleutellijst per kanaal").classes( + 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 @@ -356,7 +356,7 @@ class BbsPanel: advanced_any = True if not advanced_any: ui.label( - 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + 'Enable at least one channel to see advanced options.' ).classes('text-xs text-gray-400 italic') def _render_channel_settings_row(self, ch: Dict) -> None: @@ -381,22 +381,22 @@ class BbsPanel: 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: '● Actief', False: 'β—‹ Uit'}, + {True: '● Active', False: 'β—‹ Off'}, value=is_active, ).classes('text-xs') # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + 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') # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + 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('uur').classes('text-xs text-gray-600') + ui.label('hrs').classes('text-xs text-gray-600') def _save( bid=board_id, @@ -431,18 +431,18 @@ class BbsPanel: allowed_keys=existing.allowed_keys if existing else [], ) self._config_store.set_board(updated) - debug_print(f'BBS settings: kanaal {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings: channel {bid} saved') + ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) if self._active_board and self._active_board.id == bid: self._active_board = None - debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') - ui.notify(f'{bname} uitgeschakeld.', type='warning') + debug_print(f'BBS settings: channel {bid} disabled') + ui.notify(f'{bname} disabled.', type='warning') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') # ------------------------------------------------------------------ # Settings -- advanced section (collapsed) @@ -465,12 +465,12 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regio's (komma-gescheiden)", + label="Regions (comma-separated)", value=', '.join(board.regions), ).classes('w-full text-xs') wl_input = ui.input( - label='Toegestane sleutels (leeg = iedereen op het kanaal)', + label='Allowed keys (empty = everyone on the channel)', value=', '.join(board.allowed_keys), ).classes('w-full text-xs') @@ -481,7 +481,7 @@ class BbsPanel: ] ch_checks: Dict[int, object] = {} if other_channels: - ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') + 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)) @@ -502,7 +502,7 @@ class BbsPanel: ) -> None: existing = self._config_store.get_board(bid) if existing is None: - ui.notify('Zet het kanaal eerst op Actief.', type='warning') + ui.notify('Enable this channel first.', type='warning') return regions = [ r.strip() for r in (ri.value or '').split(',') if r.strip() @@ -521,12 +521,12 @@ class BbsPanel: allowed_keys=allowed_keys, ) self._config_store.set_board(updated) - debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings (advanced): {bid} saved') + ui.notify(f'{bname} saved.', type='positive') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() # ------------------------------------------------------------------ From 52d15c83a766f7e78b1045db93dd0edf9cfcf55e Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 17:09:08 +0100 Subject: [PATCH 35/39] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#V1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/__main__.py | 14 +- meshcore_gui/gui/panels/bbs_panel.py | 241 ++++++++++-------- .../meshcore_gui/gui/panels/bbs_panel.py | 241 ++++++++++-------- 3 files changed, 291 insertions(+), 205 deletions(-) diff --git a/meshcore_gui/__main__.py b/meshcore_gui/__main__.py index bab8285..5eaf7be 100644 --- a/meshcore_gui/__main__.py +++ b/meshcore_gui/__main__.py @@ -45,6 +45,7 @@ from meshcore_gui.ble.worker import create_worker from meshcore_gui.core.shared_data import SharedData from meshcore_gui.gui.dashboard import DashboardPage from meshcore_gui.gui.route_page import RoutePage +from meshcore_gui.gui.panels.bbs_panel import BbsSettingsPage from meshcore_gui.gui.archive_page import ArchivePage from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -54,6 +55,8 @@ from meshcore_gui.services.room_password_store import RoomPasswordStore _shared = None _dashboard = None _route_page = None +_bbs_settings_page = None +_bbs_config_store_main = None _archive_page = None _pin_store = None _room_password_store = None @@ -73,6 +76,13 @@ def _page_route(msg_key: str): _route_page.render(msg_key) +@ui.page('/bbs-settings') +def _page_bbs_settings(): + """NiceGUI page handler β€” BBS settings.""" + if _bbs_settings_page: + _bbs_settings_page.render() + + @ui.page('/archive') def _page_archive(): """NiceGUI page handler β€” message archive.""" @@ -155,7 +165,7 @@ def main(): Parses CLI arguments, auto-detects the transport, initialises all components and starts the NiceGUI server. """ - global _shared, _dashboard, _route_page, _archive_page, _pin_store, _room_password_store + global _shared, _dashboard, _route_page, _bbs_settings_page, _archive_page, _pin_store, _room_password_store args, flags = _parse_flags(sys.argv[1:]) @@ -259,6 +269,8 @@ def main(): _dashboard = DashboardPage(_shared, _pin_store, _room_password_store) _route_page = RoutePage(_shared) _archive_page = ArchivePage(_shared) + from meshcore_gui.services.bbs_config_store import BbsConfigStore as _BCS + _bbs_settings_page = BbsSettingsPage(_shared, _BCS()) # ── Start worker ── worker = create_worker( diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 41adb22..e84c733 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -13,6 +13,7 @@ from meshcore_gui.services.bbs_config_store import ( DEFAULT_RETENTION_HOURS, ) from meshcore_gui.services.bbs_service import BbsMessage, BbsService +from meshcore_gui.core.protocols import SharedDataReadAndLookup def _slug(name: str) -> str: @@ -27,13 +28,15 @@ def _slug(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" -class BbsPanel: - """BBS panel: board selector, filters, message list, post form and settings. +# --------------------------------------------------------------------------- +# Main BBS panel (message view only β€” settings live on /bbs-settings) +# --------------------------------------------------------------------------- - The settings section automatically derives one board per device channel. - Boards are enabled/disabled per channel; no manual board creation needed. - Advanced options (regions, allowed keys, channel combining) are hidden - in a collapsible section for administrator use. +class BbsPanel: + """BBS panel: board selector, filters, 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. @@ -67,9 +70,6 @@ class BbsPanel: self._post_category_select = None self._msg_list_container = None - # UI refs -- settings - self._boards_settings_container = None - # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] self._last_ch_fingerprint: tuple = () @@ -79,10 +79,15 @@ class BbsPanel: # ------------------------------------------------------------------ def render(self) -> None: - """Build the complete BBS panel layout.""" - # ---- Message view card -------------------------------------- + """Build the BBS message view panel layout.""" with ui.card().classes('w-full'): - ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + # 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') self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') with self._board_btn_row: @@ -114,8 +119,9 @@ class BbsPanel: ui.separator() + # Responsive message list: h-72 is overridden by domca-panel CSS self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' ) ui.separator() @@ -135,27 +141,15 @@ class BbsPanel: self._text_input = ui.input( placeholder='Message text...', - ).classes('flex-grow text-sm') + ).classes('flex-grow text-sm min-w-0') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ------------------------------------------ - 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: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - # Initial render self._rebuild_board_buttons() - self._rebuild_boards_settings() # ------------------------------------------------------------------ - # Board selector (message view) + # Board selector # ------------------------------------------------------------------ def _rebuild_board_buttons(self) -> None: @@ -167,7 +161,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No active boards.').classes( + ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return @@ -177,7 +171,6 @@ class BbsPanel: on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first board if none active or active was deleted 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]) @@ -260,9 +253,13 @@ class BbsPanel: 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 gap-0 py-1 border-b border-gray-200'): - ui.label(header).classes('text-xs text-gray-500') - ui.label(msg.text).classes('text-sm') + 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 @@ -293,7 +290,6 @@ class BbsPanel: if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' - # Post on first assigned channel (primary channel for outgoing) target_channel = self._active_board.channels[0] msg = BbsMessage( @@ -321,11 +317,113 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings -- channel list (standard view) + # External update hook # ------------------------------------------------------------------ - def _rebuild_boards_settings(self) -> None: - """Rebuild settings: one row per device channel + collapsed advanced section.""" + 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() @@ -334,36 +432,12 @@ class BbsPanel: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) - return - - # Standard view: one row per channel - for ch in self._device_channels: - self._render_channel_settings_row(ch) - - ui.separator() - - # Advanced section (collapsed) - 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') + else: + self._render_all() def _render_channel_settings_row(self, ch: Dict) -> None: """Render the standard settings row for a single device channel. - Shows enable toggle, categories, retention and a Save button. - Args: ch: Device channel dict with 'idx'/'index' and 'name' keys. """ @@ -377,7 +451,6 @@ class BbsPanel: retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) with ui.card().classes('w-full p-2'): - # Header row: channel name + active toggle 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( @@ -385,12 +458,10 @@ class BbsPanel: value=is_active, ).classes('text-xs') - # Categories 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') - # Retention 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( @@ -416,7 +487,6 @@ class BbsPanel: ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) except ValueError: ret_hours = DEFAULT_RETENTION_HOURS - # Preserve extra combined channels and advanced fields if board existed extra_channels = ( [c for c in existing.channels if c != bidx] if existing else [] @@ -435,24 +505,15 @@ class BbsPanel: ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None debug_print(f'BBS settings: channel {bid} disabled') ui.notify(f'{bname} disabled.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - # ------------------------------------------------------------------ - # Settings -- advanced section (collapsed) - # ------------------------------------------------------------------ - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: """Render the advanced settings block for a single active channel. - Shows regions, allowed keys and optional channel combining. - Args: ch: Device channel dict. board: Existing BbsBoard for this channel. @@ -465,7 +526,7 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regions (comma-separated)", + label='Regions (comma-separated)', value=', '.join(board.regions), ).classes('w-full text-xs') @@ -474,7 +535,6 @@ class BbsPanel: value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Combine with other channels other_channels = [ c for c in self._device_channels if c.get('idx', c.get('index', 0)) != idx @@ -523,30 +583,7 @@ class BbsPanel: self._config_store.set_board(updated) debug_print(f'BBS settings (advanced): {bid} saved') ui.notify(f'{bname} saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() - - # ------------------------------------------------------------------ - # External update hook - # ------------------------------------------------------------------ - - def update(self, data: Dict) -> None: - """Called by the dashboard timer with the SharedData snapshot. - - Rebuilds the settings channel list when the device channel list - changes. - - 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_boards_settings() diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 41adb22..e84c733 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -13,6 +13,7 @@ from meshcore_gui.services.bbs_config_store import ( DEFAULT_RETENTION_HOURS, ) from meshcore_gui.services.bbs_service import BbsMessage, BbsService +from meshcore_gui.core.protocols import SharedDataReadAndLookup def _slug(name: str) -> str: @@ -27,13 +28,15 @@ def _slug(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" -class BbsPanel: - """BBS panel: board selector, filters, message list, post form and settings. +# --------------------------------------------------------------------------- +# Main BBS panel (message view only β€” settings live on /bbs-settings) +# --------------------------------------------------------------------------- - The settings section automatically derives one board per device channel. - Boards are enabled/disabled per channel; no manual board creation needed. - Advanced options (regions, allowed keys, channel combining) are hidden - in a collapsible section for administrator use. +class BbsPanel: + """BBS panel: board selector, filters, 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. @@ -67,9 +70,6 @@ class BbsPanel: self._post_category_select = None self._msg_list_container = None - # UI refs -- settings - self._boards_settings_container = None - # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] self._last_ch_fingerprint: tuple = () @@ -79,10 +79,15 @@ class BbsPanel: # ------------------------------------------------------------------ def render(self) -> None: - """Build the complete BBS panel layout.""" - # ---- Message view card -------------------------------------- + """Build the BBS message view panel layout.""" with ui.card().classes('w-full'): - ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + # 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') self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') with self._board_btn_row: @@ -114,8 +119,9 @@ class BbsPanel: ui.separator() + # Responsive message list: h-72 is overridden by domca-panel CSS self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' ) ui.separator() @@ -135,27 +141,15 @@ class BbsPanel: self._text_input = ui.input( placeholder='Message text...', - ).classes('flex-grow text-sm') + ).classes('flex-grow text-sm min-w-0') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ------------------------------------------ - 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: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - # Initial render self._rebuild_board_buttons() - self._rebuild_boards_settings() # ------------------------------------------------------------------ - # Board selector (message view) + # Board selector # ------------------------------------------------------------------ def _rebuild_board_buttons(self) -> None: @@ -167,7 +161,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No active boards.').classes( + ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return @@ -177,7 +171,6 @@ class BbsPanel: on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first board if none active or active was deleted 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]) @@ -260,9 +253,13 @@ class BbsPanel: 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 gap-0 py-1 border-b border-gray-200'): - ui.label(header).classes('text-xs text-gray-500') - ui.label(msg.text).classes('text-sm') + 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 @@ -293,7 +290,6 @@ class BbsPanel: if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' - # Post on first assigned channel (primary channel for outgoing) target_channel = self._active_board.channels[0] msg = BbsMessage( @@ -321,11 +317,113 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings -- channel list (standard view) + # External update hook # ------------------------------------------------------------------ - def _rebuild_boards_settings(self) -> None: - """Rebuild settings: one row per device channel + collapsed advanced section.""" + 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() @@ -334,36 +432,12 @@ class BbsPanel: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) - return - - # Standard view: one row per channel - for ch in self._device_channels: - self._render_channel_settings_row(ch) - - ui.separator() - - # Advanced section (collapsed) - 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') + else: + self._render_all() def _render_channel_settings_row(self, ch: Dict) -> None: """Render the standard settings row for a single device channel. - Shows enable toggle, categories, retention and a Save button. - Args: ch: Device channel dict with 'idx'/'index' and 'name' keys. """ @@ -377,7 +451,6 @@ class BbsPanel: retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) with ui.card().classes('w-full p-2'): - # Header row: channel name + active toggle 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( @@ -385,12 +458,10 @@ class BbsPanel: value=is_active, ).classes('text-xs') - # Categories 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') - # Retention 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( @@ -416,7 +487,6 @@ class BbsPanel: ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) except ValueError: ret_hours = DEFAULT_RETENTION_HOURS - # Preserve extra combined channels and advanced fields if board existed extra_channels = ( [c for c in existing.channels if c != bidx] if existing else [] @@ -435,24 +505,15 @@ class BbsPanel: ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None debug_print(f'BBS settings: channel {bid} disabled') ui.notify(f'{bname} disabled.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - # ------------------------------------------------------------------ - # Settings -- advanced section (collapsed) - # ------------------------------------------------------------------ - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: """Render the advanced settings block for a single active channel. - Shows regions, allowed keys and optional channel combining. - Args: ch: Device channel dict. board: Existing BbsBoard for this channel. @@ -465,7 +526,7 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regions (comma-separated)", + label='Regions (comma-separated)', value=', '.join(board.regions), ).classes('w-full text-xs') @@ -474,7 +535,6 @@ class BbsPanel: value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Combine with other channels other_channels = [ c for c in self._device_channels if c.get('idx', c.get('index', 0)) != idx @@ -523,30 +583,7 @@ class BbsPanel: self._config_store.set_board(updated) debug_print(f'BBS settings (advanced): {bid} saved') ui.notify(f'{bname} saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() - - # ------------------------------------------------------------------ - # External update hook - # ------------------------------------------------------------------ - - def update(self, data: Dict) -> None: - """Called by the dashboard timer with the SharedData snapshot. - - Rebuilds the settings channel list when the device channel list - changes. - - 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_boards_settings() From 2d582b79b848cee7d1f46f7408d485fccd9199a4 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 17:25:43 +0100 Subject: [PATCH 36/39] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- meshcore_gui/gui/panels/bbs_panel.py | 145 ++++++++++-------- .../meshcore_gui/gui/panels/bbs_panel.py | 145 ++++++++++-------- 2 files changed, 170 insertions(+), 120 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index e84c733..692acf1 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -33,7 +33,7 @@ def _slug(name: str) -> str: # --------------------------------------------------------------------------- class BbsPanel: - """BBS panel: board selector, filters, message list and post form. + """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. @@ -56,19 +56,20 @@ class BbsPanel: # Active view state self._active_board: Optional[BbsBoard] = None - self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI refs -- message view + # UI refs self._board_btn_row = None - self._region_row = None - self._region_select = None - self._category_select = None - self._text_input = 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._msg_list_container = 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] = [] @@ -89,43 +90,30 @@ class BbsPanel: on_click=lambda: ui.navigate.to('/bbs-settings'), ).props('flat round dense').tooltip('BBS Settings') - self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + # 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('Board:').classes('text-sm text-gray-600') + ui.label('No active boards β€” open Settings to enable a channel.').classes( + 'text-xs text-gray-400 italic' + ) ui.separator() - with ui.row().classes('w-full items-center gap-4 flex-wrap'): - ui.label('Filter:').classes('text-sm text-gray-600') - - self._region_row = ui.row().classes('items-center gap-2') - with self._region_row: - ui.label('Region:').classes('text-xs text-gray-600') - self._region_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_region_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - with ui.row().classes('items-center gap-2'): - ui.label('Category:').classes('text-xs text-gray-600') - self._category_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_category_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - ui.button( - 'Refresh', on_click=self._refresh_messages, - ).props('flat no-caps').classes('text-xs') + # 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() - # Responsive message list: h-72 is overridden by domca-panel CSS + # Message list self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' - ) + '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') @@ -157,69 +145,106 @@ class BbsPanel: 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: - ui.label('Board:').classes('text-sm text-gray-600') 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: - ui.button( + btn = ui.button( board.name, on_click=lambda b=board: self._select_board(b), - ).props('flat no-caps').classes('text-xs') + ).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 filter selects. + """Activate a board and rebuild category buttons. Args: board: Board to activate. """ self._active_board = board - self._active_region = None self._active_category = None - has_regions = bool(board.regions) - if self._region_row: - self._region_row.set_visibility(has_regions) - if self._post_region_row: - self._post_region_row.set_visibility(has_regions) + # 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') - region_opts = ['(all)'] + board.regions - if self._region_select: - self._region_select.options = region_opts - self._region_select.value = '(all)' + # 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 - - cat_opts = ['(all)'] + board.categories - if self._category_select: - self._category_select.options = cat_opts - self._category_select.value = '(all)' 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() # ------------------------------------------------------------------ - # Filters + # Category buttons # ------------------------------------------------------------------ - def _on_region_filter(self, value: Optional[str]) -> None: - self._active_region = None if (not value or value == '(all)') else value + 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 _on_category_filter(self, value: Optional[str]) -> None: - self._active_category = None if (not value or value == '(all)') else value - 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 @@ -240,7 +265,7 @@ class BbsPanel: return messages = self._service.get_all_messages( channels=self._active_board.channels, - region=self._active_region, + region=None, category=self._active_category, ) if not messages: diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index e84c733..692acf1 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -33,7 +33,7 @@ def _slug(name: str) -> str: # --------------------------------------------------------------------------- class BbsPanel: - """BBS panel: board selector, filters, message list and post form. + """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. @@ -56,19 +56,20 @@ class BbsPanel: # Active view state self._active_board: Optional[BbsBoard] = None - self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI refs -- message view + # UI refs self._board_btn_row = None - self._region_row = None - self._region_select = None - self._category_select = None - self._text_input = 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._msg_list_container = 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] = [] @@ -89,43 +90,30 @@ class BbsPanel: on_click=lambda: ui.navigate.to('/bbs-settings'), ).props('flat round dense').tooltip('BBS Settings') - self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + # 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('Board:').classes('text-sm text-gray-600') + ui.label('No active boards β€” open Settings to enable a channel.').classes( + 'text-xs text-gray-400 italic' + ) ui.separator() - with ui.row().classes('w-full items-center gap-4 flex-wrap'): - ui.label('Filter:').classes('text-sm text-gray-600') - - self._region_row = ui.row().classes('items-center gap-2') - with self._region_row: - ui.label('Region:').classes('text-xs text-gray-600') - self._region_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_region_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - with ui.row().classes('items-center gap-2'): - ui.label('Category:').classes('text-xs text-gray-600') - self._category_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_category_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - ui.button( - 'Refresh', on_click=self._refresh_messages, - ).props('flat no-caps').classes('text-xs') + # 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() - # Responsive message list: h-72 is overridden by domca-panel CSS + # Message list self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' - ) + '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') @@ -157,69 +145,106 @@ class BbsPanel: 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: - ui.label('Board:').classes('text-sm text-gray-600') 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: - ui.button( + btn = ui.button( board.name, on_click=lambda b=board: self._select_board(b), - ).props('flat no-caps').classes('text-xs') + ).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 filter selects. + """Activate a board and rebuild category buttons. Args: board: Board to activate. """ self._active_board = board - self._active_region = None self._active_category = None - has_regions = bool(board.regions) - if self._region_row: - self._region_row.set_visibility(has_regions) - if self._post_region_row: - self._post_region_row.set_visibility(has_regions) + # 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') - region_opts = ['(all)'] + board.regions - if self._region_select: - self._region_select.options = region_opts - self._region_select.value = '(all)' + # 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 - - cat_opts = ['(all)'] + board.categories - if self._category_select: - self._category_select.options = cat_opts - self._category_select.value = '(all)' 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() # ------------------------------------------------------------------ - # Filters + # Category buttons # ------------------------------------------------------------------ - def _on_region_filter(self, value: Optional[str]) -> None: - self._active_region = None if (not value or value == '(all)') else value + 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 _on_category_filter(self, value: Optional[str]) -> None: - self._active_category = None if (not value or value == '(all)') else value - 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 @@ -240,7 +265,7 @@ class BbsPanel: return messages = self._service.get_all_messages( channels=self._active_board.channels, - region=self._active_region, + region=None, category=self._active_category, ) if not messages: From 374897448e6a05beb2d6de048954fc0df0740895 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 18:36:58 +0100 Subject: [PATCH 37/39] feat(bbs): DM-based BBS with short syntax and auto-abbreviations(#v1.14.0) Adds an offline Bulletin Board System accessible via Direct Message to the node's own key. All BBS commands (!p, !r, !bbs) are handled directly in EventHandler.on_contact_msg, independent of MeshBot. - One node = one board; settings reduced to a single channel selector - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - !r and !bbs help always include the abbreviation table in the reply - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- CHANGELOG.md | 34 +-- README.md | 96 +++++- meshcore_gui/ble/events.py | 56 +++- meshcore_gui/ble/worker.py | 10 + meshcore_gui/gui/panels/bbs_panel.py | 287 +++++++---------- meshcore_gui/services/bbs_config_store.py | 80 ++++- meshcore_gui/services/bbs_service.py | 357 ++++++++++++++++------ meshcore_gui/services/bot.py | 48 +-- 8 files changed, 625 insertions(+), 343 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df97ad..13142ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,33 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > lower CPU usage during idle operation, and more stable map rendering. --- -## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) +## [1.14.0] - 2026-03-14 β€” 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. + +- πŸ†• **BBS β€” Bulletin Board System** β€” offline berichtenbord voor mesh-netwerken. + - Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon. + - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee. + - Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`. + - Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist. + - Settings-pagina (`/bbs-settings`): één channel-selector, categorieΓ«n, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys. + - Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode). ### 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. + +- πŸ”„ **`ble/events.py`** β€” DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`. +- πŸ”„ **`services/bot.py`** β€” `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd. +- πŸ”„ **`services/bbs_config_store.py`** β€” `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd. +- πŸ”„ **`gui/dashboard.py`** β€” `BbsPanel` geregistreerd, `πŸ“‹ BBS` drawer-item toegevoegd. ### Storage ``` -~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) -~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +~/.meshcore-gui/bbs/bbs_config.json β€” board configuratie +~/.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 diff --git a/README.md b/README.md index 798de13..2361b3d 100644 --- a/README.md +++ b/README.md @@ -1184,11 +1184,105 @@ meshcore-gui/ └── README.md ``` -## 15. Roadmap +## 15. BBS β€” Bulletin Board System + +MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. + +### Design + +One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** β€” the channel stays clean and replies are private to the sender. + +``` +User ──DM──▢ BBS node (public key) + processes command +User ◀──DM── reply (only visible to sender) +``` + +Channel commands remain available as a fallback, but DM is the primary interface. + +### Settings + +Open the BBS settings via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. + +``` +BBS Settings +───────────────────────────────────────────── +Channel: [2] NoodNet Zwolle β–Ό +Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL +Retain: 48 hours +[Save] + +β–Ά Advanced + Regions (comma-separated) + Allowed keys (empty = everyone on the channel) +``` + +- **Channel** β€” select which device channel this node's board listens on. +- **Categories** β€” comma-separated list of valid category tags. +- **Retain** β€” message retention in hours (default 48). +- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. +- **Advanced β†’ Allowed keys** β€” sender public key whitelist; empty = all senders allowed. + +### Command syntax + +#### Short syntax + +| Command | Description | +|---|---| +| `!p ` | Post a message | +| `!p ` | Post with region | +| `!r` | Read 5 most recent (all categories) | +| `!r ` | Read filtered by category | +| `!r ` | Read filtered by region and category | + +Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: + +``` +U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table. + +#### Full syntax + +| Command | Description | +|---|---| +| `!bbs help` | Show commands and abbreviation table | +| `!bbs post ` | Post a message | +| `!bbs post ` | Post with region | +| `!bbs read` | Read 5 most recent | +| `!bbs read ` | Read filtered by category | +| `!bbs read ` | Read filtered by region and category | + +#### Example help reply + +``` +BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +### Error handling + +| Situation | Reply | +|---|---| +| Unknown category | Lists valid categories and abbreviations | +| Ambiguous abbreviation | Lists all matching categories | +| Sender not on whitelist | Silent drop β€” no reply | + +### Storage + +``` +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration (v2 format) +``` + +--- + +## 16. Roadmap This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include: - [x] **Cross-frequency bridge** β€” standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge)) +- [x] **BBS β€” Bulletin Board System** β€” offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system)) - [ ] **Observer mode** β€” passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging - [ ] **Room Server administration** β€” authenticate as admin to manage Room Server settings and users directly from the GUI - [ ] **Repeater management** β€” connect to repeater nodes to view status and adjust configuration diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 5700d40..35da67e 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -4,9 +4,16 @@ Device event callbacks for MeshCore GUI. Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA`` events from the MeshCore library. Extracted from ``SerialWorker`` so the worker only deals with connection lifecycle. + +BBS routing +~~~~~~~~~~~ +Direct Messages (``CONTACT_MSG_RECV``) whose text starts with ``!`` are +forwarded to :class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` +**before** any other DM processing. This path is completely independent of +:class:`~meshcore_gui.services.bot.MeshBot`. """ -from typing import Dict, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional from meshcore_gui.config import debug_print from meshcore_gui.core.models import Message, RxLogEntry @@ -15,6 +22,9 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType from meshcore_gui.services.bot import MeshBot from meshcore_gui.services.dedup import DualDeduplicator +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler + class EventHandler: """Processes device events and writes results to shared data. @@ -35,11 +45,15 @@ class EventHandler: decoder: PacketDecoder, dedup: DualDeduplicator, bot: MeshBot, + bbs_handler: Optional["BbsCommandHandler"] = None, + command_sink: Optional[Callable[[Dict], None]] = None, ) -> None: self._shared = shared self._decoder = decoder self._dedup = dedup self._bot = bot + self._bbs_handler = bbs_handler + self._command_sink = command_sink # Cache: message_hash β†’ path_hashes (from RX_LOG decode). # Used by on_channel_msg fallback to recover hashes that the @@ -409,9 +423,45 @@ class EventHandler: or (pubkey[:8] if pubkey else '') ) + dm_text = payload.get('text', '') + + # BBS routing: DMs starting with '!' go directly to BbsCommandHandler. + # This path is independent of the bot (MeshBot is for channel messages only). + if ( + self._bbs_handler is not None + and self._command_sink is not None + and dm_text.strip().startswith("!") + ): + bbs_reply = self._bbs_handler.handle_dm( + sender=sender, + sender_key=pubkey, + text=dm_text, + ) + if bbs_reply is not None: + debug_print(f"BBS DM reply to {sender} ({pubkey[:8]}): {bbs_reply[:60]}") + self._command_sink({ + "action": "send_dm", + "pubkey": pubkey, + "text": bbs_reply, + }) + # Always store the incoming DM in the message archive too + self._shared.add_message(Message.incoming( + sender, + dm_text, + None, + snr=self._extract_snr(payload), + path_len=path_len, + sender_pubkey=pubkey, + path_hashes=path_hashes, + path_names=path_names, + message_hash=msg_hash, + )) + debug_print(f"BBS DM stored from {sender}: {dm_text[:30]}") + return + self._shared.add_message(Message.incoming( sender, - payload.get('text', ''), + dm_text, None, snr=self._extract_snr(payload), path_len=path_len, @@ -420,7 +470,7 @@ class EventHandler: path_names=path_names, message_hash=msg_hash, )) - debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") + debug_print(f"DM received from {sender}: {dm_text[:30]}") # ------------------------------------------------------------------ # Helpers diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 484ff0a..4b45090 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -54,6 +54,8 @@ from meshcore_gui.ble.commands import CommandHandler from meshcore_gui.ble.events import EventHandler from meshcore_gui.ble.packet_decoder import PacketDecoder from meshcore_gui.services.bot import BotConfig, MeshBot +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService +from meshcore_gui.services.bbs_config_store import BbsConfigStore from meshcore_gui.services.cache import DeviceCache from meshcore_gui.services.dedup import DualDeduplicator from meshcore_gui.services.device_identity import write_device_identity @@ -124,6 +126,12 @@ class _BaseWorker(abc.ABC): enabled_check=shared.is_bot_enabled, ) + # BBS handler β€” wired directly into EventHandler for DM routing. + # Independent of the bot; uses a shared config store and service. + _bbs_config = BbsConfigStore() + _bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler(service=_bbs_service, config_store=_bbs_config) + # Channel indices that still need keys from device self._pending_keys: Set[int] = set() @@ -244,6 +252,8 @@ class _BaseWorker(abc.ABC): decoder=self._decoder, dedup=self._dedup, bot=self._bot, + bbs_handler=self._bbs_handler, + command_sink=self.shared.put_command, ) self._cmd_handler = CommandHandler( mc=self.mc, shared=self.shared, cache=self._cache, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 692acf1..2e7ee6b 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -368,8 +368,10 @@ class BbsPanel: class BbsSettingsPage: """Standalone BBS settings page, registered at /bbs-settings. - Follows the same pattern as RoutePage: one instance, render() called - per page load. + One node = one board. The page shows a single channel selector + populated from the active device channels, plus a categories field, + a retention field, and a collapsible Advanced section for regions + and allowed keys. There is no board creation or deletion UI. Args: shared: SharedData instance (for device channel list). @@ -384,7 +386,7 @@ class BbsSettingsPage: self._shared = shared self._config_store = config_store self._device_channels: List[Dict] = [] - self._boards_settings_container = None + self._container = None def render(self) -> None: """Render the BBS settings page.""" @@ -411,204 +413,125 @@ class BbsSettingsPage: 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: + self._container = ui.column().classes('w-full gap-3') + with self._container: if not self._device_channels: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) else: - self._render_all() + self._render_settings() # ------------------------------------------------------------------ # 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) + def _render_settings(self) -> None: + """Render the single-board settings block.""" + board = self._config_store.get_single_board() - ui.separator() + # Build channel options: {idx: "[idx] name"} + ch_options = { + ch.get('idx', ch.get('index', 0)): + f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}" + for ch in self._device_channels + } - 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' + current_idx = ( + board.channels[0] if board and board.channels + else next(iter(ch_options), 0) + ) + cats_value = ( + ', '.join(board.categories) if board + else ', '.join(DEFAULT_CATEGORIES) + ) + retention_value = ( + str(board.retention_hours) if board + else str(DEFAULT_RETENTION_HOURS) + ) + adv_regions_value = ', '.join(board.regions) if board else '' + adv_keys_value = ', '.join(board.allowed_keys) if board else '' + + # ── Main block ─────────────────────────────────────────────── + with ui.column().classes('w-full gap-2'): + with ui.row().classes('w-full items-center gap-2'): + ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0') + ch_select = ui.select( + options=ch_options, + value=current_idx, + ).classes('text-xs flex-grow') + + with ui.row().classes('w-full items-center gap-2'): + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + with ui.row().classes('w-full items-center gap-2'): + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input( + value=retention_value, + ).classes('text-xs').style('max-width: 80px') + ui.label('hours').classes('text-xs text-gray-600') + + # ── Advanced (collapsed) ───────────────────────────────────── + with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'): + ui.label('Regions and allowed keys').classes('text-xs text-gray-500 pb-1') + + regions_input = ui.input( + label='Regions (comma-separated)', + value=adv_regions_value, + ).classes('w-full text-xs') + + keys_input = ui.input( + label='Allowed keys (empty = everyone on the channel)', + value=adv_keys_value, + ).classes('w-full text-xs') + + # ── Save ───────────────────────────────────────────────────── + def _save( + cs=ch_select, + ci=cats_input, + ri=retention_input, + rgi=regions_input, + ki=keys_input, + ) -> None: + idx = cs.value + ch_name = ch_options.get(idx, f'Ch {idx}') + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + try: + ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) + except ValueError: + ret_hours = DEFAULT_RETENTION_HOURS + regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()] + allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + + self._config_store.set_single_board( + channel_idx=idx, + channel_name=ch_name, + categories=categories, + retention_hours=ret_hours, + regions=regions, + allowed_keys=allowed_keys, ) - 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') + debug_print(f'BBS settings: saved ch{idx} {ch_name}') + ui.notify(f'BBS saved β€” {ch_name}.', type='positive') + self._rebuild() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2') def _rebuild(self) -> None: """Clear and re-render the settings container in-place.""" - if not self._boards_settings_container: + if not self._container: return - self._boards_settings_container.clear() - with self._boards_settings_container: + data = self._shared.get_snapshot() + self._device_channels = data.get('channels', []) + self._container.clear() + with self._container: if not self._device_channels: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) else: - self._render_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() + self._render_settings() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c05727b..c8c925d 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -4,12 +4,15 @@ 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: +Design (v1.14.0 redesign) +~~~~~~~~~~~~~~~~~~~~~~~~~ +One node = one board. The settings UI exposes a single channel selector; +the board id is always ``ch{channel_idx}`` and the name is taken from the +device channel. There is no Create/Delete UI β€” the board is saved or +cleared through :meth:`set_single_board` / :meth:`clear_single_board`. -- One board per channel (classic per-channel BBS) -- One board spanning multiple channels (shared bulletin board) +Multiple-board storage is retained internally so that the storage layer +(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged. Config version history ~~~~~~~~~~~~~~~~~~~~~~ @@ -300,3 +303,70 @@ class BbsConfigStore: """ with self._lock: return any(b.id == board_id for b in self._boards) + + # ------------------------------------------------------------------ + # Single-board convenience API (v1.14.0 redesign) + # ------------------------------------------------------------------ + + def get_single_board(self) -> Optional[BbsBoard]: + """Return the one configured board, or ``None`` if none exists. + + This is the primary accessor for the simplified single-board UI. + + Returns: + The first ``BbsBoard`` in the store, or ``None``. + """ + with self._lock: + if self._boards: + return BbsBoard.from_dict(self._boards[0].to_dict()) + return None + + def set_single_board( + self, + channel_idx: int, + channel_name: str, + categories: List[str], + retention_hours: int = DEFAULT_RETENTION_HOURS, + regions: Optional[List[str]] = None, + allowed_keys: Optional[List[str]] = None, + ) -> None: + """Replace the single board with a fresh config derived from one channel. + + The board id is always ``ch{channel_idx}`` and the board name is + taken from *channel_name*. Any previously stored boards are + discarded so the store always holds at most one board. + + Args: + channel_idx: MeshCore channel index to assign to this board. + channel_name: Human-readable name of the channel (display only). + categories: Category tag list. + retention_hours: Message retention period in hours. + regions: Optional region tags (``None`` β†’ empty list). + allowed_keys: Sender public key whitelist (``None`` β†’ all allowed). + """ + board = BbsBoard( + id=f"ch{channel_idx}", + name=channel_name, + channels=[channel_idx], + categories=list(categories), + regions=list(regions) if regions else [], + retention_hours=retention_hours, + allowed_keys=list(allowed_keys) if allowed_keys else [], + ) + with self._lock: + self._boards = [board] + self._save_unlocked() + debug_print( + f"BBS config: single board set β†’ ch{channel_idx} '{channel_name}'" + ) + + def clear_single_board(self) -> None: + """Remove the configured board (disable BBS on this node). + + After this call :meth:`get_single_board` returns ``None`` and + the BBS command handler will not respond to any channel. + """ + with self._lock: + self._boards = [] + self._save_unlocked() + debug_print("BBS config: single board cleared") diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 52b7968..0432eef 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -281,11 +281,34 @@ class BbsService: # --------------------------------------------------------------------------- class BbsCommandHandler: - """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + """Parses BBS commands arriving as DMs 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. + Entry point + ~~~~~~~~~~~ + All BBS commands arrive as **Direct Messages** addressed to the node's + own public key. :meth:`handle_dm` is the sole public entry point and is + called directly from + :class:`~meshcore_gui.ble.events.EventHandler.on_contact_msg`. + It is completely independent of :class:`~meshcore_gui.services.bot.MeshBot`. + + Command syntax + ~~~~~~~~~~~~~~ + Both styles are accepted: + + Short syntax:: + + !p [region] β€” post a message + !r [region] [abbrev] β€” read (5 most recent) + + Full syntax:: + + !bbs post [region] + !bbs read [region] [category] + !bbs help + + Category abbreviations are computed automatically as the shortest unique + prefix per category within the configured list. ``!r`` and ``!bbs help`` + always include the abbreviation table in the reply. Args: service: Shared ``BbsService`` instance. @@ -299,99 +322,179 @@ class BbsCommandHandler: self._config_store = config_store # ------------------------------------------------------------------ - # Public entry point + # Public entry point β€” called from EventHandler.on_contact_msg # ------------------------------------------------------------------ - def handle( + def handle_dm( self, - channel_idx: int, sender: str, sender_key: str, text: str, ) -> Optional[str]: - """Parse an incoming message and return a reply string (or ``None``). + """Parse a DM addressed to this node and return a reply (or ``None``). + + This is the **only** entry point for BBS commands. It is called + directly by ``EventHandler.on_contact_msg`` when a DM arrives whose + text starts with ``!``. The bot is never involved. + + The board is looked up from the single configured board via + ``BbsConfigStore.get_single_board()``. Args: - 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. + sender: Display name of the DM sender. + sender_key: Public key of the sender (hex string). + text: Raw DM text. Returns: - Reply string, or ``None`` if no reply should be sent. + Reply string to send back as DM, or ``None`` for silent drop. """ text = (text or "").strip() - if not text.lower().startswith("!bbs"): + first = text.split()[0].lower() if text else "" + if not first.startswith("!"): return None - board = self._config_store.get_board_for_channel(channel_idx) + board = self._config_store.get_single_board() if board is None: + debug_print("BBS: no board configured, ignoring DM") return None # Whitelist check if board.allowed_keys and sender_key not in board.allowed_keys: debug_print( - f"BBS: silently dropping msg from {sender} " + f"BBS: silently dropping DM 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) + # Channel for storing posted messages + channel_idx = board.channels[0] if board.channels else 0 + + # Route by command prefix + if first in ("!p",): + rest = text[len(first):].strip() + return self._handle_post_short(board, channel_idx, sender, sender_key, rest) + + if first in ("!r",): + rest = text[len(first):].strip() + return self._handle_read_short(board, rest) + + if first == "!bbs": + parts = text.split(None, 2) + sub = parts[1].lower() if len(parts) > 1 else "" + rest = parts[2] if len(parts) > 2 else "" + if sub == "post": + return self._handle_post(board, channel_idx, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown subcommand '{sub}'. " + self._handle_help(board) + + # Unknown !-command starting with something else + return None # ------------------------------------------------------------------ - # Dispatch + # Abbreviation helpers # ------------------------------------------------------------------ - 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)}" + @staticmethod + def compute_abbreviations(categories: List[str]) -> Dict[str, str]: + """Compute shortest unique prefix for each category. + + Returns a dict mapping ``abbrev.upper()`` β†’ ``category``. + + Examples:: + + ["URGENT", "MEDICAL", "LOGISTICS", "STATUS", "GENERAL"] + β†’ {"U": "URGENT", "M": "MEDICAL", "L": "LOGISTICS", + "S": "STATUS", "G": "GENERAL"} + + ["MEDICAL", "MISSING"] + β†’ {"ME": "MEDICAL", "MI": "MISSING"} + """ + abbrevs: Dict[str, str] = {} + cats_upper = [c.upper() for c in categories] + for cat in cats_upper: + for length in range(1, len(cat) + 1): + prefix = cat[:length] + # Unique if no other category starts with this prefix + if sum(1 for c in cats_upper if c.startswith(prefix)) == 1: + abbrevs[prefix] = cat + break + return abbrevs + + def _abbrev_table(self, categories: List[str]) -> str: + """Return a compact abbreviation table string, e.g. ``U=URGENT M=MEDICAL``.""" + abbrevs = self.compute_abbreviations(categories) + # abbrevs maps prefix β†’ full name; invert for display + inv = {v: k for k, v in abbrevs.items()} + return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv) + + def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]: + """Resolve *token* to a category via exact match or abbreviation. + + Returns the matching category string (original case from board + config), or ``None`` if unresolvable. + """ + token_up = token.upper() + cats_upper = [c.upper() for c in categories] + + # Exact match first + if token_up in cats_upper: + return categories[cats_upper.index(token_up)] + + # Abbreviation match + abbrevs = self.compute_abbreviations(categories) + if token_up in abbrevs: + matched = abbrevs[token_up] + return categories[cats_upper.index(matched)] + + return None + + def _resolve_region(self, token: str, regions: List[str]) -> Optional[str]: + """Resolve *token* to a region via exact (case-insensitive) match.""" + token_up = token.upper() + regs_upper = [r.upper() for r in regions] + if token_up in regs_upper: + return regions[regs_upper.index(token_up)] + return None # ------------------------------------------------------------------ - # post + # Short syntax β€” !p and !r # ------------------------------------------------------------------ - def _handle_post(self, board, channel_idx, sender, sender_key, args): + def _handle_post_short(self, board, channel_idx, sender, sender_key, args): + """Handle ``!p [region] ``.""" 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())] + region = "" + if regions and tokens: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] # consume region token + + # Now tokens should be [abbrev, text] + if len(tokens) < 2: + abbr = self._abbrev_table(categories) + return ( + f"Usage: !p [region] | {abbr}" + ) + + cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else "" + # Rebuild text in case split(None,2) on a shorter string + if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3): + # re-split with region consumed + pass + + category = self._resolve_category(cat_token, categories) + if category is None: + abbr = self._abbrev_table(categories) + return ( + f"Unknown category '{cat_token}'. Valid: {abbr}" + ) msg = BbsMessage( channel=channel_idx, @@ -402,44 +505,107 @@ class BbsCommandHandler: region_label = f" [{region}]" if region else "" return f"Posted [{category}]{region_label}: {text[:60]}" - # ------------------------------------------------------------------ - # read - # ------------------------------------------------------------------ + def _handle_read_short(self, board, args): + """Handle ``!r [region] [abbrev]``. - def _handle_read(self, board, args): + With no arguments returns 5 most recent messages across all + categories and always includes the abbreviation table. + """ regions = board.regions categories = board.categories tokens = args.split() if args else [] + region = None category = None - if 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)}" + if tokens and regions: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + if tokens: + category = self._resolve_category(tokens[0], categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{tokens[0]}'. Valid: {abbr}" + + return self._format_messages(board, region, category, include_abbrevs=not args) + + # ------------------------------------------------------------------ + # Full syntax β€” !bbs post / read + # ------------------------------------------------------------------ + + def _handle_post(self, board, channel_idx, sender, sender_key, args): + """Handle ``!bbs post [region] ``.""" + regions = board.regions + categories = board.categories + tokens = args.split(None, 2) if args else [] + + region = "" + if regions and tokens: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + + if len(tokens) < 2: + abbr = self._abbrev_table(categories) + region_hint = f" [region]" if regions else "" + return f"Usage: !bbs post{region_hint} | {abbr}" + + cat_token, text = tokens[0], tokens[1] + category = self._resolve_category(cat_token, categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{cat_token}'. Valid: {abbr}" + + msg = BbsMessage( + channel=channel_idx, + region=region, category=category, + sender=sender, sender_key=sender_key, text=text, + ) + self._service.post_message(msg) + region_label = f" [{region}]" if region else "" + return f"Posted [{category}]{region_label}: {text[:60]}" + + def _handle_read(self, board, args): + """Handle ``!bbs read [region] [category]``.""" + regions = board.regions + categories = board.categories + tokens = args.split() if args else [] + + region = None + category = None + + if tokens and regions: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + + if tokens: + category = self._resolve_category(tokens[0], categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{tokens[0]}'. Valid: {abbr}" + + return self._format_messages(board, region, category, include_abbrevs=False) + + # ------------------------------------------------------------------ + # Shared message formatter + # ------------------------------------------------------------------ + + def _format_messages(self, board, region, category, include_abbrevs: bool) -> str: messages = self._service.get_messages( board.channels, region=region, category=category, limit=self.READ_LIMIT, ) - if not messages: - return "BBS: no messages found." lines = [] + if include_abbrevs: + lines.append(self._handle_help(board)) + if not messages: + lines.append("BBS: no messages found.") + return "\n".join(lines) for m in messages: ts = m.timestamp[:16].replace("T", " ") region_label = f"[{m.region}] " if m.region else "" @@ -447,22 +613,13 @@ class BbsCommandHandler: return "\n".join(lines) # ------------------------------------------------------------------ - # help + # Help # ------------------------------------------------------------------ def _handle_help(self, board) -> str: - cats = ", ".join(board.categories) + abbr = self._abbrev_table(board.categories) + header = f"BBS [{board.name}] | !p [cat] [text] | !r [cat]" if board.regions: regs = ", ".join(board.regions) - return ( - f"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}" - ) + return f"{header} | Regions: {regs} | {abbr}" + return f"{header} | {abbr}" diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 9279a72..d31a2c2 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -11,20 +11,19 @@ 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. +BBS separation +~~~~~~~~~~~~~~ +BBS commands (``!bbs``, ``!p``, ``!r``) are handled by +:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` which is +wired directly into +:class:`~meshcore_gui.ble.events.EventHandler`. They never pass +through ``MeshBot`` β€” the bot is a pure keyword/channel-message +responder only. """ import time 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 typing import Callable, Dict, List, Optional from meshcore_gui.config import debug_print @@ -91,13 +90,11 @@ class MeshBot: 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, @@ -108,7 +105,7 @@ class MeshBot: path_len: int, path_hashes: Optional[List[str]] = None, ) -> None: - """Evaluate an incoming message and queue a reply if appropriate. + """Evaluate an incoming channel message and queue a reply if appropriate. Guards (in order): 1. Bot is enabled (checkbox in GUI). @@ -117,6 +114,10 @@ class MeshBot: 4. Sender name does not end with ``'Bot'`` (prevent loops). 5. Cooldown period has elapsed. 6. Message text contains a recognised keyword. + + Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here. + They arrive as DMs and are handled by ``BbsCommandHandler`` directly + inside ``EventHandler.on_contact_msg``. """ # Guard 1: enabled? if not self._enabled(): @@ -141,27 +142,6 @@ class MeshBot: 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: From d9ad4c83b8b1a7a2cfa7bbbf7813e9f29ee4ba27 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:01:07 +0100 Subject: [PATCH 38/39] feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0) Adds an offline BBS accessible via Direct Message to the node's own key. Access is channel-based: anyone seen on a configured BBS channel is automatically whitelisted for DM access. Channels stay clean. - Multi-channel configuration: any combination of device channels can be selected; senders on any of them are auto-whitelisted - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - handle_channel_msg: bootstrap reply on channel + auto-whitelist sender - handle_dm: DM entry point, checks whitelist, routes to post/read/help - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- CHANGELOG.md | 12 +-- README.md | 87 +++++++++++----------- meshcore_gui/ble/events.py | 17 +++++ meshcore_gui/gui/panels/bbs_panel.py | 86 +++++++++++---------- meshcore_gui/services/bbs_config_store.py | 91 ++++++++++++++++------- meshcore_gui/services/bbs_service.py | 71 +++++++++++++++++- 6 files changed, 247 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13142ee..68b92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,18 +35,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ### Added - πŸ†• **BBS β€” Bulletin Board System** β€” offline berichtenbord voor mesh-netwerken. - - Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon. - - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee. + - **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel. + - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). - Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`. - - Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist. - - Settings-pagina (`/bbs-settings`): één channel-selector, categorieΓ«n, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys. + - Optioneel regio-filter en handmatige allowed-keys override in Advanced. + - Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieΓ«n, retentie, Advanced voor regio's en handmatige keys. - Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode). ### Changed -- πŸ”„ **`ble/events.py`** β€” DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`. +- πŸ”„ **`ble/events.py`** β€” `on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden volledig los van `MeshBot`. - πŸ”„ **`services/bot.py`** β€” `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd. -- πŸ”„ **`services/bbs_config_store.py`** β€” `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd. +- πŸ”„ **`services/bbs_config_store.py`** β€” `configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`. - πŸ”„ **`gui/dashboard.py`** β€” `BbsPanel` geregistreerd, `πŸ“‹ BBS` drawer-item toegevoegd. ### Storage diff --git a/README.md b/README.md index 2361b3d..1b056d0 100644 --- a/README.md +++ b/README.md @@ -1188,91 +1188,94 @@ meshcore-gui/ MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. -### Design +### Toegangsmodel -One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** β€” the channel stays clean and replies are private to the sender. +De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node β€” het channel zelf blijft schoon. ``` -User ──DM──▢ BBS node (public key) - processes command -User ◀──DM── reply (only visible to sender) +Eerste contact: !bbs help op het channel + β†’ node ziet de public key β†’ whitelist +Daarna: !p U hulp nodig als DM naar de node + β†’ verwerkt, reply via DM terug ``` -Channel commands remain available as a fallback, but DM is the primary interface. +Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd. ### Settings -Open the BBS settings via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. +Open via het tandwiel (βš™) in het BBS-panel, of navigeer naar `/bbs-settings`. ``` BBS Settings -───────────────────────────────────────────── -Channel: [2] NoodNet Zwolle β–Ό +────────────────────────────────────────── +Channels: β˜‘ [1] NoodNet Zwolle + β˜‘ [2] NoodNet Dalfsen + ☐ [3] NoodNet OV Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL Retain: 48 hours [Save] β–Ά Advanced Regions (comma-separated) - Allowed keys (empty = everyone on the channel) + Allowed keys (leeg = auto-geleerd via channel-activiteit) ``` -- **Channel** β€” select which device channel this node's board listens on. -- **Categories** β€” comma-separated list of valid category tags. -- **Retain** β€” message retention in hours (default 48). -- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. -- **Advanced β†’ Allowed keys** β€” sender public key whitelist; empty = all senders allowed. +- **Channels** β€” vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS. +- **Categories** β€” komma-gescheiden lijst van geldige categorie-tags. +- **Retain** β€” berichtretentie in uren (standaard 48). +- **Advanced β†’ Regions** β€” optionele regio-tags voor geografische filtering. +- **Advanced β†’ Allowed keys** β€” handmatige whitelist-override; leeg laten om alleen automatisch te leren. -### Command syntax +### Commando-syntax -#### Short syntax +#### Korte syntax -| Command | Description | +| Commando | Beschrijving | |---|---| -| `!p ` | Post a message | -| `!p ` | Post with region | -| `!r` | Read 5 most recent (all categories) | -| `!r ` | Read filtered by category | -| `!r ` | Read filtered by region and category | +| `!p ` | Bericht posten | +| `!p ` | Posten met regio | +| `!r` | Laatste 5 berichten lezen (alle categorieΓ«n) | +| `!r ` | Lezen gefilterd op categorie | +| `!r ` | Lezen gefilterd op regio en categorie | -Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: +Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: ``` U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table. +`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee. -#### Full syntax +#### Volledige syntax -| Command | Description | +| Commando | Beschrijving | |---|---| -| `!bbs help` | Show commands and abbreviation table | -| `!bbs post ` | Post a message | -| `!bbs post ` | Post with region | -| `!bbs read` | Read 5 most recent | -| `!bbs read ` | Read filtered by category | -| `!bbs read ` | Read filtered by region and category | +| `!bbs help` | Toon commando's en afkortingstabel | +| `!bbs post ` | Bericht posten | +| `!bbs post ` | Posten met regio | +| `!bbs read` | Laatste 5 berichten | +| `!bbs read ` | Gefilterd op categorie | +| `!bbs read ` | Gefilterd op regio en categorie | -#### Example help reply +#### Voorbeeld help-reply ``` -BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -### Error handling +### Foutafhandeling -| Situation | Reply | +| Situatie | Reply | |---|---| -| Unknown category | Lists valid categories and abbreviations | -| Ambiguous abbreviation | Lists all matching categories | -| Sender not on whitelist | Silent drop β€” no reply | +| Onbekende categorie | Lijst met geldige categorieΓ«n en afkortingen | +| Ambigue afkorting | Lijst met overeenkomende categorieΓ«n | +| Sender niet op whitelist | Silent drop β€” geen reply | ### Storage ``` -~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) -~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration (v2 format) +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag (WAL-mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board-configuratie ``` --- diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 35da67e..361836c 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -301,6 +301,23 @@ class EventHandler: message_hash=msg_hash, )) + # BBS channel hook: auto-whitelist sender + bootstrap reply for !-commands. + # Runs on every message on a configured BBS channel, independent of the bot. + if self._bbs_handler is not None and self._command_sink is not None: + bbs_reply = self._bbs_handler.handle_channel_msg( + channel_idx=ch_idx, + sender=sender, + sender_key=sender_pubkey, + text=msg_text, + ) + if bbs_reply is not None: + debug_print(f"BBS channel reply on ch{ch_idx} to {sender!r}: {bbs_reply[:60]}") + self._command_sink({ + "action": "send_message", + "channel": ch_idx, + "text": bbs_reply, + }) + self._bot.check_and_reply( sender=sender, text=msg_text, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 2e7ee6b..5abb6b9 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -427,20 +427,9 @@ class BbsSettingsPage: # ------------------------------------------------------------------ def _render_settings(self) -> None: - """Render the single-board settings block.""" + """Render the board settings block.""" board = self._config_store.get_single_board() - - # Build channel options: {idx: "[idx] name"} - ch_options = { - ch.get('idx', ch.get('index', 0)): - f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}" - for ch in self._device_channels - } - - current_idx = ( - board.channels[0] if board and board.channels - else next(iter(ch_options), 0) - ) + active_channels = set(board.channels) if board else set() cats_value = ( ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) @@ -452,25 +441,31 @@ class BbsSettingsPage: adv_regions_value = ', '.join(board.regions) if board else '' adv_keys_value = ', '.join(board.allowed_keys) if board else '' - # ── Main block ─────────────────────────────────────────────── - with ui.column().classes('w-full gap-2'): - with ui.row().classes('w-full items-center gap-2'): - ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0') - ch_select = ui.select( - options=ch_options, - value=current_idx, - ).classes('text-xs flex-grow') + # ── Channel checkboxes ─────────────────────────────────────── + ch_checks: Dict[int, object] = {} + with ui.column().classes('w-full gap-1'): + ui.label('Channels:').classes('text-xs text-gray-600') + with ui.column().classes('w-full gap-1 pl-2'): + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {name}', + value=idx in active_channels, + ).classes('text-xs') + ch_checks[idx] = cb - with ui.row().classes('w-full items-center gap-2'): - 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') + # ── Categories + retention ─────────────────────────────────── + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - with ui.row().classes('w-full items-center gap-2'): - ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') - retention_input = ui.input( - value=retention_value, - ).classes('text-xs').style('max-width: 80px') - ui.label('hours').classes('text-xs text-gray-600') + with ui.row().classes('w-full items-center gap-2'): + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input( + value=retention_value, + ).classes('text-xs').style('max-width: 80px') + ui.label('hours').classes('text-xs text-gray-600') # ── Advanced (collapsed) ───────────────────────────────────── with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'): @@ -482,20 +477,27 @@ class BbsSettingsPage: ).classes('w-full text-xs') keys_input = ui.input( - label='Allowed keys (empty = everyone on the channel)', + label='Allowed keys (empty = auto-learned from channel activity)', value=adv_keys_value, ).classes('w-full text-xs') # ── Save ───────────────────────────────────────────────────── def _save( - cs=ch_select, + cc=ch_checks, ci=cats_input, ri=retention_input, rgi=regions_input, ki=keys_input, ) -> None: - idx = cs.value - ch_name = ch_options.get(idx, f'Ch {idx}') + selected = [idx for idx, cb in cc.items() if cb.value] + if not selected: + ui.notify('Select at least one channel.', type='warning') + return + + ch_names = { + ch.get('idx', ch.get('index', 0)): ch.get('name', '?') + for ch in self._device_channels + } categories = [ c.strip().upper() for c in (ci.value or '').split(',') if c.strip() @@ -505,18 +507,22 @@ class BbsSettingsPage: except ValueError: ret_hours = DEFAULT_RETENTION_HOURS regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()] - allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + # Only pass allowed_keys if the field was explicitly filled; + # empty field means "keep auto-learned keys" + raw_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + allowed_keys = raw_keys if raw_keys else None - self._config_store.set_single_board( - channel_idx=idx, - channel_name=ch_name, + self._config_store.configure_board( + channel_indices=selected, + channel_names=ch_names, categories=categories, retention_hours=ret_hours, regions=regions, allowed_keys=allowed_keys, ) - debug_print(f'BBS settings: saved ch{idx} {ch_name}') - ui.notify(f'BBS saved β€” {ch_name}.', type='positive') + ch_labels = ', '.join(f"[{i}] {ch_names.get(i, '?')}" for i in sorted(selected)) + debug_print(f'BBS settings: configured channels {ch_labels}') + ui.notify(f'BBS saved β€” {ch_labels}.', type='positive') self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2') diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c8c925d..2eb5a35 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -9,7 +9,7 @@ Design (v1.14.0 redesign) One node = one board. The settings UI exposes a single channel selector; the board id is always ``ch{channel_idx}`` and the name is taken from the device channel. There is no Create/Delete UI β€” the board is saved or -cleared through :meth:`set_single_board` / :meth:`clear_single_board`. +cleared through :meth:`configure_board` / :meth:`clear_board`. Multiple-board storage is retained internally so that the storage layer (``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged. @@ -305,13 +305,11 @@ class BbsConfigStore: return any(b.id == board_id for b in self._boards) # ------------------------------------------------------------------ - # Single-board convenience API (v1.14.0 redesign) + # Board API (v1.14.0 redesign) # ------------------------------------------------------------------ def get_single_board(self) -> Optional[BbsBoard]: - """Return the one configured board, or ``None`` if none exists. - - This is the primary accessor for the simplified single-board UI. + """Return the configured board, or ``None`` if none exists. Returns: The first ``BbsBoard`` in the store, or ``None``. @@ -321,52 +319,89 @@ class BbsConfigStore: return BbsBoard.from_dict(self._boards[0].to_dict()) return None - def set_single_board( + def configure_board( self, - channel_idx: int, - channel_name: str, + channel_indices: List[int], + channel_names: Dict[int, str], categories: List[str], retention_hours: int = DEFAULT_RETENTION_HOURS, regions: Optional[List[str]] = None, allowed_keys: Optional[List[str]] = None, ) -> None: - """Replace the single board with a fresh config derived from one channel. + """Save the board configuration. - The board id is always ``ch{channel_idx}`` and the board name is - taken from *channel_name*. Any previously stored boards are - discarded so the store always holds at most one board. + Multiple channels can be assigned. Every sender seen on any of + these channels is automatically eligible for DM access (the + worker calls :meth:`add_allowed_key` when it sees them). + + The board id is always ``'bbs_board'``. The board name is built + from the channel names in *channel_names*. Args: - channel_idx: MeshCore channel index to assign to this board. - channel_name: Human-readable name of the channel (display only). + channel_indices: MeshCore channel indices to assign. + channel_names: Mapping ``idx β†’ display name`` for labelling. categories: Category tag list. retention_hours: Message retention period in hours. - regions: Optional region tags (``None`` β†’ empty list). - allowed_keys: Sender public key whitelist (``None`` β†’ all allowed). + regions: Optional region tags. + allowed_keys: Manual sender key whitelist seed (auto-learned + keys are added via :meth:`add_allowed_key`). """ + name = ", ".join( + channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices) + ) or "BBS" + + # Preserve existing auto-learned keys unless caller supplies a new list + existing = self.get_single_board() + merged_keys = list(allowed_keys) if allowed_keys is not None else ( + existing.allowed_keys if existing else [] + ) + board = BbsBoard( - id=f"ch{channel_idx}", - name=channel_name, - channels=[channel_idx], + id="bbs_board", + name=name, + channels=sorted(channel_indices), categories=list(categories), regions=list(regions) if regions else [], retention_hours=retention_hours, - allowed_keys=list(allowed_keys) if allowed_keys else [], + allowed_keys=merged_keys, ) with self._lock: self._boards = [board] self._save_unlocked() debug_print( - f"BBS config: single board set β†’ ch{channel_idx} '{channel_name}'" + f"BBS config: board configured β†’ channels={sorted(channel_indices)} " + f"name='{name}'" ) - def clear_single_board(self) -> None: - """Remove the configured board (disable BBS on this node). - - After this call :meth:`get_single_board` returns ``None`` and - the BBS command handler will not respond to any channel. - """ + def clear_board(self) -> None: + """Remove the configured board (disable BBS on this node).""" with self._lock: self._boards = [] self._save_unlocked() - debug_print("BBS config: single board cleared") + debug_print("BBS config: board cleared") + + def add_allowed_key(self, sender_key: str) -> bool: + """Add *sender_key* to the board's allowed_keys whitelist. + + Called automatically by the worker whenever a sender is seen on + a configured BBS channel. No-op if the key is already present + or if no board is configured. + + Args: + sender_key: Public key hex string of the sender. + + Returns: + ``True`` if the key was newly added, ``False`` otherwise. + """ + if not sender_key: + return False + with self._lock: + if not self._boards: + return False + board = self._boards[0] + if sender_key in board.allowed_keys: + return False + board.allowed_keys.append(sender_key) + self._save_unlocked() + debug_print(f"BBS config: auto-whitelisted key {sender_key[:12]}…") + return True diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 0432eef..e73ff18 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -322,7 +322,76 @@ class BbsCommandHandler: self._config_store = config_store # ------------------------------------------------------------------ - # Public entry point β€” called from EventHandler.on_contact_msg + # Public entry points + # ------------------------------------------------------------------ + + def handle_channel_msg( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Handle a channel message on a configured BBS channel. + + Called from ``EventHandler.on_channel_msg`` **after** the message + has been stored. Two responsibilities: + + 1. **Auto-whitelist**: every sender seen on a BBS channel gets their + key added to ``allowed_keys`` so they can use DMs afterwards. + 2. **Bootstrap reply**: if the message starts with ``!``, reply on + the channel so the sender knows the BBS is active and receives + the abbreviation table. + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string to post on the channel, or ``None``. + """ + board = self._config_store.get_single_board() + if board is None: + return None + if channel_idx not in board.channels: + return None + + # Auto-whitelist: register this sender so they can use DMs + if sender_key: + self._config_store.add_allowed_key(sender_key) + + # Bootstrap reply only for !-commands + text = (text or "").strip() + if not text.startswith("!"): + return None + + first = text.split()[0].lower() + channel_for_post = channel_idx + + if first == "!p": + rest = text[len(first):].strip() + return self._handle_post_short(board, channel_for_post, sender, sender_key, rest) + + if first == "!r": + rest = text[len(first):].strip() + return self._handle_read_short(board, rest) + + if first == "!bbs": + parts = text.split(None, 2) + sub = parts[1].lower() if len(parts) > 1 else "" + rest = parts[2] if len(parts) > 2 else "" + if sub == "post": + return self._handle_post(board, channel_for_post, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown subcommand '{sub}'. " + self._handle_help(board) + + return None + # ------------------------------------------------------------------ def handle_dm( From e2a911838bfa687457bb89ba5d4984dd398e87a5 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:38:32 +0100 Subject: [PATCH 39/39] feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0) Adds an offline BBS accessible via Direct Message to the node's own key. Access is channel-based: anyone seen on a configured BBS channel is automatically whitelisted for DM access. Channels stay clean. - Multi-channel configuration: any combination of device channels can be selected; senders on any of them are auto-whitelisted - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - handle_channel_msg: bootstrap reply on channel + auto-whitelist sender - handle_dm: DM entry point, checks whitelist, routes to post/read/help - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- README.md | 80 ++++++++++++++-------------- meshcore_gui/services/bbs_service.py | 53 +++++++++--------- 2 files changed, 69 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 1b056d0..8e72d38 100644 --- a/README.md +++ b/README.md @@ -1188,22 +1188,22 @@ meshcore-gui/ MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. -### Toegangsmodel +### Access model -De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node β€” het channel zelf blijft schoon. +The operator links one or more channels to the BBS. Anyone who sends a message on a configured BBS channel is automatically added to the whitelist. After that, they can send commands via **Direct Message** to the BBS node β€” the channel itself stays clean. ``` -Eerste contact: !bbs help op het channel - β†’ node ziet de public key β†’ whitelist -Daarna: !p U hulp nodig als DM naar de node - β†’ verwerkt, reply via DM terug +First contact: !bbs help on the configured channel + β†’ node sees the public key β†’ whitelists it +After that: !p U need assistance as DM to the node + β†’ processed, reply sent back via DM ``` -Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd. +Anyone who has never sent a message on a configured channel is not on the whitelist and is silently ignored. ### Settings -Open via het tandwiel (βš™) in het BBS-panel, of navigeer naar `/bbs-settings`. +Open via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. ``` BBS Settings @@ -1217,65 +1217,65 @@ Retain: 48 hours β–Ά Advanced Regions (comma-separated) - Allowed keys (leeg = auto-geleerd via channel-activiteit) + Allowed keys (empty = auto-learned from channel activity) ``` -- **Channels** β€” vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS. -- **Categories** β€” komma-gescheiden lijst van geldige categorie-tags. -- **Retain** β€” berichtretentie in uren (standaard 48). -- **Advanced β†’ Regions** β€” optionele regio-tags voor geografische filtering. -- **Advanced β†’ Allowed keys** β€” handmatige whitelist-override; leeg laten om alleen automatisch te leren. +- **Channels** β€” check all channels whose participants should have access to the BBS. Multiple channels can be selected. +- **Categories** β€” comma-separated list of valid category tags. +- **Retain** β€” message retention in hours (default 48). +- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. +- **Advanced β†’ Allowed keys** β€” manual whitelist override; leave empty to rely on auto-learned keys only. -### Commando-syntax +### Command syntax -#### Korte syntax +#### Short syntax -| Commando | Beschrijving | +| Command | Description | |---|---| -| `!p ` | Bericht posten | -| `!p ` | Posten met regio | -| `!r` | Laatste 5 berichten lezen (alle categorieΓ«n) | -| `!r ` | Lezen gefilterd op categorie | -| `!r ` | Lezen gefilterd op regio en categorie | +| `!p ` | Post a message | +| `!p ` | Post with region | +| `!r` | Read 5 most recent messages (all categories) | +| `!r ` | Read filtered by category | +| `!r ` | Read filtered by region and category | -Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: +Category abbreviations are computed automatically as the shortest unique prefix within the configured list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: ``` U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee. +If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), longer prefixes are calculated automatically: `ME` and `MI`. The `!r` (without arguments) and `!bbs help` replies always include the current abbreviation table. -#### Volledige syntax +#### Full syntax -| Commando | Beschrijving | +| Command | Description | |---|---| -| `!bbs help` | Toon commando's en afkortingstabel | -| `!bbs post ` | Bericht posten | -| `!bbs post ` | Posten met regio | -| `!bbs read` | Laatste 5 berichten | -| `!bbs read ` | Gefilterd op categorie | -| `!bbs read ` | Gefilterd op regio en categorie | +| `!bbs help` | Show commands and abbreviation table | +| `!bbs post ` | Post a message | +| `!bbs post ` | Post with region | +| `!bbs read` | Read 5 most recent messages | +| `!bbs read ` | Read filtered by category | +| `!bbs read ` | Read filtered by region and category | -#### Voorbeeld help-reply +#### Example help reply ``` BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -### Foutafhandeling +### Error handling -| Situatie | Reply | +| Situation | Reply | |---|---| -| Onbekende categorie | Lijst met geldige categorieΓ«n en afkortingen | -| Ambigue afkorting | Lijst met overeenkomende categorieΓ«n | -| Sender niet op whitelist | Silent drop β€” geen reply | +| Unknown category | Lists valid categories and abbreviations | +| Ambiguous abbreviation | Lists all matching categories | +| Sender not on whitelist | Silent drop β€” no reply | ### Storage ``` -~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag (WAL-mode) -~/.meshcore-gui/bbs/bbs_config.json β€” Board-configuratie +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration ``` --- diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index e73ff18..9f04bc7 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -536,34 +536,32 @@ class BbsCommandHandler: """Handle ``!p [region] ``.""" regions = board.regions categories = board.categories - tokens = args.split(None, 2) if args else [] + + # First token may be a region; split loosely to detect it + first_tokens = args.split(None, 1) if args else [] region = "" - if regions and tokens: - resolved_r = self._resolve_region(tokens[0], regions) + remainder = args # everything after optional region + + if regions and first_tokens: + resolved_r = self._resolve_region(first_tokens[0], regions) if resolved_r: region = resolved_r - tokens = tokens[1:] # consume region token + remainder = first_tokens[1] if len(first_tokens) > 1 else "" - # Now tokens should be [abbrev, text] - if len(tokens) < 2: + # Split remainder into exactly [category/abbrev, full_text] + cat_and_text = remainder.split(None, 1) if remainder else [] + + if len(cat_and_text) < 2: abbr = self._abbrev_table(categories) - return ( - f"Usage: !p [region] | {abbr}" - ) + return f"Usage: !p [region] | {abbr}" - cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else "" - # Rebuild text in case split(None,2) on a shorter string - if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3): - # re-split with region consumed - pass + cat_token, text = cat_and_text[0], cat_and_text[1] category = self._resolve_category(cat_token, categories) if category is None: abbr = self._abbrev_table(categories) - return ( - f"Unknown category '{cat_token}'. Valid: {abbr}" - ) + return f"Unknown category '{cat_token}'. Valid: {abbr}" msg = BbsMessage( channel=channel_idx, @@ -609,21 +607,28 @@ class BbsCommandHandler: """Handle ``!bbs post [region] ``.""" regions = board.regions categories = board.categories - tokens = args.split(None, 2) if args else [] + + # First token may be a region; split loosely to detect it + first_tokens = args.split(None, 1) if args else [] region = "" - if regions and tokens: - resolved_r = self._resolve_region(tokens[0], regions) + remainder = args # everything after optional region + + if regions and first_tokens: + resolved_r = self._resolve_region(first_tokens[0], regions) if resolved_r: region = resolved_r - tokens = tokens[1:] + remainder = first_tokens[1] if len(first_tokens) > 1 else "" - if len(tokens) < 2: + # Split remainder into exactly [category, full_text] + cat_and_text = remainder.split(None, 1) if remainder else [] + + if len(cat_and_text) < 2: abbr = self._abbrev_table(categories) - region_hint = f" [region]" if regions else "" + region_hint = " [region]" if regions else "" return f"Usage: !bbs post{region_hint} | {abbr}" - cat_token, text = tokens[0], tokens[1] + cat_token, text = cat_and_text[0], cat_and_text[1] category = self._resolve_category(cat_token, categories) if category is None: abbr = self._abbrev_table(categories)