From 8836d9dd6e8d245bf73d1d22b7b1faad46dcad14 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Mon, 16 Mar 2026 11:20:55 +0100 Subject: [PATCH] fix(bbs_service): resolve NameError in _abbrev_table that crashed !h and !help(#v1.14.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _abbrev_table used a list comprehension inline inside a generator expression filter. In Python 3, list comprehensions have their own scope, so the loop variable 'cu' was not visible to the outer 'if' condition β€” causing a NameError on every !h / !help DM command. Extract the comprehension to a local variable 'cats_upper' so both the iteration and the filter operate on the same pre-built list. --- CHANGELOG.md | 22 + MAP_ARCHITECTURE.md | 231 ----- README.md | 2 +- .../meshcore_gui/config.py => config.py | 2 +- .../ISSUE_bbs_channel_reply_unknown_sender.md | 30 + docs/MAP_ARCHITECTURE.md | 12 +- install_scripts/install_serial.sh | 15 +- .../bridge_config.yaml | 0 meshcore_gui/CHANGELOG.md | 922 ------------------ meshcore_gui/ble/commands.py | 50 +- meshcore_gui/ble/events.py | 38 +- meshcore_gui/config.py | 2 +- meshcore_gui/gui/panels/bbs_panel.py | 11 +- meshcore_gui/meshcore_gui/gui/dashboard.py | 850 ---------------- .../meshcore_gui/gui/panels/__init__.py | 18 - .../meshcore_gui/gui/panels/bbs_panel.py | 614 ------------ .../meshcore_gui/services/bbs_config_store.py | 302 ------ .../meshcore_gui/services/bbs_service.py | 468 --------- meshcore_gui/meshcore_gui/services/bot.py | 221 ----- meshcore_gui/services/bbs_config_store.py | 15 +- meshcore_gui/services/bbs_service.py | 94 +- .../observer_config.template.yaml | 0 .../observer_config.yaml | 0 23 files changed, 197 insertions(+), 3722 deletions(-) delete mode 100644 MAP_ARCHITECTURE.md rename meshcore_gui/meshcore_gui/config.py => config.py (99%) create mode 100644 docs/ISSUE_bbs_channel_reply_unknown_sender.md mode change 100755 => 100644 install_scripts/install_serial.sh rename bridge_config.yaml => meshcore_bridge/bridge_config.yaml (100%) delete mode 100644 meshcore_gui/CHANGELOG.md delete mode 100644 meshcore_gui/meshcore_gui/gui/dashboard.py delete mode 100644 meshcore_gui/meshcore_gui/gui/panels/__init__.py delete mode 100644 meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py delete mode 100644 meshcore_gui/meshcore_gui/services/bbs_config_store.py delete mode 100644 meshcore_gui/meshcore_gui/services/bbs_service.py delete mode 100644 meshcore_gui/meshcore_gui/services/bot.py rename observer_config.template.yaml => meshcore_observer/observer_config.template.yaml (100%) rename observer_config.yaml => meshcore_observer/observer_config.yaml (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68b92a2..df26c23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > lower CPU usage during idle operation, and more stable map rendering. --- +## [1.14.3] - 2026-03-16 β€” BBS !h / !help NameError fix + +### Fixed +- πŸ› **`services/bbs_service.py`** β€” `!h` en `!help` DM-commando's gooiden een `NameError: name 'cu' is not defined` in `_abbrev_table()`. + - **Root cause**: `cu` werd gedefinieerd in een inner list comprehension `[cu.upper() for cu in categories]`, maar Python 3 list comprehensions hebben een eigen scope. De `if cu.upper() in inv` in de buitenste generator expression kon `cu` daardoor niet bereiken. + - **Fix**: list comprehension extracted naar een aparte variabele `cats_upper`; de generator itereert nu over die lijst. + +## [1.14.2] - 2026-03-16 β€” BBS whitelist fix: !bbs channel hook in on_rx_log + +### Fixed +- πŸ› **`ble/events.py`** β€” `!bbs` op een geconfigureerd BBS-channel deed nooit een whitelist-add, waardoor `!h` en andere DM-BBS-commando's daarna silently werden gedropped. + - **Root cause**: de BBS channel hook stond uitsluitend in `on_channel_msg`, maar `on_channel_msg` wordt in het normale pad onderdrukt door de content-dedup early-return (het bericht is dan al door `on_rx_log` verwerkt en gemarkeerd). + - **Fix**: BBS channel hook (`handle_channel_msg`) ook aangeroepen in `on_rx_log`, direct nΓ‘ de bot-aanroep, binnen de `GroupText + channel_idx resolved`-branch. `sender_pubkey` is daar al opgelost via `get_contact_by_name`. + - De hook in `on_channel_msg` blijft intact als fallback voor het deferred-path (channel_idx onopgelost in `on_rx_log`). + +## [1.14.1] - 2026-03-16 β€” BBS test corrections + +### Changed +- Testing package flattened to a single canonical `meshcore_gui/...` tree so runtime and validation target one code path. +- `!bbs` channel bootstrap, DM-only `!h` / `!help`, and chunked BBS reply work were applied as in-progress fixes under version `1.14.1` while testing continues. +- No release bump: version numbering is kept at `1.14.1` for this test set. + ## [1.14.0] - 2026-03-14 β€” BBS (Bulletin Board System) ### Added diff --git a/MAP_ARCHITECTURE.md b/MAP_ARCHITECTURE.md deleted file mode 100644 index ac62404..0000000 --- a/MAP_ARCHITECTURE.md +++ /dev/null @@ -1,231 +0,0 @@ -# Map Architecture β€” MeshCore GUI - -## Overview - -The MeshCore GUI map subsystem is implemented as a **browser-managed Leaflet runtime** embedded inside a NiceGUI container. - -The key design decision is that the **map lifecycle is owned by the browser**, not by the Python UI update loop. - -NiceGUI acts only as a container and data provider. - -This architecture prevents map resets, marker flicker, and viewport jumps during the 500 ms dashboard refresh cycle. - ---- - -# Architecture - -``` -NiceGUI Dashboard - β”‚ - β”‚ snapshot (500 ms) - β–Ό -MapPanel (Python) - β”‚ - β”‚ JSON payload - β–Ό -Leaflet Runtime (Browser) - β”‚ - β”œβ”€ Map instance (persistent) - β”œβ”€ Marker registry - β”œβ”€ Contact cluster layer - β”œβ”€ Theme state - └─ Viewport state -``` - ---- - -# Component Responsibilities - -## MapPanel (Python) - -Location: - -``` -meshcore_gui/gui/panels/map_panel.py -``` - -Responsibilities: - -* provides the map container -* injects the Leaflet runtime assets -* sends compact map snapshots -* handles UI actions: - - * theme toggle - * center on device - -MapPanel **does NOT control the Leaflet map directly**. - -It only sends data. - ---- - -## MapSnapshotService - -Location: - -``` -meshcore_gui/services/map_snapshot_service.py -``` - -Responsibilities: - -* converts device/contact data into a compact JSON snapshot -* ensures stable node identifiers -* prepares payloads for the browser runtime - -Example snapshot structure: - -```json -{ - "device": {...}, - "contacts": [...], - "force_center": false -} -``` - -Snapshots are emitted every **500 ms** by the dashboard update loop. - ---- - -## Leaflet Runtime - -Location: - -``` -meshcore_gui/static/leaflet_map_panel.js -``` - -Responsibilities: - -* initialize the Leaflet map once -* maintain persistent map instance -* manage marker registry -* maintain a persistent contact cluster layer -* keep the own-device marker outside clustering -* apply snapshots incrementally -* manage map theme and viewport state - -Key design rules: - -``` -map is created once -markers updated incrementally -snapshots never recreate the map -clustering is attached only after maxZoom is known -``` - ---- - -# Update Flow - -``` -SharedData - β”‚ - β–Ό -Dashboard update loop (500 ms) - β”‚ - β–Ό -MapSnapshotService - β”‚ - β–Ό -MapPanel - β”‚ - β–Ό -Leaflet Runtime -``` - -Snapshots are **coalesced** so the browser applies only the newest payload. - ---- - -# Theme Handling - -Theme changes are handled via a **dedicated theme channel**. - -Snapshots do **not** carry theme information. - -Reason: - -Embedding theme state in snapshots caused race conditions where queued snapshots overwrote explicit user selections. - -Theme state is managed in the browser runtime and restored on reconnect. - ---- - -# Marker Model - -Markers are keyed by **stable node id**. - -``` -device marker (standalone) -contact markers (clustered) -``` - -Updates are applied incrementally: - -``` -add marker -update marker -remove marker -``` - -This prevents marker flicker during the refresh loop. - ---- - -# Important Constraints - -Developers must **not**: - -* recreate the Leaflet map inside the dashboard refresh loop -* call `L.map(...)` from snapshot handlers, retry loops or timer callbacks -* embed theme state in snapshots -* call Leaflet APIs directly from Python -* force viewport resets during normal snapshot updates -* place the device marker inside the contact cluster layer - -Violating these rules will reintroduce: - -* disappearing maps -* marker flicker -* viewport resets -* theme resets - ---- - -# Reconnect Behaviour - -When the NiceGUI connection temporarily drops: - -1. the Leaflet runtime persists in the browser -2. the map instance remains intact -3. theme and viewport state are restored -4. snapshot updates resume once the connection returns - ---- - -# Future Extensions - -Possible improvements without breaking the architecture: - -* heatmap layers -* route overlays -* tile provider switching -* richer cluster icons or spiderfy tuning - -All extensions must remain **browser-managed**. - ---- - -# Summary - -The MeshCore map subsystem follows a strict separation: - -``` -Python β†’ data -Browser β†’ map lifecycle -``` - -This prevents UI refresh cycles from interfering with map state and ensures smooth rendering even with frequent dashboard updates. - diff --git a/README.md b/README.md index a27fe71..5f0763c 100644 --- a/README.md +++ b/README.md @@ -956,7 +956,7 @@ Retain: 48 hours β–Ά Advanced Regions (comma-separated) - Allowed keys (empty = auto-learned from channel activity) + Allowed keys (DM-BBS whitelist) ``` - **Channels** β€” check all channels whose participants should have access to the BBS. Multiple channels can be selected. diff --git a/meshcore_gui/meshcore_gui/config.py b/config.py similarity index 99% rename from meshcore_gui/meshcore_gui/config.py rename to config.py index d7c3dc5..aba5c7b 100644 --- a/meshcore_gui/meshcore_gui/config.py +++ b/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.14.0" +VERSION: str = "1.14.1" # ============================================================================== diff --git a/docs/ISSUE_bbs_channel_reply_unknown_sender.md b/docs/ISSUE_bbs_channel_reply_unknown_sender.md new file mode 100644 index 0000000..e3b2b30 --- /dev/null +++ b/docs/ISSUE_bbs_channel_reply_unknown_sender.md @@ -0,0 +1,30 @@ +# BBS channel reply sent as broadcast when sender is unknown + +**Component:** BBS service +**Version:** 1.14.1 +**Priority:** Low +**Type:** Known Limitation + +--- + +## Description + +When a node sends `!bbs` on the BBS channel but is not yet known to the BBS node, +the help text reply is broadcast to the entire channel instead of sent privately +to the sender. + +--- + +## Workaround + +Ensure the sender has been in RF contact with the BBS node at least once before +using the BBS. Under normal operating conditions this happens automatically. + +--- + +## Possible solution + +When the BBS node receives `!bbs` from an unknown sender, reply on the channel +with a short message instructing the sender to send a direct ADVERT request first. +Once the ADVERT is received, the BBS node knows the sender's identity and can +deliver all further replies as private messages. diff --git a/docs/MAP_ARCHITECTURE.md b/docs/MAP_ARCHITECTURE.md index 0ecad30..ac62404 100644 --- a/docs/MAP_ARCHITECTURE.md +++ b/docs/MAP_ARCHITECTURE.md @@ -27,6 +27,7 @@ Leaflet Runtime (Browser) β”‚ β”œβ”€ Map instance (persistent) β”œβ”€ Marker registry + β”œβ”€ Contact cluster layer β”œβ”€ Theme state └─ Viewport state ``` @@ -100,6 +101,8 @@ Responsibilities: * initialize the Leaflet map once * maintain persistent map instance * manage marker registry +* maintain a persistent contact cluster layer +* keep the own-device marker outside clustering * apply snapshots incrementally * manage map theme and viewport state @@ -109,6 +112,7 @@ Key design rules: map is created once markers updated incrementally snapshots never recreate the map +clustering is attached only after maxZoom is known ``` --- @@ -154,8 +158,8 @@ Theme state is managed in the browser runtime and restored on reconnect. Markers are keyed by **stable node id**. ``` -device marker -contact markers +device marker (standalone) +contact markers (clustered) ``` Updates are applied incrementally: @@ -175,9 +179,11 @@ This prevents marker flicker during the refresh loop. Developers must **not**: * recreate the Leaflet map inside the dashboard refresh loop +* call `L.map(...)` from snapshot handlers, retry loops or timer callbacks * embed theme state in snapshots * call Leaflet APIs directly from Python * force viewport resets during normal snapshot updates +* place the device marker inside the contact cluster layer Violating these rules will reintroduce: @@ -203,10 +209,10 @@ When the NiceGUI connection temporarily drops: Possible improvements without breaking the architecture: -* marker clustering * heatmap layers * route overlays * tile provider switching +* richer cluster icons or spiderfy tuning All extensions must remain **browser-managed**. diff --git a/install_scripts/install_serial.sh b/install_scripts/install_serial.sh old mode 100755 new mode 100644 index 2ca8fe7..93817a7 --- a/install_scripts/install_serial.sh +++ b/install_scripts/install_serial.sh @@ -64,13 +64,16 @@ fi CURRENT_USER="$(whoami)" VENV_PYTHON="${PROJECT_DIR}/venv/bin/python" -# Check venv +# Check venv / bootstrap dependencies when missing if [[ ! -x "${VENV_PYTHON}" ]]; then - error "Virtual environment not found at: ${VENV_PYTHON} - Create it first: - python3 -m venv venv - source venv/bin/activate - pip install meshcore nicegui meshcoredecoder" + info "Virtual environment not found. Creating project venv..." + python3 -m venv "${PROJECT_DIR}/venv" + + info "Installing required Python packages into the venv..." + # shellcheck disable=SC1091 + source "${PROJECT_DIR}/venv/bin/activate" + pip install nicegui meshcore meshcoredecoder + ok "Virtual environment created and dependencies installed" fi # Determine the entry point diff --git a/bridge_config.yaml b/meshcore_bridge/bridge_config.yaml similarity index 100% rename from bridge_config.yaml rename to meshcore_bridge/bridge_config.yaml diff --git a/meshcore_gui/CHANGELOG.md b/meshcore_gui/CHANGELOG.md deleted file mode 100644 index 6df97ad..0000000 --- a/meshcore_gui/CHANGELOG.md +++ /dev/null @@ -1,922 +0,0 @@ - - -# 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/ble/commands.py b/meshcore_gui/ble/commands.py index 027861f..20934bd 100644 --- a/meshcore_gui/ble/commands.py +++ b/meshcore_gui/ble/commands.py @@ -18,6 +18,8 @@ from meshcore_gui.services.cache import DeviceCache class CommandHandler: + MAX_REPLY_LEN: int = 180 + """Dispatches and executes commands sent from the GUI. Args: @@ -67,6 +69,34 @@ class CommandHandler: else: debug_print(f"Unknown command action: {action}") + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _split_reply(self, text: str) -> List[str]: + """Split long replies into transport-safe chunks on line boundaries.""" + if not text: + return [] + lines = str(text).splitlines() or [str(text)] + chunks: List[str] = [] + current = "" + for line in lines: + line = line.rstrip() + candidate = line if not current else f"{current}\n{line}" + if len(candidate) <= self.MAX_REPLY_LEN: + current = candidate + continue + if current: + chunks.append(current) + current = "" + while len(line) > self.MAX_REPLY_LEN: + chunks.append(line[:self.MAX_REPLY_LEN]) + line = line[self.MAX_REPLY_LEN:] + current = line + if current: + chunks.append(current) + return chunks + # ------------------------------------------------------------------ # Individual command handlers # ------------------------------------------------------------------ @@ -76,11 +106,13 @@ class CommandHandler: text = cmd.get('text', '') is_bot = cmd.get('_bot', False) if text: - await self._mc.commands.send_chan_msg(channel, text) + chunks = self._split_reply(text) + for idx, chunk in enumerate(chunks): + await self._mc.commands.send_chan_msg(channel, chunk) + if idx + 1 < len(chunks): + await asyncio.sleep(0.2) if not is_bot: - self._shared.add_message(Message.outgoing( - text, channel, - )) + self._shared.add_message(Message.outgoing(text, channel)) debug_print( f"{'BOT' if is_bot else 'Sent'} message to " f"channel {channel}: {text[:30]}" @@ -91,10 +123,12 @@ class CommandHandler: text = cmd.get('text', '') contact_name = cmd.get('contact_name', pubkey[:8]) if text and pubkey: - await self._mc.commands.send_msg(pubkey, text) - self._shared.add_message(Message.outgoing( - text, None, sender_pubkey=pubkey, - )) + chunks = self._split_reply(text) + for idx, chunk in enumerate(chunks): + await self._mc.commands.send_msg(pubkey, chunk) + if idx + 1 < len(chunks): + await asyncio.sleep(0.2) + self._shared.add_message(Message.outgoing(text, None, sender_pubkey=pubkey)) debug_print(f"Sent DM to {contact_name}: {text[:30]}") async def _cmd_send_advert(self, cmd: Dict) -> None: diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 361836c..4a818f9 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -208,7 +208,34 @@ class EventHandler: path_len=decoded.path_length, path_hashes=decoded.path_hashes, ) - + + # BBS channel hook: auto-whitelist sender and reply + # for '!bbs' on a configured BBS channel. + # Must run here because on_channel_msg is suppressed + # by content-dedup when on_rx_log already stored the + # message (the common path for resolved channel_idx). + if ( + self._bbs_handler is not None + and self._command_sink is not None + ): + bbs_reply = self._bbs_handler.handle_channel_msg( + channel_idx=decoded.channel_idx, + sender=decoded.sender, + sender_key=sender_pubkey, + text=decoded.text, + ) + if bbs_reply is not None: + debug_print( + f"BBS channel reply (rx_log) on " + f"ch{decoded.channel_idx} to " + f"{decoded.sender!r}: {bbs_reply[:60]}" + ) + self._command_sink({ + "action": "send_message", + "channel": decoded.channel_idx, + "text": bbs_reply, + }) + # Add RX log entry with message_hash and path info (if available) # ── Fase 1 Observer: raw packet metadata ── raw_packet_len = len(payload_hex) // 2 if payload_hex else 0 @@ -276,8 +303,13 @@ class EventHandler: f"text={msg_text[:40]!r}" ) - sender_pubkey = '' - if sender: + sender_pubkey = ( + payload.get('pubkey_prefix') + or payload.get('sender_pubkey') + or payload.get('signature') + or '' + ) + if not sender_pubkey and sender: match = self._shared.get_contact_by_name(sender) if match: sender_pubkey, _contact = match diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index d7c3dc5..f489cbf 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.14.0" +VERSION: str = "1.14.2" # ============================================================================== diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 5abb6b9..41f7ef6 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -324,16 +324,9 @@ class BbsPanel: ) self._service.post_message(msg) - region_part = f'{region} ' if region else '' - mesh_text = f'!bbs post {region_part}{category} {text}' - self._put_command({ - 'action': 'send_message', - 'channel': target_channel, - 'text': mesh_text, - }) debug_print( - f'BBS panel: posted to board={self._active_board.id} ' - f'ch={target_channel} {mesh_text[:60]}' + f'BBS panel: locally posted to board={self._active_board.id} ' + f'ch={target_channel} [{category}] {text[:60]}' ) if self._text_input: diff --git a/meshcore_gui/meshcore_gui/gui/dashboard.py b/meshcore_gui/meshcore_gui/gui/dashboard.py deleted file mode 100644 index 411e67c..0000000 --- a/meshcore_gui/meshcore_gui/gui/dashboard.py +++ /dev/null @@ -1,850 +0,0 @@ -""" -Main dashboard page for MeshCore GUI. - -Thin orchestrator that owns the layout and the 500 ms update timer. -All visual content is delegated to individual panel classes in -:mod:`meshcore_gui.gui.panels`. -""" - -import logging -from urllib.parse import urlencode - -from nicegui import ui - -from meshcore_gui import config - -from meshcore_gui.core.protocols import SharedDataReader -from meshcore_gui.gui.panels import ( - ActionsPanel, - BbsPanel, - ContactsPanel, - DevicePanel, - MapPanel, - MessagesPanel, - RoomServerPanel, - RxLogPanel, -) -from meshcore_gui.gui.archive_page import ArchivePage -from meshcore_gui.services.bbs_config_store import BbsConfigStore -from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService -from meshcore_gui.services.pin_store import PinStore -from meshcore_gui.services.room_password_store import RoomPasswordStore - - -# Suppress the harmless "Client has been deleted" warning that NiceGUI -# emits when a browser tab is refreshed while a ui.timer is active. -class _DeletedClientFilter(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return 'Client has been deleted' not in record.getMessage() - -logging.getLogger('nicegui').addFilter(_DeletedClientFilter()) - - -# ── DOMCA Theme ────────────────────────────────────────────────────── -# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar. -# Dark/light variable sets switch via Quasar's body--dark / body--light classes. - -_DOMCA_HEAD = ''' - - - - - - - - - -''' - -# ── 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 deleted file mode 100644 index f9245f4..0000000 --- a/meshcore_gui/meshcore_gui/gui/panels/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Individual dashboard panels β€” each panel is a single-responsibility class. - -Re-exports all panels for convenient importing:: - - from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ... -""" - -from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401 -from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401 -from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401 -from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401 -from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401 -from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 -from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 -from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 -from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 -from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py deleted file mode 100644 index 692acf1..0000000 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ /dev/null @@ -1,614 +0,0 @@ -"""BBS panel -- board-based Bulletin Board System viewer and configuration.""" - -import re -from typing import Callable, Dict, List, Optional - -from nicegui import ui - -from meshcore_gui.config import debug_print -from meshcore_gui.services.bbs_config_store import ( - BbsBoard, - BbsConfigStore, - DEFAULT_CATEGORIES, - DEFAULT_RETENTION_HOURS, -) -from meshcore_gui.services.bbs_service import BbsMessage, BbsService -from meshcore_gui.core.protocols import SharedDataReadAndLookup - - -def _slug(name: str) -> str: - """Convert a board name to a safe id slug. - - Args: - name: Human-readable board name. - - Returns: - Lowercase alphanumeric + underscore string. - """ - return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" - - -# --------------------------------------------------------------------------- -# Main BBS panel (message view only β€” settings live on /bbs-settings) -# --------------------------------------------------------------------------- - -class BbsPanel: - """BBS panel: board selector, category buttons, message list and post form. - - Settings are on a separate page (/bbs-settings), reachable via the - gear icon in the panel header. - - Args: - put_command: Callable to enqueue a command dict for the worker. - bbs_service: Shared BbsService instance. - config_store: Shared BbsConfigStore instance. - """ - - def __init__( - self, - put_command: Callable[[Dict], None], - bbs_service: BbsService, - config_store: BbsConfigStore, - ) -> None: - self._put_command = put_command - self._service = bbs_service - self._config_store = config_store - - # Active view state - self._active_board: Optional[BbsBoard] = None - self._active_category: Optional[str] = None - - # UI refs - self._board_btn_row = None - self._category_btn_row = None - self._msg_list_container = None - self._post_region_row = None - self._post_region_select = None - self._post_category_select = None - self._text_input = None - - # Button refs for active highlight - self._board_buttons: Dict[str, object] = {} - self._category_buttons: Dict[str, object] = {} - - # Cached device channels (updated by update()) - self._device_channels: List[Dict] = [] - self._last_ch_fingerprint: tuple = () - - # ------------------------------------------------------------------ - # Render - # ------------------------------------------------------------------ - - def render(self) -> None: - """Build the BBS message view panel layout.""" - with ui.card().classes('w-full'): - # Header row with gear icon - with ui.row().classes('w-full items-center justify-between'): - ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - ui.button( - icon='settings', - on_click=lambda: ui.navigate.to('/bbs-settings'), - ).props('flat round dense').tooltip('BBS Settings') - - # Board selector row - self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') - with self._board_btn_row: - ui.label('No active boards β€” open Settings to enable a channel.').classes( - 'text-xs text-gray-400 italic' - ) - - ui.separator() - - # Category filter row (clickable buttons, replaces dropdown) - self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') - with self._category_btn_row: - ui.label('Select a board first.').classes('text-xs text-gray-400 italic') - - ui.separator() - - # Message list - self._msg_list_container = ui.column().classes( - 'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' - ).style('max-height: calc(100vh - 24rem); min-height: 8rem') - - ui.separator() - - # Post row β€” keep selects for sending - with ui.row().classes('w-full items-center gap-2 flex-wrap'): - ui.label('Post:').classes('text-sm text-gray-600') - - self._post_region_row = ui.row().classes('items-center gap-1') - with self._post_region_row: - self._post_region_select = ui.select( - options=[], label='Region', - ).classes('text-xs').style('min-width: 110px') - - self._post_category_select = ui.select( - options=[], label='Category', - ).classes('text-xs').style('min-width: 110px') - - self._text_input = ui.input( - placeholder='Message text...', - ).classes('flex-grow text-sm min-w-0') - - ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - - # Initial render - self._rebuild_board_buttons() - - # ------------------------------------------------------------------ - # Board selector - # ------------------------------------------------------------------ - - def _rebuild_board_buttons(self) -> None: - """Rebuild board selector buttons from current config.""" - if not self._board_btn_row: - return - self._board_btn_row.clear() - self._board_buttons = {} - boards = self._config_store.get_boards() - with self._board_btn_row: - if not boards: - ui.label('No active boards β€” open Settings to enable a channel.').classes( - 'text-xs text-gray-400 italic' - ) - return - for board in boards: - btn = ui.button( - board.name, - on_click=lambda b=board: self._select_board(b), - ).props('flat no-caps').classes('text-xs domca-menu-btn') - self._board_buttons[board.id] = btn - - ids = [b.id for b in boards] - if boards and (self._active_board is None or self._active_board.id not in ids): - self._select_board(boards[0]) - elif self._active_board and self._active_board.id in self._board_buttons: - self._board_buttons[self._active_board.id].classes('domca-menu-active') - - def _select_board(self, board: BbsBoard) -> None: - """Activate a board and rebuild category buttons. - - Args: - board: Board to activate. - """ - self._active_board = board - self._active_category = None - - # Update board button highlights - for bid, btn in self._board_buttons.items(): - if bid == board.id: - btn.classes('domca-menu-active', remove='') - else: - btn.classes(remove='domca-menu-active') - - # Update post selects - if self._post_region_row: - self._post_region_row.set_visibility(bool(board.regions)) - if self._post_region_select: - self._post_region_select.options = board.regions - self._post_region_select.value = board.regions[0] if board.regions else None - if self._post_category_select: - self._post_category_select.options = board.categories - self._post_category_select.value = board.categories[0] if board.categories else None - - self._rebuild_category_buttons() - self._refresh_messages() - - # ------------------------------------------------------------------ - # Category buttons - # ------------------------------------------------------------------ - - def _rebuild_category_buttons(self) -> None: - """Rebuild clickable category filter buttons for the active board.""" - if not self._category_btn_row: - return - self._category_btn_row.clear() - self._category_buttons = {} - if self._active_board is None: - with self._category_btn_row: - ui.label('Select a board first.').classes('text-xs text-gray-400 italic') - return - with self._category_btn_row: - # "All" button - all_btn = ui.button( - 'ALL', - on_click=lambda: self._on_category_filter(None), - ).props('flat no-caps').classes('text-xs domca-menu-btn') - self._category_buttons['__all__'] = all_btn - - for cat in self._active_board.categories: - btn = ui.button( - cat, - on_click=lambda c=cat: self._on_category_filter(c), - ).props('flat no-caps').classes('text-xs domca-menu-btn') - self._category_buttons[cat] = btn - - # Highlight the current active category - self._update_category_highlight() - - def _on_category_filter(self, category: Optional[str]) -> None: - """Handle category button click. - - Args: - category: Category string, or None for all. - """ - self._active_category = category - self._update_category_highlight() - self._refresh_messages() - - def _update_category_highlight(self) -> None: - """Apply domca-menu-active to the currently selected category button.""" - active_key = self._active_category if self._active_category else '__all__' - for key, btn in self._category_buttons.items(): - if key == active_key: - btn.classes('domca-menu-active', remove='') - else: - btn.classes(remove='domca-menu-active') - - # ------------------------------------------------------------------ - # Message list - # ------------------------------------------------------------------ - - def _refresh_messages(self) -> None: - if not self._msg_list_container: - return - self._msg_list_container.clear() - with self._msg_list_container: - if self._active_board is None: - ui.label('Select a board above.').classes('text-xs text-gray-400 italic') - return - if not self._active_board.channels: - ui.label('No channels assigned to this board.').classes( - 'text-xs text-gray-400 italic' - ) - return - messages = self._service.get_all_messages( - channels=self._active_board.channels, - region=None, - category=self._active_category, - ) - if not messages: - ui.label('No messages.').classes('text-xs text-gray-400 italic') - return - for msg in messages: - self._render_message_row(msg) - - def _render_message_row(self, msg: BbsMessage) -> None: - ts = msg.timestamp[:16].replace('T', ' ') - region_label = f' [{msg.region}]' if msg.region else '' - header = f'{ts} {msg.sender} [{msg.category}]{region_label}' - with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'): - ui.label(header).classes('text-xs text-gray-500').style( - 'word-break: break-all; overflow-wrap: break-word' - ) - ui.label(msg.text).classes('text-sm').style( - 'word-break: break-word; overflow-wrap: break-word' - ) - - # ------------------------------------------------------------------ - # Post - # ------------------------------------------------------------------ - - def _on_post(self) -> None: - if self._active_board is None: - ui.notify('Select a board first.', type='warning') - return - if not self._active_board.channels: - ui.notify('No channels assigned to this board.', type='warning') - return - - text = (self._text_input.value or '').strip() if self._text_input else '' - if not text: - ui.notify('Message text cannot be empty.', type='warning') - return - - category = ( - self._post_category_select.value if self._post_category_select - else (self._active_board.categories[0] if self._active_board.categories else '') - ) - if not category: - ui.notify('Please select a category.', type='warning') - return - - region = '' - if self._active_board.regions and self._post_region_select: - region = self._post_region_select.value or '' - - target_channel = self._active_board.channels[0] - - msg = BbsMessage( - channel=target_channel, - region=region, category=category, - sender='Me', sender_key='', text=text, - ) - self._service.post_message(msg) - - region_part = f'{region} ' if region else '' - mesh_text = f'!bbs post {region_part}{category} {text}' - self._put_command({ - 'action': 'send_message', - 'channel': target_channel, - 'text': mesh_text, - }) - debug_print( - f'BBS panel: posted to board={self._active_board.id} ' - f'ch={target_channel} {mesh_text[:60]}' - ) - - if self._text_input: - self._text_input.value = '' - self._refresh_messages() - ui.notify('Message posted.', type='positive') - - # ------------------------------------------------------------------ - # External update hook - # ------------------------------------------------------------------ - - def update(self, data: Dict) -> None: - """Called by the dashboard timer with the SharedData snapshot. - - Args: - data: SharedData snapshot dict. - """ - device_channels = data.get('channels', []) - fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels - ) - if fingerprint != self._last_ch_fingerprint: - self._last_ch_fingerprint = fingerprint - self._device_channels = device_channels - self._rebuild_board_buttons() - - -# --------------------------------------------------------------------------- -# Separate settings page (/bbs-settings) -# --------------------------------------------------------------------------- - -class BbsSettingsPage: - """Standalone BBS settings page, registered at /bbs-settings. - - Follows the same pattern as RoutePage: one instance, render() called - per page load. - - Args: - shared: SharedData instance (for device channel list). - config_store: BbsConfigStore instance. - """ - - def __init__( - self, - shared: SharedDataReadAndLookup, - config_store: BbsConfigStore, - ) -> None: - self._shared = shared - self._config_store = config_store - self._device_channels: List[Dict] = [] - self._boards_settings_container = None - - def render(self) -> None: - """Render the BBS settings page.""" - from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy β€” avoids circular import - data = self._shared.get_snapshot() - self._device_channels = data.get('channels', []) - - ui.page_title('BBS Settings') - ui.add_head_html(_DOMCA_HEAD) - ui.dark_mode(True) - - with ui.header().classes('items-center px-4 py-2 shadow-md'): - ui.button( - icon='arrow_back', - on_click=lambda: ui.run_javascript('window.history.back()'), - ).props('flat round dense color=white').tooltip('Back') - ui.label('πŸ“‹ BBS Settings').classes( - 'text-lg font-bold domca-header-text' - ).style("font-family: 'JetBrains Mono', monospace") - ui.space() - - with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'): - with ui.card().classes('w-full'): - ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.separator() - - self._boards_settings_container = ui.column().classes('w-full gap-3') - with self._boards_settings_container: - if not self._device_channels: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - else: - self._render_all() - - # ------------------------------------------------------------------ - # Settings rendering - # ------------------------------------------------------------------ - - def _render_all(self) -> None: - """Render all channel rows and the advanced section.""" - for ch in self._device_channels: - self._render_channel_settings_row(ch) - - ui.separator() - - with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): - ui.label('Regions and key list per channel').classes( - 'text-xs text-gray-500 pb-1' - ) - advanced_any = False - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - board = self._config_store.get_board(f'ch{idx}') - if board is not None: - self._render_channel_advanced_row(ch, board) - advanced_any = True - if not advanced_any: - ui.label( - 'Enable at least one channel to see advanced options.' - ).classes('text-xs text-gray-400 italic') - - def _rebuild(self) -> None: - """Clear and re-render the settings container in-place.""" - if not self._boards_settings_container: - return - self._boards_settings_container.clear() - with self._boards_settings_container: - if not self._device_channels: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - else: - self._render_all() - - def _render_channel_settings_row(self, ch: Dict) -> None: - """Render the standard settings row for a single device channel. - - Args: - ch: Device channel dict with 'idx'/'index' and 'name' keys. - """ - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') - board_id = f'ch{idx}' - board = self._config_store.get_board(board_id) - - is_active = board is not None - cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) - retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) - - with ui.card().classes('w-full p-2'): - with ui.row().classes('w-full items-center justify-between'): - ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') - active_toggle = ui.toggle( - {True: '● Active', False: 'β—‹ Off'}, - value=is_active, - ).classes('text-xs') - - with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') - cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - - with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') - retention_input = ui.input(value=retention_value).classes('text-xs').style( - 'max-width: 80px' - ) - ui.label('hrs').classes('text-xs text-gray-600') - - def _save( - bid=board_id, - bname=ch_name, - bidx=idx, - tog=active_toggle, - ci=cats_input, - ri=retention_input, - ) -> None: - if tog.value: - existing = self._config_store.get_board(bid) - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - try: - ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) - except ValueError: - ret_hours = DEFAULT_RETENTION_HOURS - extra_channels = ( - [c for c in existing.channels if c != bidx] - if existing else [] - ) - updated = BbsBoard( - id=bid, - name=bname, - channels=[bidx] + extra_channels, - categories=categories, - regions=existing.regions if existing else [], - retention_hours=ret_hours, - allowed_keys=existing.allowed_keys if existing else [], - ) - self._config_store.set_board(updated) - debug_print(f'BBS settings: channel {bid} saved') - ui.notify(f'{bname} saved.', type='positive') - else: - self._config_store.delete_board(bid) - debug_print(f'BBS settings: channel {bid} disabled') - ui.notify(f'{bname} disabled.', type='warning') - self._rebuild() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: - """Render the advanced settings block for a single active channel. - - Args: - ch: Device channel dict. - board: Existing BbsBoard for this channel. - """ - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') - board_id = f'ch{idx}' - - with ui.column().classes('w-full gap-1 py-2'): - ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') - - regions_input = ui.input( - label='Regions (comma-separated)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - wl_input = ui.input( - label='Allowed keys (empty = everyone on the channel)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - other_channels = [ - c for c in self._device_channels - if c.get('idx', c.get('index', 0)) != idx - ] - ch_checks: Dict[int, object] = {} - if other_channels: - ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1') - with ui.row().classes('flex-wrap gap-2'): - for other_ch in other_channels: - other_idx = other_ch.get('idx', other_ch.get('index', 0)) - other_name = other_ch.get('name', f'Ch {other_idx}') - cb = ui.checkbox( - f'[{other_idx}] {other_name}', - value=other_idx in board.channels, - ).classes('text-xs') - ch_checks[other_idx] = cb - - def _save_adv( - bid=board_id, - bidx=idx, - bname=ch_name, - ri=regions_input, - wli=wl_input, - cc=ch_checks, - ) -> None: - existing = self._config_store.get_board(bid) - if existing is None: - ui.notify('Enable this channel first.', type='warning') - return - regions = [ - r.strip() for r in (ri.value or '').split(',') if r.strip() - ] - allowed_keys = [ - k.strip() for k in (wli.value or '').split(',') if k.strip() - ] - combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value] - updated = BbsBoard( - id=bid, - name=bname, - channels=combined, - categories=existing.categories, - regions=regions, - retention_hours=existing.retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print(f'BBS settings (advanced): {bid} saved') - ui.notify(f'{bname} saved.', type='positive') - self._rebuild() - - ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') - ui.separator() diff --git a/meshcore_gui/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/meshcore_gui/services/bbs_config_store.py deleted file mode 100644 index c05727b..0000000 --- a/meshcore_gui/meshcore_gui/services/bbs_config_store.py +++ /dev/null @@ -1,302 +0,0 @@ -""" -BBS board configuration store for MeshCore GUI. - -Persists BBS board configuration to -``~/.meshcore-gui/bbs/bbs_config.json``. - -A **board** groups one or more MeshCore channel indices into a single -bulletin board. Messages posted on any of the board's channels are -visible in the board view. This supports two usage patterns: - -- One board per channel (classic per-channel BBS) -- One board spanning multiple channels (shared bulletin board) - -Config version history -~~~~~~~~~~~~~~~~~~~~~~ -v1 β€” per-channel config (list of channels with enabled flag). -v2 β€” board-based config (list of boards, each with a channels list). - Automatic migration from v1 on first load. - -Thread safety -~~~~~~~~~~~~~ -All public methods acquire an internal ``threading.Lock``. -""" - -import json -import threading -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional - -from meshcore_gui.config import debug_print - -# --------------------------------------------------------------------------- -# Storage -# --------------------------------------------------------------------------- - -BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" -BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" - -CONFIG_VERSION: int = 2 - -# --------------------------------------------------------------------------- -# Defaults -# --------------------------------------------------------------------------- - -DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] -DEFAULT_REGIONS: List[str] = [] -DEFAULT_RETENTION_HOURS: int = 48 - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - -@dataclass -class BbsBoard: - """A BBS board grouping one or more MeshCore channels. - - Attributes: - id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``). - name: Human-readable board name. - channels: List of MeshCore channel indices assigned to this board. - categories: Valid category tags for this board. - regions: Optional region tags; empty = no region filtering. - retention_hours: Message retention period in hours. - allowed_keys: Sender public key whitelist (empty = all allowed). - """ - - id: str - name: str - channels: List[int] = field(default_factory=list) - categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES)) - regions: List[str] = field(default_factory=list) - retention_hours: int = DEFAULT_RETENTION_HOURS - allowed_keys: List[str] = field(default_factory=list) - - def to_dict(self) -> Dict: - """Serialise to a JSON-compatible dict.""" - return { - "id": self.id, - "name": self.name, - "channels": list(self.channels), - "categories": list(self.categories), - "regions": list(self.regions), - "retention_hours": self.retention_hours, - "allowed_keys": list(self.allowed_keys), - } - - @staticmethod - def from_dict(d: Dict) -> "BbsBoard": - """Deserialise from a config dict.""" - return BbsBoard( - id=d.get("id", ""), - name=d.get("name", ""), - channels=list(d.get("channels", [])), - categories=list(d.get("categories", DEFAULT_CATEGORIES)), - regions=list(d.get("regions", [])), - retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)), - allowed_keys=list(d.get("allowed_keys", [])), - ) - - -# --------------------------------------------------------------------------- -# Store -# --------------------------------------------------------------------------- - -class BbsConfigStore: - """Persistent store for BBS board configuration. - - Args: - config_path: Path to the JSON config file. - Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``. - """ - - def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: - self._path = config_path - self._lock = threading.Lock() - self._boards: List[BbsBoard] = [] - self._load() - - # ------------------------------------------------------------------ - # Load / save - # ------------------------------------------------------------------ - - def _load(self) -> None: - """Load config from disk; migrate v1 β†’ v2 if needed.""" - BBS_DIR.mkdir(parents=True, exist_ok=True) - - if not self._path.exists(): - self._save_unlocked() - debug_print("BBS config: created new config file (v2)") - return - - try: - raw = self._path.read_text(encoding="utf-8") - data = json.loads(raw) - version = data.get("version", 1) - - if version == CONFIG_VERSION: - self._boards = [ - BbsBoard.from_dict(b) for b in data.get("boards", []) - ] - debug_print(f"BBS config: loaded {len(self._boards)} boards") - - elif version == 1: - # Migrate: each v1 channel β†’ one board - self._boards = self._migrate_v1(data.get("channels", [])) - self._save_unlocked() - debug_print( - f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" - ) - else: - debug_print( - f"BBS config: unknown version {version}, using empty config" - ) - - except (json.JSONDecodeError, OSError) as exc: - debug_print(f"BBS config: load error ({exc}), using empty config") - - @staticmethod - def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]: - """Convert v1 per-channel entries to v2 boards. - - Only enabled channels are migrated. - - Args: - v1_channels: List of v1 channel config dicts. - - Returns: - List of ``BbsBoard`` instances. - """ - boards = [] - for ch in v1_channels: - if not ch.get("enabled", False): - continue - idx = ch.get("channel", 0) - board_id = f"ch{idx}" - boards.append(BbsBoard( - id=board_id, - name=ch.get("name", f"Channel {idx}"), - channels=[idx], - categories=list(ch.get("categories", DEFAULT_CATEGORIES)), - regions=list(ch.get("regions", [])), - retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)), - allowed_keys=list(ch.get("allowed_keys", [])), - )) - return boards - - def _save_unlocked(self) -> None: - """Write config to disk. MUST be called with self._lock held.""" - BBS_DIR.mkdir(parents=True, exist_ok=True) - data = { - "version": CONFIG_VERSION, - "boards": [b.to_dict() for b in self._boards], - } - tmp = self._path.with_suffix(".tmp") - tmp.write_text( - json.dumps(data, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - tmp.replace(self._path) - - def save(self) -> None: - """Flush current configuration to disk.""" - with self._lock: - self._save_unlocked() - - # ------------------------------------------------------------------ - # Board queries - # ------------------------------------------------------------------ - - def get_boards(self) -> List[BbsBoard]: - """Return a copy of all configured boards. - - Returns: - List of ``BbsBoard`` instances. - """ - with self._lock: - return list(self._boards) - - def get_board(self, board_id: str) -> Optional[BbsBoard]: - """Return a board by its id, or ``None``. - - Args: - board_id: Board identifier string. - - Returns: - ``BbsBoard`` instance or ``None``. - """ - with self._lock: - for b in self._boards: - if b.id == board_id: - return BbsBoard.from_dict(b.to_dict()) - return None - - def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: - """Return the first board that includes *channel_idx*, or ``None``. - - Used by ``BbsCommandHandler`` to route incoming mesh commands. - - Args: - channel_idx: MeshCore channel index. - - Returns: - ``BbsBoard`` instance or ``None``. - """ - with self._lock: - for b in self._boards: - if channel_idx in b.channels: - return BbsBoard.from_dict(b.to_dict()) - return None - - # ------------------------------------------------------------------ - # Board management - # ------------------------------------------------------------------ - - def set_board(self, board: BbsBoard) -> None: - """Insert or replace a board (matched by ``board.id``). - - Args: - board: ``BbsBoard`` to persist. - """ - with self._lock: - for i, b in enumerate(self._boards): - if b.id == board.id: - self._boards[i] = BbsBoard.from_dict(board.to_dict()) - self._save_unlocked() - debug_print(f"BBS config: updated board '{board.id}'") - return - self._boards.append(BbsBoard.from_dict(board.to_dict())) - self._save_unlocked() - debug_print(f"BBS config: added board '{board.id}'") - - def delete_board(self, board_id: str) -> bool: - """Remove a board by id. - - Args: - board_id: Board identifier to remove. - - Returns: - ``True`` if removed, ``False`` if not found. - """ - with self._lock: - before = len(self._boards) - self._boards = [b for b in self._boards if b.id != board_id] - if len(self._boards) < before: - self._save_unlocked() - debug_print(f"BBS config: deleted board '{board_id}'") - return True - return False - - def board_id_exists(self, board_id: str) -> bool: - """Check whether a board id is already in use. - - Args: - board_id: Board identifier to check. - - Returns: - ``True`` if a board with this id exists. - """ - with self._lock: - return any(b.id == board_id for b in self._boards) diff --git a/meshcore_gui/meshcore_gui/services/bbs_service.py b/meshcore_gui/meshcore_gui/services/bbs_service.py deleted file mode 100644 index 52b7968..0000000 --- a/meshcore_gui/meshcore_gui/services/bbs_service.py +++ /dev/null @@ -1,468 +0,0 @@ -""" -Offline Bulletin Board System (BBS) service for MeshCore GUI. - -Stores BBS messages in a local SQLite database. Messages are keyed by -their originating MeshCore channel index. A **board** (see -:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or -more channel indices to a single bulletin board, so queries are always -issued as ``WHERE channel IN (...)``. - -Architecture -~~~~~~~~~~~~ -- ``BbsService`` -- persistence layer (SQLite, retention, queries). -- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and - delegates to ``BbsService``. Returns reply text. - -Thread safety -~~~~~~~~~~~~~ -SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by -multiple application instances (e.g. 800 MHz + 433 MHz on one Pi). - -Storage -~~~~~~~ -``~/.meshcore-gui/bbs/bbs_messages.db`` -``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) -""" - -import sqlite3 -import threading -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Dict, List, Optional - -from meshcore_gui.config import debug_print - -BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" -BBS_DB_PATH = BBS_DIR / "bbs_messages.db" - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - -@dataclass -class BbsMessage: - """A single BBS message. - - Attributes: - id: Database row id (``None`` before insert). - channel: MeshCore channel index the message arrived on. - region: Region tag (empty string when board has no regions). - category: Category tag. - sender: Display name of the sender. - sender_key: Public key of the sender (hex string). - text: Message body. - timestamp: UTC ISO-8601 timestamp string. - """ - - channel: int - region: str - category: str - sender: str - sender_key: str - text: str - timestamp: str = field( - default_factory=lambda: datetime.now(timezone.utc).isoformat() - ) - id: Optional[int] = None - - -# --------------------------------------------------------------------------- -# Service -# --------------------------------------------------------------------------- - -class BbsService: - """SQLite-backed BBS storage service. - - Args: - db_path: Path to the SQLite database file. - """ - - def __init__(self, db_path: Path = BBS_DB_PATH) -> None: - self._db_path = db_path - self._lock = threading.Lock() - self._init_db() - - def _init_db(self) -> None: - """Create the database directory and schema if not present.""" - BBS_DIR.mkdir(parents=True, exist_ok=True) - with self._connect() as conn: - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=3000") - conn.execute(""" - CREATE TABLE IF NOT EXISTS bbs_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - channel INTEGER NOT NULL, - region TEXT NOT NULL DEFAULT '', - category TEXT NOT NULL, - sender TEXT NOT NULL, - sender_key TEXT NOT NULL DEFAULT '', - text TEXT NOT NULL, - timestamp TEXT NOT NULL - ) - """) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)" - ) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)" - ) - conn.commit() - debug_print(f"BBS: database ready at {self._db_path}") - - def _connect(self) -> sqlite3.Connection: - return sqlite3.connect(str(self._db_path), check_same_thread=False) - - # ------------------------------------------------------------------ - # Write - # ------------------------------------------------------------------ - - def post_message(self, msg: BbsMessage) -> int: - """Insert a BBS message and return its row id. - - Args: - msg: ``BbsMessage`` dataclass to persist. - - Returns: - Assigned ``rowid`` (also set on ``msg.id``). - """ - with self._lock: - with self._connect() as conn: - cur = conn.execute( - """INSERT INTO bbs_messages - (channel, region, category, sender, sender_key, text, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - (msg.channel, msg.region, msg.category, - msg.sender, msg.sender_key, msg.text, msg.timestamp), - ) - conn.commit() - msg.id = cur.lastrowid - debug_print( - f"BBS: posted id={msg.id} ch={msg.channel} " - f"cat={msg.category} sender={msg.sender}" - ) - return msg.id - - # ------------------------------------------------------------------ - # Read (channels is a list to support multi-channel boards) - # ------------------------------------------------------------------ - - def get_messages( - self, - channels: List[int], - region: Optional[str] = None, - category: Optional[str] = None, - limit: int = 5, - ) -> List[BbsMessage]: - """Return the *limit* most recent messages for a set of channels. - - Args: - channels: MeshCore channel indices to query (board's channel list). - region: Optional region filter. - category: Optional category filter. - limit: Maximum number of messages to return. - - Returns: - List of ``BbsMessage`` objects, newest first. - """ - if not channels: - return [] - placeholders = ",".join("?" * len(channels)) - query = ( - f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " - f"FROM bbs_messages WHERE channel IN ({placeholders})" - ) - params: list = list(channels) - if region: - query += " AND region = ?" - params.append(region) - if category: - query += " AND category = ?" - params.append(category) - query += " ORDER BY timestamp DESC LIMIT ?" - params.append(limit) - - with self._lock: - with self._connect() as conn: - rows = conn.execute(query, params).fetchall() - return [self._row_to_msg(r) for r in rows] - - def get_all_messages( - self, - channels: List[int], - region: Optional[str] = None, - category: Optional[str] = None, - ) -> List[BbsMessage]: - """Return all messages for a set of channels (oldest first). - - Args: - channels: MeshCore channel indices to query. - region: Optional region filter. - category: Optional category filter. - - Returns: - List of ``BbsMessage`` objects, oldest first. - """ - if not channels: - return [] - placeholders = ",".join("?" * len(channels)) - query = ( - f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " - f"FROM bbs_messages WHERE channel IN ({placeholders})" - ) - params: list = list(channels) - if region: - query += " AND region = ?" - params.append(region) - if category: - query += " AND category = ?" - params.append(category) - query += " ORDER BY timestamp ASC" - - with self._lock: - with self._connect() as conn: - rows = conn.execute(query, params).fetchall() - return [self._row_to_msg(r) for r in rows] - - @staticmethod - def _row_to_msg(row: tuple) -> BbsMessage: - return BbsMessage( - id=row[0], channel=row[1], region=row[2], category=row[3], - sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7], - ) - - # ------------------------------------------------------------------ - # Retention - # ------------------------------------------------------------------ - - def purge_expired(self, channels: List[int], retention_hours: int) -> int: - """Delete messages older than *retention_hours* for a set of channels. - - Args: - channels: MeshCore channel indices to purge. - retention_hours: Messages older than this are deleted. - - Returns: - Number of rows deleted. - """ - if not channels: - return 0 - cutoff = ( - datetime.now(timezone.utc) - timedelta(hours=retention_hours) - ).isoformat() - placeholders = ",".join("?" * len(channels)) - with self._lock: - with self._connect() as conn: - cur = conn.execute( - f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?", - list(channels) + [cutoff], - ) - conn.commit() - deleted = cur.rowcount - if deleted: - debug_print( - f"BBS: purged {deleted} expired messages from ch={channels}" - ) - return deleted - - def purge_all_expired(self, boards) -> None: - """Run retention cleanup for all boards. - - Args: - boards: Iterable of ``BbsBoard`` instances. - """ - for board in boards: - self.purge_expired(board.channels, board.retention_hours) - - -# --------------------------------------------------------------------------- -# Command handler -# --------------------------------------------------------------------------- - -class BbsCommandHandler: - """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - - Looks up the board for the incoming channel via ``BbsConfigStore`` - so that a single board spanning multiple channels handles commands - from all of them. - - Args: - service: Shared ``BbsService`` instance. - config_store: ``BbsConfigStore`` instance for live board config. - """ - - READ_LIMIT: int = 5 - - def __init__(self, service: BbsService, config_store) -> None: - self._service = service - self._config_store = config_store - - # ------------------------------------------------------------------ - # Public entry point - # ------------------------------------------------------------------ - - def handle( - self, - channel_idx: int, - sender: str, - sender_key: str, - text: str, - ) -> Optional[str]: - """Parse an incoming message and return a reply string (or ``None``). - - Args: - channel_idx: MeshCore channel index the message arrived on. - sender: Display name of the sender. - sender_key: Public key of the sender (hex string). - text: Raw message text. - - Returns: - Reply string, or ``None`` if no reply should be sent. - """ - text = (text or "").strip() - if not text.lower().startswith("!bbs"): - return None - - board = self._config_store.get_board_for_channel(channel_idx) - if board is None: - return None - - # Whitelist check - if board.allowed_keys and sender_key not in board.allowed_keys: - debug_print( - f"BBS: silently dropping msg from {sender} " - f"(key not in whitelist for board '{board.id}')" - ) - return None - - parts = text.split(None, 1) - args = parts[1].strip() if len(parts) > 1 else "" - return self._dispatch(board, channel_idx, sender, sender_key, args) - - # ------------------------------------------------------------------ - # Dispatch - # ------------------------------------------------------------------ - - def _dispatch(self, board, channel_idx, sender, sender_key, args): - sub = args.split(None, 1)[0].lower() if args else "" - rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" - if sub == "post": - return self._handle_post(board, channel_idx, sender, sender_key, rest) - if sub == "read": - return self._handle_read(board, rest) - if sub == "help" or not sub: - return self._handle_help(board) - return f"Unknown command '{sub}'. {self._handle_help(board)}" - - # ------------------------------------------------------------------ - # post - # ------------------------------------------------------------------ - - def _handle_post(self, board, channel_idx, sender, sender_key, args): - regions = board.regions - categories = board.categories - tokens = args.split(None, 2) if args else [] - - if regions: - if len(tokens) < 3: - return ( - f"Usage: !bbs post [region] [category] [text] | " - f"Regions: {', '.join(regions)} | " - f"Categories: {', '.join(categories)}" - ) - region, category, text = tokens[0], tokens[1], tokens[2] - valid_r = [r.upper() for r in regions] - if region.upper() not in valid_r: - return f"Invalid region '{region}'. Valid: {', '.join(regions)}" - region = regions[valid_r.index(region.upper())] - valid_c = [c.upper() for c in categories] - if category.upper() not in valid_c: - return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_c.index(category.upper())] - else: - if len(tokens) < 2: - return ( - f"Usage: !bbs post [category] [text] | " - f"Categories: {', '.join(categories)}" - ) - region = "" - category, text = tokens[0], tokens[1] - valid_c = [c.upper() for c in categories] - if category.upper() not in valid_c: - return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_c.index(category.upper())] - - msg = BbsMessage( - channel=channel_idx, - region=region, category=category, - sender=sender, sender_key=sender_key, text=text, - ) - self._service.post_message(msg) - region_label = f" [{region}]" if region else "" - return f"Posted [{category}]{region_label}: {text[:60]}" - - # ------------------------------------------------------------------ - # read - # ------------------------------------------------------------------ - - def _handle_read(self, board, args): - regions = board.regions - categories = board.categories - tokens = args.split() if args else [] - region = None - category = None - - if regions: - valid_r = [r.upper() for r in regions] - valid_c = [c.upper() for c in categories] - if tokens: - if tokens[0].upper() in valid_r: - region = regions[valid_r.index(tokens[0].upper())] - if len(tokens) >= 2: - if tokens[1].upper() in valid_c: - category = categories[valid_c.index(tokens[1].upper())] - else: - return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" - else: - return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" - else: - valid_c = [c.upper() for c in categories] - if tokens: - if tokens[0].upper() in valid_c: - category = categories[valid_c.index(tokens[0].upper())] - else: - return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" - - messages = self._service.get_messages( - board.channels, region=region, category=category, limit=self.READ_LIMIT, - ) - if not messages: - return "BBS: no messages found." - lines = [] - for m in messages: - ts = m.timestamp[:16].replace("T", " ") - region_label = f"[{m.region}] " if m.region else "" - lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}") - return "\n".join(lines) - - # ------------------------------------------------------------------ - # help - # ------------------------------------------------------------------ - - def _handle_help(self, board) -> str: - cats = ", ".join(board.categories) - if board.regions: - regs = ", ".join(board.regions) - return ( - f"BBS [{board.name}] | " - f"!bbs post [region] [cat] [text] | " - f"!bbs read [region] [cat] | " - f"Regions: {regs} | Categories: {cats}" - ) - return ( - f"BBS [{board.name}] | " - f"!bbs post [cat] [text] | " - f"!bbs read [cat] | " - f"Categories: {cats}" - ) diff --git a/meshcore_gui/meshcore_gui/services/bot.py b/meshcore_gui/meshcore_gui/services/bot.py deleted file mode 100644 index 9279a72..0000000 --- a/meshcore_gui/meshcore_gui/services/bot.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Keyword-triggered auto-reply bot for MeshCore GUI. - -Extracted from SerialWorker to satisfy the Single Responsibility Principle. -The bot listens on a configured channel and replies to messages that -contain recognised keywords. - -Open/Closed -~~~~~~~~~~~ -New keywords are added via ``BotConfig.keywords`` (data) without -modifying the ``MeshBot`` class (code). Custom matching strategies -can be implemented by subclassing and overriding ``_match_keyword``. - -BBS integration -~~~~~~~~~~~~~~~ -``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a -:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one -is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is -``None`` (default), BBS routing is simply skipped. -""" - -import time -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, List, Optional - -if TYPE_CHECKING: - from meshcore_gui.services.bbs_service import BbsCommandHandler - -from meshcore_gui.config import debug_print - - -# ============================================================================== -# Bot defaults (previously in config.py) -# ============================================================================== - -# Channel indices the bot listens on (must match device channels). -BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot - -# Display name prepended to every bot reply. -BOT_NAME: str = "ZwolsBotje" - -# Minimum seconds between two bot replies (prevents reply-storms). -BOT_COOLDOWN_SECONDS: float = 5.0 - -# Keyword β†’ reply template mapping. -# Available variables: {bot}, {sender}, {snr}, {path} -# The bot checks whether the incoming message text *contains* the keyword -# (case-insensitive). First match wins. -BOT_KEYWORDS: Dict[str, str] = { - 'test': '@[{sender}], rcvd | SNR {snr} | {path}', - 'ping': 'Pong!', - 'help': 'test, ping, help', -} - - -@dataclass -class BotConfig: - """Configuration for :class:`MeshBot`. - - Attributes: - channels: Channel indices to listen on. - name: Display name prepended to replies. - cooldown_seconds: Minimum seconds between replies. - keywords: Keyword β†’ reply template mapping. - """ - - channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS)) - name: str = BOT_NAME - cooldown_seconds: float = BOT_COOLDOWN_SECONDS - keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS)) - - -class MeshBot: - """Keyword-triggered auto-reply bot. - - The bot checks incoming messages against a set of keyword β†’ template - pairs. When a keyword is found (case-insensitive substring match, - first match wins), the template is expanded and queued as a channel - message via *command_sink*. - - Args: - config: Bot configuration. - command_sink: Callable that enqueues a command dict for the - worker (typically ``shared.put_command``). - enabled_check: Callable that returns ``True`` when the bot is - enabled (typically ``shared.is_bot_enabled``). - """ - - def __init__( - self, - config: BotConfig, - command_sink: Callable[[Dict], None], - enabled_check: Callable[[], bool], - bbs_handler: Optional["BbsCommandHandler"] = None, - ) -> None: - self._config = config - self._sink = command_sink - self._enabled = enabled_check - self._last_reply: float = 0.0 - self._bbs_handler = bbs_handler - - def check_and_reply( - self, - sender: str, - text: str, - channel_idx: Optional[int], - snr: Optional[float], - path_len: int, - path_hashes: Optional[List[str]] = None, - ) -> None: - """Evaluate an incoming message and queue a reply if appropriate. - - Guards (in order): - 1. Bot is enabled (checkbox in GUI). - 2. Message is on the configured channel. - 3. Sender is not the bot itself. - 4. Sender name does not end with ``'Bot'`` (prevent loops). - 5. Cooldown period has elapsed. - 6. Message text contains a recognised keyword. - """ - # Guard 1: enabled? - if not self._enabled(): - return - - # Guard 2: correct channel? - if channel_idx not in self._config.channels: - return - - # Guard 3: own messages? - if sender == "Me" or (text and text.startswith(self._config.name)): - return - - # Guard 4: other bots? - if sender and sender.rstrip().lower().endswith("bot"): - debug_print(f"BOT: skipping message from other bot '{sender}'") - return - - # Guard 5: cooldown? - now = time.time() - if now - self._last_reply < self._config.cooldown_seconds: - debug_print("BOT: cooldown active, skipping") - return - - # BBS routing: delegate !bbs commands to BbsCommandHandler - if self._bbs_handler is not None: - text_stripped = (text or "").strip() - if text_stripped.lower().startswith("!bbs"): - bbs_reply = self._bbs_handler.handle( - channel_idx=channel_idx, - sender=sender, - sender_key="", # sender_key not available at this call-site - text=text_stripped, - ) - if bbs_reply is not None: - self._last_reply = now - self._sink({ - "action": "send_message", - "channel": channel_idx, - "text": bbs_reply, - "_bot": True, - }) - debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}") - return # Do not fall through to keyword matching - - # Guard 6: keyword match - template = self._match_keyword(text) - if template is None: - return - - # Build reply - path_str = self._format_path(path_len, path_hashes) - snr_str = f"{snr:.1f}" if snr is not None else "?" - reply = template.format( - bot=self._config.name, - sender=sender or "?", - snr=snr_str, - path=path_str, - ) - - self._last_reply = now - - self._sink({ - "action": "send_message", - "channel": channel_idx, - "text": reply, - "_bot": True, - }) - debug_print(f"BOT: queued reply to '{sender}': {reply}") - - # ------------------------------------------------------------------ - # Extension point (OCP) - # ------------------------------------------------------------------ - - def _match_keyword(self, text: str) -> Optional[str]: - """Return the reply template for the first matching keyword. - - Override this method for custom matching strategies (regex, - exact match, priority ordering, etc.). - - Returns: - Template string, or ``None`` if no keyword matched. - """ - text_lower = (text or "").lower() - for keyword, template in self._config.keywords.items(): - if keyword in text_lower: - return template - return None - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - @staticmethod - def _format_path( - path_len: int, - path_hashes: Optional[List[str]], - ) -> str: - """Format path info as ``path(N); ``path(0)``.""" - if not path_len: - return "path(0)" - return f"path({path_len})" diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index 2eb5a35..184fcda 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -66,7 +66,7 @@ class BbsBoard: categories: Valid category tags for this board. regions: Optional region tags; empty = no region filtering. retention_hours: Message retention period in hours. - allowed_keys: Sender public key whitelist (empty = all allowed). + allowed_keys: Sender public key whitelist for DM-BBS access. """ id: str @@ -330,9 +330,9 @@ class BbsConfigStore: ) -> None: """Save the board configuration. - Multiple channels can be assigned. Every sender seen on any of - these channels is automatically eligible for DM access (the - worker calls :meth:`add_allowed_key` when it sees them). + Multiple channels can be assigned. DM-BBS access is controlled by the + whitelist; senders are added only through the explicit ``!bbs`` + bootstrap on the linked channel. The board id is always ``'bbs_board'``. The board name is built from the channel names in *channel_names*. @@ -343,8 +343,7 @@ class BbsConfigStore: categories: Category tag list. retention_hours: Message retention period in hours. regions: Optional region tags. - allowed_keys: Manual sender key whitelist seed (auto-learned - keys are added via :meth:`add_allowed_key`). + allowed_keys: Manual sender key whitelist seed. """ name = ", ".join( channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices) @@ -383,8 +382,8 @@ class BbsConfigStore: def add_allowed_key(self, sender_key: str) -> bool: """Add *sender_key* to the board's allowed_keys whitelist. - Called automatically by the worker whenever a sender is seen on - a configured BBS channel. No-op if the key is already present + Called when the explicit ``!bbs`` bootstrap is received on the linked + BBS channel. No-op if the key is already present or if no board is configured. Args: diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 834c62a..90951b2 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -377,13 +377,12 @@ class BbsCommandHandler: """Handle a channel message on a configured BBS channel. Called from ``EventHandler.on_channel_msg`` **after** the message - has been stored. Two responsibilities: + has been stored. - 1. **Auto-whitelist**: every sender seen on a BBS channel gets their - key added to ``allowed_keys`` so they can use DMs afterwards. - 2. **Bootstrap reply**: if the message starts with ``!``, reply on - the channel so the sender knows the BBS is active and receives - the abbreviation table. + Design rule: + - only ``!bbs`` is handled on the linked BBS channel; + - it whitelists the sender public key for later DM-BBS use; + - help/read/post/search commands are DM-only. Args: channel_idx: MeshCore channel index the message arrived on. @@ -400,46 +399,16 @@ class BbsCommandHandler: if channel_idx not in board.channels: return None - # Auto-whitelist: register this sender so they can use DMs - if sender_key: - self._config_store.add_allowed_key(sender_key) - - # Bootstrap reply only for !-commands text = (text or "").strip() - if not text.startswith("!"): + if text.lower() != "!bbs": return None - - first = text.split()[0].lower() - channel_for_post = channel_idx - - if first in ("!p",): - rest = text[len(first):].strip() - return self._handle_post_short(board, channel_for_post, sender, sender_key, rest) - - if first in ("!r",): - rest = text[len(first):].strip() - return self._handle_read_short(board, rest) - - if first in ("!s",): - rest = text[len(first):].strip() - return self._handle_search(board, rest) - - if first in ("!help", "!h"): - return self._handle_help(board) - - if first == "!bbs": - parts = text.split(None, 2) - sub = parts[1].lower() if len(parts) > 1 else "" - rest = parts[2] if len(parts) > 2 else "" - if sub == "post": - return self._handle_post(board, channel_for_post, sender, sender_key, rest) - if sub == "read": - return self._handle_read(board, rest) - if sub in ("help", ""): - return self._handle_help(board) - return f"Unknown subcommand '{sub}'. " + self._handle_help(board) - - return None + if not sender_key: + debug_print("BBS: !bbs ignored on channel because sender key is empty") + return "BBS whitelist failed: sender key unknown." + added = self._config_store.add_allowed_key(sender_key) + if added: + return "Add to BBS OK. Use !h in DM-BBS for help." + return "Already on BBS whitelist. Use !h in DM-BBS for help." # ------------------------------------------------------------------ @@ -476,13 +445,25 @@ class BbsCommandHandler: debug_print("BBS: no board configured, ignoring DM") return None - # Whitelist check - if board.allowed_keys and sender_key not in board.allowed_keys: - debug_print( - f"BBS: silently dropping DM from {sender} " - f"(key not in whitelist for board '{board.id}')" - ) - return None + # Whitelist check (accept full-key/prefix matches in both directions) + if board.allowed_keys: + sender_key_up = (sender_key or "").upper() + allowed = False + for key in board.allowed_keys: + key_up = (key or "").upper() + if sender_key_up and key_up and ( + sender_key_up == key_up + or sender_key_up.startswith(key_up) + or key_up.startswith(sender_key_up) + ): + allowed = True + break + if not allowed: + debug_print( + f"BBS: silently dropping DM from {sender} " + f"(key not in whitelist for board '{board.id}')" + ) + return None # Channel for storing posted messages channel_idx = board.channels[0] if board.channels else 0 @@ -511,9 +492,9 @@ class BbsCommandHandler: return self._handle_post(board, channel_idx, sender, sender_key, rest) if sub == "read": return self._handle_read(board, rest) - if sub in ("help", ""): + if sub == "help": return self._handle_help(board) - return f"Unknown subcommand '{sub}'. " + self._handle_help(board) + return "Use !bbs on the linked channel for whitelist bootstrap." # Unknown !-command return None @@ -553,7 +534,8 @@ class BbsCommandHandler: abbrevs = self.compute_abbreviations(categories) # abbrevs maps prefix β†’ full name; invert for display inv = {v: k for k, v in abbrevs.items()} - return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv) + cats_upper = [c.upper() for c in categories] + return " ".join(f"{inv[c]}={c}" for c in cats_upper if c in inv) def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]: """Resolve *token* to a category via exact match or abbreviation. @@ -633,7 +615,7 @@ class BbsCommandHandler: Range syntax: ``!r U 6-10`` returns messages 6 to 10 (1-indexed, newest first). Without a range the default is 1-5 (five most recent). - ``!r`` without any arguments always includes the abbreviation table. + Bare ``!r`` returns the most recent messages across all categories. """ regions = board.regions categories = board.categories @@ -670,7 +652,7 @@ class BbsCommandHandler: return self._format_messages( board, region, category, offset=offset, limit=limit, - include_abbrevs=not args, + include_abbrevs=False, ) def _handle_search(self, board, args): diff --git a/observer_config.template.yaml b/meshcore_observer/observer_config.template.yaml similarity index 100% rename from observer_config.template.yaml rename to meshcore_observer/observer_config.template.yaml diff --git a/observer_config.yaml b/meshcore_observer/observer_config.yaml similarity index 100% rename from observer_config.yaml rename to meshcore_observer/observer_config.yaml