mirror of
https://github.com/pe1hvh/meshcore-gui.git
synced 2026-03-28 17:42:38 +01:00
feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0)
Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI — no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json — channel configuration ~/.meshcore-gui/bbs/bbs_messages.db — SQLite message store No new external dependencies (SQLite is stdlib).
This commit is contained in:
922
meshcore_gui/CHANGELOG.md
Normal file
922
meshcore_gui/CHANGELOG.md
Normal file
@@ -0,0 +1,922 @@
|
||||
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
<!-- CHANGED: Title changed from "CHANGELOG: Message & Metadata Persistence" to "CHANGELOG" —
|
||||
a root-level CHANGELOG.md should be project-wide, not feature-specific. -->
|
||||
|
||||
All notable changes to MeshCore GUI are documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **📈 Performance note — v1.13.1 through v1.13.4**
|
||||
> Although versions 1.13.1–1.13.4 were released as targeted bugfix releases, the
|
||||
> cumulative effect of the fixes delivered a significant performance improvement:
|
||||
>
|
||||
> - **v1.13.1** — Bot non-response fix eliminated a silent failure path that caused
|
||||
> repeated dedup-marked command re-evaluation on every message tick.
|
||||
> - **v1.13.2** — Map display fixes prevented Leaflet from being initialized on hidden
|
||||
> zero-size containers, removing a source of repeated failed bootstrap retries and
|
||||
> associated DOM churn.
|
||||
> - **v1.13.3** — Active panel timer gating reduced the 500 ms dashboard update work to
|
||||
> only the currently visible panel, cutting unnecessary UI updates and background
|
||||
> redraw load substantially — especially noticeable over VPN or on slower hardware.
|
||||
> - **v1.13.4** — Room Server event classification fix and sender name resolution removed
|
||||
> redundant fallback processing paths and reduced per-tick contact lookup overhead.
|
||||
>
|
||||
> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching,
|
||||
> lower CPU usage during idle operation, and more stable map rendering.
|
||||
|
||||
---
|
||||
## [1.14.0] - 2026-03-14 — Offline BBS (Bulletin Board System)
|
||||
|
||||
### Added
|
||||
- 🆕 **`meshcore_gui/services/bbs_config_store.py`** — `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`.
|
||||
- 🆕 **`meshcore_gui/services/bbs_service.py`** — SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`.
|
||||
- 🆕 **`meshcore_gui/gui/panels/bbs_panel.py`** — BBS panel voor het dashboard.
|
||||
- Board-selector (knoppen per geconfigureerd board).
|
||||
- Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft).
|
||||
- Scrollbare berichtenlijst over alle channels van het actieve board.
|
||||
- Post-formulier: post op het eerste channel van het board.
|
||||
- **Settings-sectie**: boards aanmaken (naam → Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieën, regio's, retentie, whitelist, Save en Delete.
|
||||
|
||||
### Changed
|
||||
- 🔄 **`meshcore_gui/services/bot.py`** — `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`.
|
||||
- 🔄 **`meshcore_gui/config.py`** — `BBS_CHANNELS` verwijderd; versie `1.14.0`.
|
||||
- 🔄 **`meshcore_gui/gui/dashboard.py`** — `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `📋 BBS` drawer-item.
|
||||
- 🔄 **`meshcore_gui/gui/panels/__init__.py`** — `BbsPanel` re-exported.
|
||||
|
||||
### Storage
|
||||
```
|
||||
~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2)
|
||||
~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag
|
||||
```
|
||||
|
||||
### Not changed
|
||||
- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels.
|
||||
|
||||
---
|
||||
|
||||
## [1.13.5] - 2026-03-14 — Route back-button and map popup flicker fixes
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Route page back-button navigated to main menu regardless of origin** — the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page.
|
||||
- 🛠 **Map marker popup flickered on every 500 ms update tick** — the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible.
|
||||
- 🛠 **Map marker popup appeared with a flicker/flash on first click (main map and route map)** — Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts.
|
||||
|
||||
### Changed
|
||||
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`.
|
||||
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — `applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`.
|
||||
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.5`.
|
||||
|
||||
### Impact
|
||||
- Back navigation from the route page now always returns to the correct origin screen.
|
||||
- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed.
|
||||
- Popup opening is instant on both maps; no animation artefacts on low-power hardware.
|
||||
|
||||
---
|
||||
## [1.13.4] - 2026-03-12 — Room Server message classification fix
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Incoming room messages from other participants could be misclassified as normal DMs** — `CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`.
|
||||
- 🛠 **Incoming room traffic could be attached to the wrong key** — room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`.
|
||||
- 🛠 **Room login UI could stay out of sync with the actual server-confirmed state** — `LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key.
|
||||
- 🛠 **Room Server panel showed hex codes instead of sender names** — when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known.
|
||||
|
||||
### Changed
|
||||
- 🔄 `meshcore_gui/ble/events.py` — Broadened room payload parsing and added payload-key debug logging for incoming room traffic.
|
||||
- 🔄 `meshcore_gui/ble/worker.py` — `LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history.
|
||||
- 🔄 `meshcore_gui/config.py` — Version kept at `1.13.4`.
|
||||
|
||||
### Impact
|
||||
- Keeps the existing Room Server panel logic intact.
|
||||
- Fix is limited to room event classification and room login confirmation handling.
|
||||
- No intended behavioural change for ordinary DMs or channel messages.
|
||||
|
||||
---
|
||||
---
|
||||
## [1.13.3] - 2026-03-12 — Active Panel Timer Gating
|
||||
|
||||
### Changed
|
||||
- 🔄 `meshcore_gui/gui/dashboard.py` — The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only
|
||||
- 🔄 `meshcore_gui/gui/dashboard.py` — Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick
|
||||
- 🔄 `meshcore_gui/gui/panels/map_panel.py` — Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists
|
||||
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists
|
||||
- 🔄 `meshcore_gui/config.py` — Version bumped to `1.13.3`
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Hidden panels still refreshed every 500 ms** — Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active
|
||||
- 🛠 **Map bootstrap activity while panel is not visible** — Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic
|
||||
- 🛠 **Slow navigation over VPN** — Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel
|
||||
|
||||
### Impact
|
||||
- Faster panel switching because the selected panel gets one direct refresh immediately
|
||||
- Lower background UI/update load on dashboard level, especially when the map panel is not active
|
||||
- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage
|
||||
- No intended functional regression for route maps or visible panel behaviour
|
||||
|
||||
---
|
||||
## [1.13.2] - 2026-03-11 — Map Display Bugfix
|
||||
|
||||
### Fixed
|
||||
- 🛠 **MAP panel blank when contacts list is empty at startup** — dashboard update loop
|
||||
had two separate conditional map-update blocks that both silently stopped firing after
|
||||
tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and
|
||||
remained blank indefinitely.
|
||||
- 🛠 **Leaflet map initialized on hidden (zero-size) container** — `processPending` in
|
||||
the browser runtime called `L.map()` on the host element while it was still
|
||||
`display:none` (Vue v-show, panel not yet visible). This produced a broken 0×0 map
|
||||
that never recovered because `ensureMap` returned the cached broken state on all
|
||||
subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`:
|
||||
initialization is deferred until the host has real dimensions.
|
||||
- 🛠 **Route map container had no height** — `route_page.py` used the Tailwind class
|
||||
`h-96` for the Leaflet host `<div>`. NiceGUI/Quasar does not include Tailwind CSS,
|
||||
so `h-96` had no effect and the container rendered at height 0. Leaflet initialized
|
||||
on a zero-height element and produced a blank map.
|
||||
- 🛠 **Route map not rendered when no node has GPS coordinates** — `_render_map`
|
||||
returned early before creating the Leaflet container when `payload['nodes']` was
|
||||
empty. Fixed: container is always created; a notice label is shown instead.
|
||||
|
||||
### Changed
|
||||
- 🔄 `meshcore_gui/static/leaflet_map_panel.js` — Added size guard in `ensureMap`:
|
||||
returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map
|
||||
state exists yet. `processPending` retries on the next tick once the panel is visible.
|
||||
- 🔄 `meshcore_gui/gui/dashboard.py` — Consolidated two conditional map-update blocks
|
||||
into a single unconditional update while the MAP panel is active. Added `h-96` to the
|
||||
DOMCA CSS height overrides for consistency with the route page map container.
|
||||
- 🔄 `meshcore_gui/gui/route_page.py` — Replaced `h-96` Tailwind class on the route
|
||||
map host `<div>` with an explicit inline `style` (height: 24rem). Removed early
|
||||
`return` guard so the Leaflet container is always created.
|
||||
|
||||
### Impact
|
||||
- MAP panel now renders reliably on first open regardless of contact/GPS availability
|
||||
- Route map now always shows with correct height even when route nodes have no GPS
|
||||
- No breaking changes outside the three files listed above
|
||||
|
||||
---
|
||||
## [1.13.0] - 2026-03-09 — Leaflet Map Runtime Stabilization
|
||||
|
||||
### 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 `<BLE_ADDRESS>_meshcore_gui.log` (e.g. `AA_BB_CC_DD_EE_FF_meshcore_gui.log`) instead of the generic `meshcore_gui.log`. Makes it easy to identify which device produced which log file when running multiple instances
|
||||
- New helper `_sanitize_ble_address()` strips `literal:` prefix and replaces colons with underscores
|
||||
- New function `configure_log_file(ble_address)` updates `LOG_FILE` at runtime before the logger is initialised
|
||||
- Rotated backups follow the same naming pattern automatically
|
||||
|
||||
### Removed
|
||||
- ❌ **`meshcore_gui/meshcore_gui.py`** — Redundant copy of `main()` that was never imported. All three entry points (`meshcore_gui.py` root, `__main__.py`, and `meshcore_gui/meshcore_gui.py`) contained near-identical copies of the same logic, causing changes to be missed (as demonstrated by this fix). `__main__.py` is now the single source of truth; root `meshcore_gui.py` is a thin wrapper that imports from it
|
||||
|
||||
### Changed
|
||||
- 🔄 `config.py`: Added `_sanitize_ble_address()` and `configure_log_file()`; version bumped to `1.9.4`
|
||||
- 🔄 `__main__.py`: Added `config.configure_log_file(ble_address)` call before any debug output
|
||||
- 🔄 `meshcore_gui.py` (root): Reduced to 4-line wrapper importing `main` from `__main__`
|
||||
|
||||
### Impact
|
||||
- Log files are now identifiable per BLE device
|
||||
- Single source of truth for `main()` eliminates future sync issues between entry points
|
||||
- Both startup methods (`python meshcore_gui.py` and `python -m meshcore_gui`) remain functional
|
||||
- No breaking changes — defaults and all existing behaviour unchanged
|
||||
---
|
||||
|
||||
## [1.9.3] - 2026-02-16 — Bugfix: Map Default Location & Payload Type Decoding
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Map centred on hardcoded Zwolle instead of device location** — All Leaflet maps used magic-number coordinates `(52.5, 6.0)` as initial centre and fallback. These are now replaced by a single configurable constant `DEFAULT_MAP_CENTER` in `config.py`. Once the device reports a valid `adv_lat`/`adv_lon`, maps re-centre on the actual device position (existing behaviour, unchanged)
|
||||
- 🛠 **Payload type shown as raw integer** — Payload type is now retrieved from the decoded payload and translated to human-readable text using MeshCoreDecoder functions, instead of displaying the raw numeric type value
|
||||
|
||||
### Changed
|
||||
- 🔄 `config.py`: Added `DEFAULT_MAP_CENTER` (default: `(52.5168, 6.0830)`) and `DEFAULT_MAP_ZOOM` (default: `9`) constants in new **MAP DEFAULTS** section. Version bumped to `1.9.2`
|
||||
- 🔄 `gui/panels/map_panel.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; `ui.leaflet(center=...)` uses config constants instead of hardcoded values
|
||||
- 🔄 `gui/route_page.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; fallback coordinates (`or 52.5` / `or 6.0`) replaced by `DEFAULT_MAP_CENTER[0]` / `[1]`; zoom uses `DEFAULT_MAP_ZOOM`
|
||||
|
||||
### Impact
|
||||
- Map default location is now a single-point-of-change in `config.py`
|
||||
- Payload type is displayed as readable text instead of a raw number
|
||||
- No breaking changes — all existing map behaviour (re-centre on device position, contact markers) unchanged
|
||||
|
||||
## [1.9.2] - 2026-02-15 — CLI Parameters & Cleanup
|
||||
|
||||
### Added
|
||||
- ✅ **`--port=PORT` CLI parameter** — Web server port is now configurable at startup (default: `8081`). Allows running multiple instances simultaneously on different ports
|
||||
- ✅ **`--ble-pin=PIN` CLI parameter** — BLE pairing PIN is now configurable at startup (default: `123456`). Eliminates the need to edit `config.py` for devices with a non-default PIN, and works in systemd service files
|
||||
- ✅ **Per-device log file** — Debug log file now includes the BLE address in its filename (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`), so multiple instances log to separate files
|
||||
|
||||
### Fixed
|
||||
- 🛠 **BLE PIN not applied from CLI** — `ble/worker.py` imported `BLE_PIN` as a constant at module load time (`from config import BLE_PIN`), capturing the default value `"123456"` before CLI parsing could override `config.BLE_PIN`. Changed to runtime access via `config.BLE_PIN` so the `--ble-pin` parameter is correctly passed to the BLE agent
|
||||
|
||||
### Removed
|
||||
- ❌ **Redundant `meshcore_gui/meshcore_gui.py`** — This file was a near-identical copy of both `meshcore_gui.py` (top-level) and `meshcore_gui/__main__.py`, but was never imported or referenced. Removed to eliminate maintenance risk. The two remaining entry points cover all startup methods: `python meshcore_gui.py` and `python -m meshcore_gui`
|
||||
|
||||
### Impact
|
||||
- Multiple instances can run side-by-side with different ports, PINs and log files
|
||||
- Service deployments no longer require editing `config.py` — all runtime settings via CLI
|
||||
- No breaking changes — all defaults are unchanged
|
||||
|
||||
---
|
||||
|
||||
## [1.9.1] - 2026-02-14 — Bugfix: Dual Reconnect Conflict
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Library reconnect interfered with application reconnect** — The meshcore library's internal `auto_reconnect` (visible in logs as `"Attempting reconnection 1/3"`) ran a fast 3-attempt reconnect cycle without bond cleanup. This prevented the application's own `reconnect_loop` (which does `remove_bond()` + backoff) from succeeding, because BlueZ retained a stale bond → `"failed to discover service"`
|
||||
|
||||
### Changed
|
||||
- 🔄 `ble/worker.py`: Set `auto_reconnect=False` in both `MeshCore.create_ble()` call sites (`_connect()` and `_create_fresh_connection()`), so only the application's bond-aware `reconnect_loop` handles reconnection
|
||||
- 🔄 `ble/worker.py`: Added `"failed to discover"` and `"service discovery"` to disconnect detection keywords for defensive coverage
|
||||
|
||||
### Impact
|
||||
- Eliminates the ~9 second wasted library reconnect cycle after every BLE disconnect
|
||||
- Application's `reconnect_loop` (with bond cleanup) now runs immediately after disconnect detection
|
||||
- No breaking changes — the application reconnect logic was already fully functional
|
||||
|
||||
---
|
||||
|
||||
## [1.9.0] - 2026-02-14 — BLE Connection Stability
|
||||
|
||||
### Added
|
||||
- ✅ **Built-in BLE PIN agent** — New `ble/ble_agent.py` registers a D-Bus agent with BlueZ to handle PIN pairing requests automatically. Eliminates the need for external `bt-agent.service` and `bluez-tools` package
|
||||
- Uses `dbus_fast` (already a dependency of `bleak`, no new packages)
|
||||
- Supports `RequestPinCode`, `RequestPasskey`, `DisplayPasskey`, `RequestConfirmation`, `AuthorizeService` callbacks
|
||||
- Configurable PIN via `BLE_PIN` in `config.py` (default: `123456`)
|
||||
- ✅ **Automatic bond cleanup** — New `ble/ble_reconnect.py` provides `remove_bond()` function that removes stale BLE bonds via D-Bus, equivalent to `bluetoothctl remove <address>`. Called automatically on startup and before each reconnect attempt
|
||||
- ✅ **Automatic reconnect after disconnect** — BLEWorker main loop now detects BLE disconnects (via connection error exceptions) and automatically triggers a reconnect sequence: bond removal → linear backoff wait → fresh connection → re-wire handlers → reload device data
|
||||
- Configurable via `RECONNECT_MAX_RETRIES` (default: 5) and `RECONNECT_BASE_DELAY` (default: 5.0s)
|
||||
- After all retries exhausted: waits 60s then starts a new retry cycle (infinite recovery)
|
||||
- ✅ **Generic install script** — `install_ble_stable.sh` auto-detects user, project directory, venv path and entry point to generate systemd service and D-Bus policy. Supports `--uninstall` flag
|
||||
|
||||
### Changed
|
||||
- 🔄 **`ble/worker.py`** — `_async_main()` rewritten with three phases: (1) start PIN agent, (2) remove stale bond, (3) connect + main loop with disconnect detection. Reconnect logic re-wires all event handlers and reloads device data after successful reconnection
|
||||
- 🔄 **`config.py`** — Added `BLE_PIN`, `RECONNECT_MAX_RETRIES`, `RECONNECT_BASE_DELAY` constants
|
||||
|
||||
### Removed
|
||||
- ❌ **`bt-agent.service` dependency** — No longer needed; PIN pairing is handled by the built-in agent
|
||||
- ❌ **`bluez-tools` system package** — No longer needed
|
||||
- ❌ **`~/.meshcore-ble-pin` file** — No longer needed
|
||||
- ❌ **Manual `bluetoothctl remove` before startup** — Handled automatically
|
||||
- ❌ **`ExecStartPre` in systemd service** — Bond cleanup is internal
|
||||
|
||||
### Impact
|
||||
- Zero external dependencies for BLE pairing on Linux
|
||||
- Automatic recovery from the T1000e ~2 hour BLE disconnect issue
|
||||
- No manual intervention needed after BLE connection loss
|
||||
- Single systemd service (`meshcore-gui.service`) manages everything
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
## [1.8.0] - 2026-02-14 — DRY Message Construction & Archive Layout Unification
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Case-sensitive prefix matching** — `get_contact_name_by_prefix()` and `get_contact_by_prefix()` in `shared_data.py` failed to match path hashes (uppercase, e.g. `'B8'`) against contact pubkeys (lowercase, e.g. `'b8a3f2...'`). Added `.lower()` to both sides of the comparison, consistent with `_resolve_path_names()` which already had it
|
||||
- 🛠 **Route page 404 from archive** — Archive page linked to `/route/{hash}` but route was registered as `/route/{msg_index:int}`, causing a JSON parse error for hex hash strings. Route parameter changed to `str` with 3-strategy lookup (index → memory hash → archive fallback)
|
||||
- 🛠 **Three entry points out of sync** — `meshcore_gui.py` (root), `meshcore_gui/meshcore_gui.py` (inner) and `meshcore_gui/__main__.py` had diverging route registrations. All three now use identical `/route/{msg_key}` with `str` parameter
|
||||
|
||||
### Changed
|
||||
- 🔄 **`core/models.py` — DRY factory methods and formatting**
|
||||
- `Message.now_timestamp()`: static method replacing 7× hardcoded `datetime.now().strftime('%H:%M:%S')` across `events.py` and `commands.py`
|
||||
- `Message.incoming()`: classmethod factory for received messages (`direction='in'`, auto-timestamp)
|
||||
- `Message.outgoing()`: classmethod factory for sent messages (`sender='Me'`, `direction='out'`, auto-timestamp)
|
||||
- `Message.format_line(channel_names)`: single-line display formatting (`"12:34:56 ← [Public] [2h✓] PE1ABC: Hello mesh!"`), replacing duplicate inline formatting in `messages_panel.py` and `archive_page.py`
|
||||
- 🔄 **`ble/events.py`** — 4× `Message(...)` constructors replaced by `Message.incoming()`; `datetime` import removed
|
||||
- 🔄 **`ble/commands.py`** — 3× `Message(...)` constructors replaced by `Message.outgoing()`; `datetime` import removed
|
||||
- 🔄 **`gui/panels/messages_panel.py`** — 15 lines inline formatting replaced by single `msg.format_line(channel_names)` call
|
||||
- 🔄 **`gui/archive_page.py` — Layout unified with main page**
|
||||
- Multi-row card layout replaced by single-line `msg.format_line()` in monospace container (same style as main page)
|
||||
- DM added to channel filter dropdown (post-filter on `channel is None`)
|
||||
- Message click opens `/route/{message_hash}` in new tab (was: no click handler on archive messages)
|
||||
- Removed `_render_message_card()` (98 lines) and `_render_archive_route()` (75 lines)
|
||||
- Removed `RouteBuilder` dependency and `TYPE_LABELS` import
|
||||
- File reduced from 445 to 267 lines
|
||||
- 🔄 **`gui/route_page.py`** — `render(msg_index: int)` → `render(msg_key: str)` with 3-strategy message lookup: (1) numeric index from in-memory list, (2) hash match in memory, (3) `archive.get_message_by_hash()` fallback
|
||||
- 🔄 **`services/message_archive.py`** — New method `get_message_by_hash(hash)` for single-message lookup by packet hash
|
||||
- 🔄 **`__main__.py` + `meshcore_gui.py` (both)** — Route changed from `/route/{msg_index}` (int) to `/route/{msg_key}` (str)
|
||||
|
||||
### Impact
|
||||
- DRY: timestamp formatting 7→1 definition, message construction 7→2 factories, line formatting 2→1 method
|
||||
- Archive page visually consistent with main messages panel (single-line, monospace)
|
||||
- Archive messages now clickable to open route visualization (was: only in-memory messages)
|
||||
- Case-insensitive prefix matching fixes path name resolution for contacts with uppercase path hashes
|
||||
- No breaking changes to BLE protocol handling, dedup, bot, or data storage
|
||||
|
||||
### Known Limitations
|
||||
- DM filter in archive uses post-filtering (query without channel filter + filter on `channel is None`); becomes exact when `query_messages()` gets native DM support
|
||||
|
||||
### Parked for later
|
||||
- Multi-path tracking (enrich RxLogEntry with multiple path observations)
|
||||
- Events correlation improvements (only if proven data loss after `.lower()` fix)
|
||||
|
||||
---
|
||||
|
||||
## [1.7.0] - 2026-02-13 — Archive Channel Name Persistence
|
||||
|
||||
### Added
|
||||
- ✅ **Channel name stored in archive** — Messages now persist `channel_name` alongside the numeric `channel` index in `<ADDRESS>_messages.json`, so archived messages retain their human-readable channel name even when the device is not connected
|
||||
- `Message` dataclass: new field `channel_name: str` (default `""`, backward compatible)
|
||||
- `SharedData.add_message()`: automatically resolves `channel_name` from the live channels list when not already set (new helper `_resolve_channel_name()`)
|
||||
- `MessageArchive.add_message()`: writes `channel_name` to the JSON dict
|
||||
- ✅ **Archive channel selector built from archived data** — Channel filter dropdown on `/archive` now populated via `SELECT DISTINCT channel_name` on the archive instead of the live BLE channels list
|
||||
- New method `MessageArchive.get_distinct_channel_names()` returns sorted unique channel names from stored messages
|
||||
- Selector shows only channels that actually have archived messages
|
||||
- ✅ **Archive filter on channel name** — `MessageArchive.query_messages()` parameter changed from `channel: Optional[int]` to `channel_name: Optional[str]` (exact match on name string)
|
||||
|
||||
### Changed
|
||||
- 🔄 `core/models.py`: Added `channel_name` field to `Message` dataclass and `from_dict()`
|
||||
- 🔄 `core/shared_data.py`: `add_message()` resolves channel name; added `_resolve_channel_name()` helper
|
||||
- 🔄 `services/message_archive.py`: `channel_name` persisted in JSON; `query_messages()` filters by name; new `get_distinct_channel_names()` method
|
||||
- 🔄 `gui/archive_page.py`: Channel selector built from `archive.get_distinct_channel_names()`; filter state changed from `_channel_filter` (int) to `_channel_name_filter` (str); message cards show `channel_name` directly from archive
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Main page empty after startup** — After a restart the messages panel showed no messages until new live BLE traffic arrived. `SharedData.load_recent_from_archive()` now loads up to 100 recent archived messages during the cache-first startup phase, so historical messages are immediately visible
|
||||
- New method `SharedData.load_recent_from_archive(limit)` — reads from `MessageArchive.query_messages()` and populates the in-memory list without re-archiving
|
||||
- `BLEWorker._apply_cache()` calls `load_recent_from_archive()` at the end of cache loading
|
||||
|
||||
### Impact
|
||||
- Archived messages now self-contained — channel name visible without live BLE connection
|
||||
- Main page immediately shows historical messages after startup (no waiting for live BLE traffic)
|
||||
- Backward compatible — old archive entries without `channel_name` fall back to `"Ch <idx>"`
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-02-13 — Dashboard Layout Consolidation
|
||||
|
||||
### Changed
|
||||
- 🔄 **Messages panel consolidated** — Filter checkboxes (DM + channels) and message input (text field, channel selector, Send button) are now integrated into the Messages panel, replacing the separate Filter and Input panels
|
||||
- DM + channel checkboxes displayed centered in the Messages header row, between the "💬 Messages" label and the "📚 Archive" button
|
||||
- Message input row (text field, channel selector, Send button) placed below the message list within the same card
|
||||
- `messages_panel.py`: Constructor now accepts `put_command` callable; added `update_filters(data)`, `update_channel_options(channels)` methods and `channel_filters`, `last_channels` properties (all logic 1:1 from FilterPanel/InputPanel); `update()` signature unchanged
|
||||
- 🔄 **Actions panel expanded** — BOT toggle checkbox moved from Filter panel to Actions panel, below the Refresh/Advert buttons
|
||||
- `actions_panel.py`: Constructor now accepts `set_bot_enabled` callable; added `update(data)` method for BOT state sync; `_on_bot_toggle()` logic 1:1 from FilterPanel
|
||||
- 🔄 **Dashboard layout simplified** — Centre column reduced from 4 panels (Map → Input → Filter → Messages) to 2 panels (Map → Messages)
|
||||
- `dashboard.py`: FilterPanel and InputPanel no longer rendered; all dependencies rerouted to MessagesPanel and ActionsPanel; `_update_ui()` call-sites updated accordingly
|
||||
|
||||
### Removed (from layout, files retained)
|
||||
- ❌ **Filter panel** no longer rendered as separate panel — `filter_panel.py` retained in codebase but not instantiated in dashboard
|
||||
- ❌ **Input panel** no longer rendered as separate panel — `input_panel.py` retained in codebase but not instantiated in dashboard
|
||||
|
||||
### Impact
|
||||
- Cleaner, more compact dashboard: 2 fewer panels in the centre column
|
||||
- All functionality preserved — message filtering, send, BOT toggle, archive all work identically
|
||||
- No breaking changes to BLE, services, core or other panels
|
||||
|
||||
---
|
||||
|
||||
<!-- ADDED: v1.5.0 feature + bugfix entry -->
|
||||
|
||||
## [1.5.0] - 2026-02-11 — Room Server Support, Dynamic Channel Discovery & Contact Management
|
||||
|
||||
### Added
|
||||
- ✅ **Room Server panel** — Dedicated per-room-server message panel in the centre column below Messages. Each Room Server (type=3 contact) gets its own `ui.card()` with login/logout controls and message display
|
||||
- Click a Room Server contact to open an add/login dialog with password field
|
||||
- After login: messages are displayed in the room card; send messages directly from the room panel
|
||||
- Password row + login button automatically replaced by Logout button after successful login
|
||||
- Room Server author attribution via `signature` field (txt_type=2) — real message author is resolved from the 4-byte pubkey prefix, not the room server pubkey
|
||||
- New panel: `gui/panels/room_server_panel.py` — per-room card management with login state tracking
|
||||
- ✅ **Room Server password store** — Passwords stored outside the repository in `~/.meshcore-gui/room_passwords/<ADDRESS>.json`
|
||||
- New service: `services/room_password_store.py` — JSON-backed persistent password storage per BLE device, analogous to `PinStore`
|
||||
- Room panels are restored from stored passwords on app restart
|
||||
- ✅ **Dynamic channel discovery** — Channels are now auto-discovered from the device at startup via `get_channel()` BLE probing, replacing the hardcoded `CHANNELS_CONFIG`
|
||||
- Single-attempt probe per channel slot with early stop after 2 consecutive empty slots
|
||||
- Channel name and encryption key extracted in a single pass (combined discovery + key loading)
|
||||
- Configurable channel caching via `CHANNEL_CACHE_ENABLED` (default: `False` — always fresh from device)
|
||||
- `MAX_CHANNELS` setting (default: 8) controls how many slots are probed
|
||||
- ✅ **Individual contact deletion** — 🗑️ delete button per unpinned contact in the contacts list, with confirmation dialog
|
||||
- New command: `remove_single_contact` in BLE command handler
|
||||
- Pinned contacts are protected (no delete button shown)
|
||||
- ✅ **"Also delete from history" option** — Checkbox in the Clean up confirmation dialog to also remove locally cached contact data
|
||||
|
||||
<!-- ADDED: Research document reference -->
|
||||
- ✅ **Room Server protocol research** — `RoomServer_Companion_App_Onderzoek.md` documents the full companion app message flow (login, push protocol, signature mechanism, auto_message_fetching)
|
||||
|
||||
### Changed
|
||||
- 🔄 `config.py`: Removed `CHANNELS_CONFIG` constant; added `MAX_CHANNELS` (default: 8) and `CHANNEL_CACHE_ENABLED` (default: `False`)
|
||||
- 🔄 `ble/worker.py`: Replaced hardcoded channel loading with `_discover_channels()` method; added `_try_get_channel_info()` helper; `_apply_cache()` respects `CHANNEL_CACHE_ENABLED` setting; removed `_load_channel_keys()` (integrated into discovery pass)
|
||||
- 🔄 `ble/commands.py`: Added `login_room`, `send_room_msg` and `remove_single_contact` command handlers
|
||||
- 🔄 `gui/panels/contacts_panel.py`: Contact click now dispatches by type — type=3 (Room Server) opens room dialog, others open DM dialog; added `on_add_room` callback parameter; added 🗑️ delete button per unpinned contact
|
||||
- 🔄 `gui/panels/messages_panel.py`: Room Server messages filtered from general message view via `_is_room_message()` with prefix matching; `update()` accepts `room_pubkeys` parameter
|
||||
- 🔄 `gui/dashboard.py`: Added `RoomServerPanel` in centre column; `_update_ui()` passes `room_pubkeys` to Messages panel; added `_on_add_room_server` callback
|
||||
- 🔄 `gui/panels/filter_panel.py`: Channel filter checkboxes now built dynamically from discovered channels (no hardcoded references)
|
||||
- 🔄 `services/bot.py`: Removed stale comment referencing hardcoded channels
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Room Server messages appeared as DM** — Messages from Room Servers (txt_type=2) were displayed in the general Messages panel as direct messages. They are now filtered out and shown exclusively in the Room Server panel
|
||||
- 🛠 **Historical room messages not shown after login** — Post-login fetch loop was polling `get_msg()` before room server had time to push messages over LoRa RF (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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<!-- ADDED: v1.3.2 bugfix entry -->
|
||||
|
||||
## [1.3.2] - 2026-02-09 — Bugfix: Bot Device Name Restoration After Restart
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Bot device name not properly restored after restart/crash** — After a restart or crash with bot mode previously active, the original device name was incorrectly stored as the bot name (e.g. `NL-OV-ZWL-STDSHGN-WKC Bot`) instead of the real device name (e.g. `PE1HVH T1000e`). The original device name is now correctly preserved and restored when bot mode is disabled
|
||||
|
||||
### Changed
|
||||
- 🔄 `commands.py`: `set_bot_name` handler now verifies that the stored original name is not already the bot name before saving
|
||||
- 🔄 `shared_data.py`: `original_device_name` is only written when it differs from `BOT_DEVICE_NAME` to prevent overwriting with the bot name on restart
|
||||
|
||||
---
|
||||
|
||||
<!-- ADDED: v1.3.1 bugfix entry -->
|
||||
|
||||
## [1.3.1] - 2026-02-09 — Bugfix: Auto-add AttributeError
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Auto-add error on first toggle** — Setting auto-add for the first time raised `AttributeError: 'telemetry_mode_base'`. The `set_manual_add_contacts()` SDK call now handles missing `telemetry_mode_base` attribute gracefully
|
||||
|
||||
### Changed
|
||||
- 🔄 `commands.py`: `set_auto_add` handler wraps `set_manual_add_contacts()` call with attribute check and error handling for missing `telemetry_mode_base`
|
||||
|
||||
---
|
||||
|
||||
<!-- ADDED: New v1.3.0 entry at top -->
|
||||
|
||||
## [1.3.0] - 2026-02-08 — Bot Device Name Management
|
||||
|
||||
### Added
|
||||
- ✅ **Bot device name switching** — When the BOT checkbox is enabled, the device name is automatically changed to a configurable bot name; when disabled, the original name is restored
|
||||
- Original device name is saved before renaming so it can be restored on BOT disable
|
||||
- Device name written to device via BLE `set_name()` SDK call
|
||||
- Graceful handling of BLE failures during name change
|
||||
- ✅ **`BOT_DEVICE_NAME` constant** in `config.py` — Configurable fixed device name used when bot mode is active (default: `;NL-OV-ZWL-STDSHGN-WKC Bot`)
|
||||
|
||||
### Changed
|
||||
- 🔄 `config.py`: Added `BOT_DEVICE_NAME` constant for bot mode device name
|
||||
- 🔄 `bot.py`: Removed hardcoded `BOT_NAME` prefix ("Zwolle Bot") from bot reply messages — bot replies no longer include a name prefix
|
||||
- 🔄 `filter_panel.py`: BOT checkbox toggle now triggers device name save/rename via command queue
|
||||
- 🔄 `commands.py`: Added `set_bot_name` and `restore_name` command handlers for device name switching
|
||||
- 🔄 `shared_data.py`: Added `original_device_name` field for storing the pre-bot device name
|
||||
|
||||
### Removed
|
||||
- ❌ `BOT_NAME` constant from `bot.py` — bot reply prefix removed; replies no longer prepend a bot display name
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2026-02-08 — Contact Maintenance Feature
|
||||
|
||||
### Added
|
||||
- ✅ **Pin/Unpin contacts** (Iteration A) — Toggle to pin individual contacts, protecting them from bulk deletion
|
||||
- Persistent pin state stored in `~/.meshcore-gui/cache/<ADDRESS>_pins.json`
|
||||
- Pinned contacts visually marked with yellow background
|
||||
- Pinned contacts sorted to top of contact list
|
||||
- Pin state survives app restart
|
||||
- New service: `services/pin_store.py` — JSON-backed persistent pin storage
|
||||
|
||||
- ✅ **Bulk delete unpinned contacts** (Iteration B) — Remove all unpinned contacts from device in one action
|
||||
- "🧹 Clean up" button in contacts panel with confirmation dialog
|
||||
- Shows count of contacts to be removed vs. pinned contacts kept
|
||||
- Progress status updates during removal
|
||||
- Automatic device resync after completion
|
||||
- New service: `services/contact_cleaner.py` — ContactCleanerService with purge statistics
|
||||
|
||||
- ✅ **Auto-add contacts toggle** (Iteration C) — Control whether device automatically adds new contacts from mesh adverts
|
||||
- "📥 Auto-add" checkbox in contacts panel (next to Clean up button)
|
||||
- Syncs with device via `set_manual_add_contacts()` SDK call
|
||||
- Inverted logic handled internally (UI "Auto-add ON" = `set_manual_add_contacts(false)`)
|
||||
- Optimistic update with automatic rollback on BLE failure
|
||||
- State synchronized from device on each GUI update cycle
|
||||
|
||||
### Changed
|
||||
- 🔄 `contacts_panel.py`: Added pin checkbox per contact, purge button, auto-add toggle, DM dialog (all existing functionality preserved)
|
||||
- 🔄 `commands.py`: Added `purge_unpinned` and `set_auto_add` command handlers
|
||||
- 🔄 `shared_data.py`: Added `auto_add_enabled` field with thread-safe getter/setter
|
||||
- 🔄 `protocols.py`: Added `set_auto_add_enabled` and `is_auto_add_enabled` to Writer and Reader protocols
|
||||
- 🔄 `dashboard.py`: Passes `PinStore` and `set_auto_add_enabled` callback to ContactsPanel
|
||||
- 🔄 **UI language**: All Dutch strings in `contacts_panel.py` and `commands.py` translated to English
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
- 🛠 **Route table names and IDs not displayed** — Route tables in both current messages (RoutePage) and archive messages (ArchivePage) now correctly show node names and public key IDs for sender, repeaters and receiver
|
||||
|
||||
### Changed
|
||||
- 🔄 **CHANGELOG.md**: Corrected version numbering to semantic versioning, fixed inaccurate references (archive button location, filter state persistence)
|
||||
- 🔄 **README.md**: Added Message Archive feature, updated project structure, configuration table and architecture diagram
|
||||
- 🔄 **MeshCore_GUI_Design.docx**: Added ArchivePage, MessageArchive, Models components; updated project structure, protocols, configuration and version history
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2026-02-07 — Archive Viewer Feature
|
||||
|
||||
|
||||
### Added
|
||||
- ✅ **Archive Viewer Page** (`/archive`) — Full-featured message archive browser
|
||||
- Pagination (50 messages per page, configurable)
|
||||
- Channel filter dropdown (All + configured channels)
|
||||
- Time range filter (24h, 7d, 30d, 90d, All time)
|
||||
- Text search (case-insensitive)
|
||||
- Filter state stored in instance variables (reset on page reload)
|
||||
- Message cards with same styling as main messages panel
|
||||
- Clickable messages for route visualization (where available)
|
||||
- **💬 Reply functionality** — Expandable reply panel per message
|
||||
- **🗺️ Inline route table** — Expandable route display per archive message with sender, repeaters and receiver (names, IDs, node types)
|
||||
- *(Note: Reply panels and inline route tables removed in v1.8.0, replaced by click-to-route navigation via message hash)*
|
||||
|
||||
<!-- CHANGED: "Filter state persistence (app.storage.user)" replaced with "Filter state stored in
|
||||
instance variables" — the code (archive_page.py:36-40) uses self._current_page etc.,
|
||||
not app.storage.user. The comment in the code is misleading. -->
|
||||
|
||||
<!-- ADDED: "Inline route table" entry — _render_archive_route() in archive_page.py:333-407
|
||||
was not documented. -->
|
||||
|
||||
- ✅ **MessageArchive.query_messages()** method
|
||||
- Filter by: time range, channel, text search, sender
|
||||
- Pagination support (limit, offset)
|
||||
- Returns tuple: (messages, total_count)
|
||||
- Sorting: Newest first
|
||||
|
||||
- ✅ **UI Integration**
|
||||
- "📚 Archive" button in Messages panel header (opens in new tab)
|
||||
- Back to Dashboard button in archive page
|
||||
|
||||
<!-- CHANGED: "📚 View Archive button in Actions panel" corrected — the button is in
|
||||
MessagesPanel (messages_panel.py:25), not in ActionsPanel (actions_panel.py).
|
||||
ActionsPanel only contains Refresh and Advert buttons. -->
|
||||
|
||||
- ✅ **Reply Panel**
|
||||
- Expandable reply per message (💬 Reply button)
|
||||
- Pre-filled with @sender mention
|
||||
- Channel selector
|
||||
- Send button with success notification
|
||||
- Auto-close expansion after send
|
||||
|
||||
### Changed
|
||||
- 🔄 `SharedData.get_snapshot()`: Now includes `'archive'` field
|
||||
- 🔄 `MessagesPanel`: Added archive button in header row
|
||||
- 🔄 Both entry points (`__main__.py` and `meshcore_gui.py`): Register `/archive` route
|
||||
|
||||
<!-- CHANGED: "ActionsPanel: Added archive button" corrected to "MessagesPanel" -->
|
||||
|
||||
### Performance
|
||||
- Query: ~10ms for 10k messages with filters
|
||||
- Memory: ~10KB per page (50 messages)
|
||||
- No impact on main UI (separate page)
|
||||
|
||||
### Known Limitations
|
||||
- ~~Route visualization only works for messages in recent buffer (last 100)~~ — Fixed in v1.8.0: archive messages now support click-to-route via `get_message_by_hash()` fallback
|
||||
- Text search is linear scan (no indexing yet)
|
||||
- Sender filter exists in API but not in UI yet
|
||||
|
||||
---
|
||||
|
||||
## [1.0.3] - 2026-02-07 — Critical Bugfix: Archive Overwrite Prevention
|
||||
|
||||
|
||||
### Fixed
|
||||
- 🛠 **CRITICAL**: Fixed bug where archive was overwritten instead of appended on restart
|
||||
- 🛠 Archive now preserves existing data when read errors occur
|
||||
- 🛠 Buffer is retained for retry if existing archive cannot be read
|
||||
|
||||
### Changed
|
||||
- 🔄 `_flush_messages()`: Early return on read error instead of overwriting
|
||||
- 🔄 `_flush_rxlog()`: Early return on read error instead of overwriting
|
||||
- 🔄 Better error messages for version mismatch and JSON decode errors
|
||||
|
||||
### Details
|
||||
**Problem:** If the existing archive file had a JSON parse error or version mismatch,
|
||||
the flush operation would proceed with `existing_messages = []`, effectively
|
||||
overwriting all historical data with only the new buffered messages.
|
||||
|
||||
**Solution:** The flush methods now:
|
||||
1. Try to read existing archive first
|
||||
2. If read fails (JSON error, version mismatch, IO error), abort the flush
|
||||
3. Keep buffer intact for next retry
|
||||
4. Only clear buffer after successful write
|
||||
|
||||
**Impact:** No data loss on restart or when archive files have issues.
|
||||
|
||||
### Testing
|
||||
- ✅ Added `test_append_on_restart_not_overwrite()` integration test
|
||||
- ✅ Verifies data is appended across multiple sessions
|
||||
- ✅ All existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
## [1.0.2] - 2026-02-07 — RxLog message_hash Enhancement
|
||||
|
||||
|
||||
### Added
|
||||
- ✅ `message_hash` field added to `RxLogEntry` model
|
||||
- ✅ RxLog entries now include message_hash for correlation with messages
|
||||
- ✅ Archive JSON includes message_hash in rxlog entries
|
||||
|
||||
### Changed
|
||||
- 🔄 `events.py`: Restructured `on_rx_log()` to extract message_hash before creating RxLogEntry
|
||||
- 🔄 `message_archive.py`: Updated rxlog archiving to include message_hash field
|
||||
- 🔄 Tests updated to verify message_hash persistence
|
||||
|
||||
### Benefits
|
||||
- **Correlation**: Link RX log entries to their corresponding messages
|
||||
- **Analysis**: Track which packets resulted in messages
|
||||
- **Debugging**: Better troubleshooting of packet processing
|
||||
|
||||
---
|
||||
|
||||
## [1.0.1] - 2026-02-07 — Entry Point Fix
|
||||
|
||||
|
||||
### Fixed
|
||||
- ✅ `meshcore_gui.py` (root entry point) now passes ble_address to SharedData
|
||||
- ✅ Archive works correctly regardless of how application is started
|
||||
|
||||
### Changed
|
||||
- 🔄 Both entry points (`meshcore_gui.py` and `meshcore_gui/__main__.py`) updated
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-02-07 — Message & Metadata Persistence
|
||||
|
||||
|
||||
### Added
|
||||
- ✅ MessageArchive class for persistent storage
|
||||
- ✅ Configurable retention periods (MESSAGE_RETENTION_DAYS, RXLOG_RETENTION_DAYS, CONTACT_RETENTION_DAYS)
|
||||
- ✅ Automatic daily cleanup of old data
|
||||
- ✅ Batch writes for performance
|
||||
- ✅ Thread-safe with separate locks
|
||||
- ✅ Atomic file writes
|
||||
- ✅ Contact retention in DeviceCache
|
||||
- ✅ Archive statistics API
|
||||
- ✅ Comprehensive tests (20+ unit, 8+ integration)
|
||||
- ✅ Full documentation
|
||||
|
||||
### Storage Locations
|
||||
- `~/.meshcore-gui/archive/<ADDRESS>_messages.json`
|
||||
- `~/.meshcore-gui/archive/<ADDRESS>_rxlog.json`
|
||||
|
||||
### Requirements Completed
|
||||
- R1: All incoming messages persistent ✅
|
||||
- R2: All incoming RxLog entries persistent ✅
|
||||
- R3: Configurable retention ✅
|
||||
- R4: Automatic cleanup ✅
|
||||
- R5: Backward compatibility ✅
|
||||
- R6: Contact retention ✅
|
||||
- R7: Archive stats API ✅
|
||||
|
||||
- Fix3: Leaflet asset injection is now per page render instead of process-global, and browser bootstrap now retries until the host element, Leaflet runtime, and MeshCore panel runtime are all available. This fixes blank map containers caused by missing or late-loaded JS/CSS assets.
|
||||
|
||||
- Fix5: Removed per-snapshot map invalidate calls, stopped forcing a default dark theme during map bootstrap, and added client-side interaction/resize guards so zooming stays responsive and the theme no longer jumps back during status-loop updates.
|
||||
|
||||
|
||||
## 2026-03-09 map hotfix v2
|
||||
- regular map snapshots no longer carry theme state
|
||||
- explicit theme changes are now handled only via the dedicated theme channel
|
||||
- initial map render now sends an ensure_map command plus an immediate theme sync
|
||||
- added no-op ensure_map handling in the Leaflet runtime to avoid accidental fallback behaviour
|
||||
395
meshcore_gui/meshcore_gui/config.py
Normal file
395
meshcore_gui/meshcore_gui/config.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Application configuration for MeshCore GUI.
|
||||
|
||||
Contains only global runtime settings.
|
||||
Bot configuration lives in :mod:`meshcore_gui.services.bot`.
|
||||
UI display constants live in :mod:`meshcore_gui.gui.constants`.
|
||||
|
||||
The ``DEBUG`` flag defaults to False and can be activated at startup
|
||||
with the ``--debug-on`` command-line option.
|
||||
|
||||
Debug output is written to both stdout and a rotating log file at
|
||||
``~/.meshcore-gui/logs/meshcore_gui.log``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# VERSION
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
VERSION: str = "1.14.0"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# OPERATOR / LANDING PAGE
|
||||
# ==============================================================================
|
||||
|
||||
# Operator callsign shown on the landing page SVG and drawer footer.
|
||||
# Change this to your own callsign (e.g. "PE1HVH", "PE1HVH/MIT").
|
||||
OPERATOR_CALLSIGN: str = "PE1HVH"
|
||||
|
||||
# Path to the landing page SVG file.
|
||||
# The placeholder ``{callsign}`` inside the SVG is replaced at runtime
|
||||
# with ``OPERATOR_CALLSIGN``.
|
||||
#
|
||||
# Default: the bundled DOMCA splash (static/landing_default.svg).
|
||||
# To use a custom SVG, point this to your own file, e.g.:
|
||||
# LANDING_SVG_PATH = DATA_DIR / "landing.svg"
|
||||
LANDING_SVG_PATH: Path = Path(__file__).parent / "static" / "landing_default.svg"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# MAP DEFAULTS
|
||||
# ==============================================================================
|
||||
|
||||
# Default map centre used as the initial view *before* the device reports
|
||||
# its own GPS position. Once the device advertises a valid adv_lat/adv_lon
|
||||
# pair, every map will re-centre on the device's actual location.
|
||||
#
|
||||
# Change these values to match the location of your device / station.
|
||||
# Current default: Zwolle, The Netherlands (52.5168, 6.0830).
|
||||
DEFAULT_MAP_CENTER: tuple[float, float] = (52.5168, 6.0830)
|
||||
|
||||
# Default zoom level for all Leaflet maps (higher = more zoomed in).
|
||||
DEFAULT_MAP_ZOOM: int = 9
|
||||
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# DIRECTORY STRUCTURE
|
||||
# ==============================================================================
|
||||
|
||||
# Base data directory — all persistent data lives under this root.
|
||||
# Existing services (cache, pins, archive) each define their own
|
||||
# sub-directory; this constant centralises the root for new consumers.
|
||||
DATA_DIR: Path = Path.home() / ".meshcore-gui"
|
||||
|
||||
# Log directory for debug and error log files.
|
||||
LOG_DIR: Path = DATA_DIR / "logs"
|
||||
|
||||
# Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total).
|
||||
LOG_FILE: Path = LOG_DIR / "meshcore_gui.log"
|
||||
|
||||
|
||||
def set_log_file_for_device(device_id: str) -> None:
|
||||
"""Set the log file name based on the device identifier.
|
||||
|
||||
Transforms ``F0:9E:9E:75:A3:01`` into
|
||||
``~/.meshcore-gui/logs/F0_9E_9E_75_A3_01_meshcore_gui.log`` and
|
||||
``/dev/ttyUSB0`` into ``~/.meshcore-gui/logs/_dev_ttyUSB0_meshcore_gui.log``.
|
||||
|
||||
Must be called **before** the first ``debug_print()`` call so the
|
||||
lazy logger initialisation picks up the correct path.
|
||||
"""
|
||||
global LOG_FILE
|
||||
safe_name = (
|
||||
device_id
|
||||
.replace("literal:", "")
|
||||
.replace(":", "_")
|
||||
.replace("/", "_")
|
||||
)
|
||||
LOG_FILE = LOG_DIR / f"{safe_name}_meshcore_gui.log"
|
||||
|
||||
# Maximum size per log file in bytes (5 MB).
|
||||
LOG_MAX_BYTES: int = 5 * 1024 * 1024
|
||||
|
||||
# Number of rotated backup files to keep.
|
||||
LOG_BACKUP_COUNT: int = 3
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# DEBUG
|
||||
# ==============================================================================
|
||||
|
||||
DEBUG: bool = False
|
||||
|
||||
# Internal file logger — initialised lazily on first debug_print() call.
|
||||
_file_logger: logging.Logger | None = None
|
||||
|
||||
|
||||
def _init_file_logger() -> logging.Logger:
|
||||
"""Create and configure the rotating file logger (called once)."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("meshcore_gui.debug")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
LOG_FILE,
|
||||
maxBytes=LOG_MAX_BYTES,
|
||||
backupCount=LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
def _caller_module() -> str:
|
||||
"""Return a short module label for the calling code.
|
||||
|
||||
Walks two frames up (debug_print -> caller) and extracts the
|
||||
module ``__name__``. The common ``meshcore_gui.`` prefix is
|
||||
stripped for brevity, e.g. ``ble.worker`` instead of
|
||||
``meshcore_gui.ble.worker``.
|
||||
"""
|
||||
frame = sys._getframe(2) # 0=_caller_module, 1=debug_print, 2=actual caller
|
||||
module = frame.f_globals.get("__name__", "<unknown>")
|
||||
if module.startswith("meshcore_gui."):
|
||||
module = module[len("meshcore_gui."):]
|
||||
return module
|
||||
|
||||
|
||||
def _init_meshcore_logger() -> None:
|
||||
"""Route meshcore library debug output to our rotating log file.
|
||||
|
||||
The meshcore library uses ``logging.getLogger("meshcore")`` throughout,
|
||||
but never attaches a handler. Without this function all library-level
|
||||
debug output (raw send/receive, event dispatching, command flow)
|
||||
is silently dropped because Python's root logger only forwards
|
||||
WARNING and above.
|
||||
|
||||
Call once at startup (or lazily from ``debug_print``) so that
|
||||
``MESHCORE_LIB_DEBUG=True`` actually produces visible output.
|
||||
"""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mc_logger = logging.getLogger("meshcore")
|
||||
# Guard against duplicate handlers on repeated calls
|
||||
if any(isinstance(h, RotatingFileHandler) for h in mc_logger.handlers):
|
||||
return
|
||||
|
||||
handler = RotatingFileHandler(
|
||||
LOG_FILE,
|
||||
maxBytes=LOG_MAX_BYTES,
|
||||
backupCount=LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s LIB [%(name)s]: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
mc_logger.addHandler(handler)
|
||||
|
||||
# Also add a stdout handler so library output appears in the console
|
||||
stdout_handler = logging.StreamHandler(sys.stdout)
|
||||
stdout_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(asctime)s LIB [%(name)s]: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
mc_logger.addHandler(stdout_handler)
|
||||
|
||||
|
||||
def debug_print(msg: str) -> None:
|
||||
"""Print a debug message when ``DEBUG`` is enabled.
|
||||
|
||||
Output goes to both stdout and the rotating log file.
|
||||
The calling module name is automatically included so that
|
||||
exception context is immediately clear, e.g.::
|
||||
|
||||
DEBUG [ble.worker]: send_appstart attempt 3 exception: TimeoutError
|
||||
"""
|
||||
global _file_logger
|
||||
|
||||
if not DEBUG:
|
||||
return
|
||||
|
||||
module = _caller_module()
|
||||
formatted = f"DEBUG [{module}]: {msg}"
|
||||
|
||||
# stdout (existing behaviour, now with module tag)
|
||||
print(formatted)
|
||||
|
||||
# Rotating log file
|
||||
if _file_logger is None:
|
||||
_file_logger = _init_file_logger()
|
||||
# Also wire up the meshcore library logger so MESHCORE_LIB_DEBUG
|
||||
# output actually appears in the same log file + stdout.
|
||||
_init_meshcore_logger()
|
||||
_file_logger.debug(formatted)
|
||||
|
||||
|
||||
def pp(obj: Any, indent: int = 2) -> str:
|
||||
"""Pretty-format a dict, list, or other object for debug output.
|
||||
|
||||
Use inside f-strings::
|
||||
|
||||
debug_print(f"payload={pp(r.payload)}")
|
||||
|
||||
Dicts/lists get indented JSON; everything else falls back to repr().
|
||||
"""
|
||||
if isinstance(obj, (dict, list)):
|
||||
try:
|
||||
return json.dumps(obj, indent=indent, default=str, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return repr(obj)
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def debug_data(label: str, obj: Any) -> None:
|
||||
"""Print a labelled data structure with pretty indentation.
|
||||
|
||||
Combines a header line with pretty-printed data below it::
|
||||
|
||||
debug_data("get_contacts result", r.payload)
|
||||
|
||||
Output::
|
||||
|
||||
DEBUG [worker]: get_contacts result ↓
|
||||
{
|
||||
"name": "PE1HVH",
|
||||
"contacts": 629,
|
||||
...
|
||||
}
|
||||
"""
|
||||
if not DEBUG:
|
||||
return
|
||||
formatted = pp(obj)
|
||||
# Single-line values stay on the same line
|
||||
if '\n' not in formatted:
|
||||
debug_print(f"{label}: {formatted}")
|
||||
else:
|
||||
# Multi-line: indent each line for readability
|
||||
indented = '\n'.join(f" {line}" for line in formatted.splitlines())
|
||||
debug_print(f"{label} ↓\n{indented}")
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# CHANNELS
|
||||
# ==============================================================================
|
||||
|
||||
# Maximum number of channel slots to probe on the device.
|
||||
# MeshCore supports up to 8 channels (indices 0-7).
|
||||
MAX_CHANNELS: int = 8
|
||||
|
||||
# Enable or disable caching of the channel list to disk.
|
||||
# When False (default), channels are always fetched fresh from the
|
||||
# device at startup, guaranteeing the GUI always reflects the actual
|
||||
# device configuration. When True, channels are loaded from cache
|
||||
# for instant GUI population and then refreshed from the device.
|
||||
# Note: channel *keys* (for packet decryption) are always cached
|
||||
# regardless of this setting.
|
||||
CHANNEL_CACHE_ENABLED: bool = False
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# BOT DEVICE NAME
|
||||
# ==============================================================================
|
||||
|
||||
# Fixed device name applied when the BOT checkbox is enabled.
|
||||
# The original device name is saved and restored when BOT is disabled.
|
||||
BOT_DEVICE_NAME: str = "ZwolsBotje"
|
||||
|
||||
# Default device name used as fallback when restoring from BOT mode
|
||||
# and no original name was saved (e.g. after a restart).
|
||||
DEVICE_NAME: str = "PE1HVH T1000e"
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# CACHE / REFRESH
|
||||
# ==============================================================================
|
||||
|
||||
# Default timeout (seconds) for meshcore command responses.
|
||||
# Increase if you see frequent 'no_event_received' errors during startup.
|
||||
DEFAULT_TIMEOUT: float = 10.0
|
||||
|
||||
# Enable debug logging inside the meshcore library itself.
|
||||
# When True, raw send/receive data and event parsing are logged.
|
||||
MESHCORE_LIB_DEBUG: bool = True
|
||||
|
||||
# ==============================================================================
|
||||
# TRANSPORT MODE (auto-detected from CLI argument)
|
||||
# ==============================================================================
|
||||
|
||||
# "serial" or "ble" — set at startup by main() based on the device argument.
|
||||
TRANSPORT: str = "serial"
|
||||
|
||||
|
||||
def is_ble_address(device_id: str) -> bool:
|
||||
"""Detect whether *device_id* looks like a BLE MAC address.
|
||||
|
||||
Heuristic:
|
||||
- Starts with ``literal:`` → BLE
|
||||
- Matches ``XX:XX:XX:XX:XX:XX`` (6 colon-separated hex pairs) → BLE
|
||||
- Everything else (``/dev/…``, ``COM…``) → Serial
|
||||
"""
|
||||
if device_id.lower().startswith("literal:"):
|
||||
return True
|
||||
parts = device_id.split(":")
|
||||
if len(parts) == 6 and all(len(p) == 2 for p in parts):
|
||||
try:
|
||||
for p in parts:
|
||||
int(p, 16)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
TRANSPORT: str = "serial"
|
||||
|
||||
# Serial connection defaults.
|
||||
SERIAL_BAUDRATE: int = 115200
|
||||
SERIAL_CX_DELAY: float = 0.1
|
||||
|
||||
# BLE connection defaults.
|
||||
# BLE pairing PIN for the MeshCore device (T1000e default: 123456).
|
||||
# Used by the built-in D-Bus agent to answer pairing requests
|
||||
# automatically — eliminates the need for bt-agent.service.
|
||||
BLE_PIN: str = "123456"
|
||||
|
||||
# Maximum number of reconnect attempts after a disconnect.
|
||||
RECONNECT_MAX_RETRIES: int = 5
|
||||
|
||||
# Base delay in seconds between reconnect attempts (multiplied by
|
||||
# attempt number for linear backoff: 5s, 10s, 15s, 20s, 25s).
|
||||
RECONNECT_BASE_DELAY: float = 5.0
|
||||
|
||||
# Interval in seconds between periodic contact refreshes from the device.
|
||||
# Contacts are merged (new/changed contacts update the cache; contacts
|
||||
# only present in cache are kept so offline nodes are preserved).
|
||||
CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes
|
||||
|
||||
# ==============================================================================
|
||||
# EXTERNAL LINKS (drawer menu)
|
||||
# ==============================================================================
|
||||
|
||||
EXT_LINKS = [
|
||||
('MeshCore', 'https://meshcore.co.uk'),
|
||||
('Handleiding', 'https://www.pe1hvh.nl/pdf/MeshCore_Complete_Handleiding.pdf'),
|
||||
('Netwerk kaart', 'https://meshcore.co.uk/map'),
|
||||
('LocalMesh NL', 'https://www.localmesh.nl/'),
|
||||
]
|
||||
# ==============================================================================
|
||||
# ARCHIVE / RETENTION
|
||||
# ==============================================================================
|
||||
|
||||
# Retention period for archived messages (in days).
|
||||
# Messages older than this are automatically removed during cleanup.
|
||||
MESSAGE_RETENTION_DAYS: int = 30
|
||||
|
||||
# Retention period for RX log entries (in days).
|
||||
# RX log entries older than this are automatically removed during cleanup.
|
||||
RXLOG_RETENTION_DAYS: int = 7
|
||||
|
||||
# Retention period for contacts (in days).
|
||||
# Contacts not seen for longer than this are removed from cache.
|
||||
CONTACT_RETENTION_DAYS: int = 90
|
||||
|
||||
|
||||
# BBS channel configuration is managed at runtime via BbsConfigStore.
|
||||
# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json
|
||||
# and edited through the BBS Settings panel in the GUI.
|
||||
850
meshcore_gui/meshcore_gui/gui/dashboard.py
Normal file
850
meshcore_gui/meshcore_gui/gui/dashboard.py
Normal file
@@ -0,0 +1,850 @@
|
||||
"""
|
||||
Main dashboard page for MeshCore GUI.
|
||||
|
||||
Thin orchestrator that owns the layout and the 500 ms update timer.
|
||||
All visual content is delegated to individual panel classes in
|
||||
:mod:`meshcore_gui.gui.panels`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from meshcore_gui import config
|
||||
|
||||
from meshcore_gui.core.protocols import SharedDataReader
|
||||
from meshcore_gui.gui.panels import (
|
||||
ActionsPanel,
|
||||
BbsPanel,
|
||||
ContactsPanel,
|
||||
DevicePanel,
|
||||
MapPanel,
|
||||
MessagesPanel,
|
||||
RoomServerPanel,
|
||||
RxLogPanel,
|
||||
)
|
||||
from meshcore_gui.gui.archive_page import ArchivePage
|
||||
from meshcore_gui.services.bbs_config_store import BbsConfigStore
|
||||
from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
|
||||
from meshcore_gui.services.pin_store import PinStore
|
||||
from meshcore_gui.services.room_password_store import RoomPasswordStore
|
||||
|
||||
|
||||
# Suppress the harmless "Client has been deleted" warning that NiceGUI
|
||||
# emits when a browser tab is refreshed while a ui.timer is active.
|
||||
class _DeletedClientFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return 'Client has been deleted' not in record.getMessage()
|
||||
|
||||
logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
|
||||
|
||||
|
||||
# ── DOMCA Theme ──────────────────────────────────────────────────────
|
||||
# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar.
|
||||
# Dark/light variable sets switch via Quasar's body--dark / body--light classes.
|
||||
|
||||
_DOMCA_HEAD = '''
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#0d1f35">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="DOMCA">
|
||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Exo+2:wght@800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ── DOMCA theme variables (dark) ── */
|
||||
body.body--dark {
|
||||
--bg: #0A1628;
|
||||
--grid: #0077B6; --grid-op: 0.15;
|
||||
--mesh-bg: #48CAE4; --mesh-bg-op: 0.08;
|
||||
--line: #0077B6; --line-op: 0.6;
|
||||
--wave: #48CAE4; --node: #00B4D8; --node-center: #CAF0F8;
|
||||
--hub-text: #0A1628; --outer: #0077B6;
|
||||
--title: #48CAE4; --subtitle: #48CAE4;
|
||||
--tagline: #90E0EF; --tag-op: 0.5;
|
||||
--badge-stroke: #0077B6; --badge-text: #48CAE4;
|
||||
--callsign: #0077B6;
|
||||
}
|
||||
/* ── DOMCA theme variables (light) ── */
|
||||
body.body--light {
|
||||
--bg: #FFFFFF;
|
||||
--grid: #023E8A; --grid-op: 0.04;
|
||||
--mesh-bg: #0077B6; --mesh-bg-op: 0.05;
|
||||
--line: #0096C7; --line-op: 0.35;
|
||||
--wave: #0096C7; --node: #0077B6; --node-center: #FFFFFF;
|
||||
--hub-text: #FFFFFF; --outer: #0096C7;
|
||||
--title: #0077B6; --subtitle: #0077B6;
|
||||
--tagline: #0096C7; --tag-op: 0.4;
|
||||
--badge-stroke: #0077B6; --badge-text: #0077B6;
|
||||
--callsign: #0096C7;
|
||||
}
|
||||
|
||||
/* ── DOMCA page background ── */
|
||||
body.body--dark { background: #0A1628 !important; }
|
||||
body.body--light { background: #f4f8fb !important; }
|
||||
body.body--dark .q-page { background: #0A1628 !important; }
|
||||
body.body--light .q-page { background: #f4f8fb !important; }
|
||||
|
||||
/* ── DOMCA header ── */
|
||||
body.body--dark .q-header { background: #0d1f35 !important; }
|
||||
body.body--light .q-header { background: #0077B6 !important; }
|
||||
|
||||
/* ── DOMCA drawer — distinct from page background ── */
|
||||
body.body--dark .domca-drawer { background: #0f2340 !important; border-right: 1px solid rgba(0,119,182,0.25) !important; }
|
||||
body.body--light .domca-drawer { background: rgba(244,248,251,0.97) !important; }
|
||||
.domca-drawer .q-btn__content { justify-content: flex-start !important; }
|
||||
|
||||
/* ── DOMCA cards — dark mode readable ── */
|
||||
body.body--dark .q-card {
|
||||
background: #112240 !important;
|
||||
color: #e0f0f8 !important;
|
||||
border: 1px solid rgba(0,119,182,0.15) !important;
|
||||
}
|
||||
body.body--dark .q-card .text-gray-600 { color: #48CAE4 !important; }
|
||||
body.body--dark .q-card .text-gray-500 { color: #8badc4 !important; }
|
||||
body.body--dark .q-card .text-gray-400 { color: #6a8fa8 !important; }
|
||||
body.body--dark .q-card .text-xs { color: #c0dce8 !important; }
|
||||
body.body--dark .q-card .text-sm { color: #d0e8f2 !important; }
|
||||
body.body--dark .q-card .text-red-400 { color: #f87171 !important; }
|
||||
|
||||
/* ── Dark mode: message area, inputs, tables ── */
|
||||
body.body--dark .bg-gray-50 { background: #0c1a2e !important; color: #c0dce8 !important; }
|
||||
body.body--dark .bg-gray-100 { background: #152a45 !important; }
|
||||
body.body--dark .hover\\:bg-gray-100:hover { background: #1a3352 !important; }
|
||||
body.body--dark .hover\\:bg-blue-50:hover { background: #0d2a4a !important; }
|
||||
body.body--dark .bg-yellow-50 { background: rgba(72,202,228,0.06) !important; }
|
||||
|
||||
body.body--dark .q-field__control { background: #0c1a2e !important; color: #e0f0f8 !important; }
|
||||
body.body--dark .q-field__native { color: #e0f0f8 !important; }
|
||||
body.body--dark .q-field__label { color: #8badc4 !important; }
|
||||
|
||||
body.body--dark .q-table { background: #112240 !important; color: #c0dce8 !important; }
|
||||
body.body--dark .q-table thead th { color: #48CAE4 !important; }
|
||||
body.body--dark .q-table tbody td { color: #c0dce8 !important; }
|
||||
|
||||
body.body--dark .q-checkbox__label { color: #c0dce8 !important; }
|
||||
body.body--dark .q-btn--flat:not(.domca-menu-btn):not(.domca-sub-btn) { color: #48CAE4 !important; }
|
||||
|
||||
body.body--dark .q-separator { background: rgba(0,119,182,0.2) !important; }
|
||||
|
||||
/* ── DOMCA menu link styling ── */
|
||||
body.body--dark .domca-menu-btn { color: #8badc4 !important; }
|
||||
body.body--dark .domca-menu-btn:hover { color: #48CAE4 !important; }
|
||||
body.body--light .domca-menu-btn { color: #3d6380 !important; }
|
||||
body.body--light .domca-menu-btn:hover { color: #0077B6 !important; }
|
||||
|
||||
body.body--dark .domca-ext-link { color: #8badc4 !important; }
|
||||
body.body--light .domca-ext-link { color: #3d6380 !important; }
|
||||
|
||||
/* ── DOMCA active menu item ── */
|
||||
body.body--dark .domca-menu-active { color: #48CAE4 !important; background: rgba(72,202,228,0.1) !important; }
|
||||
body.body--light .domca-menu-active { color: #0077B6 !important; background: rgba(0,119,182,0.08) !important; }
|
||||
|
||||
/* ── DOMCA submenu item styling ── */
|
||||
body.body--dark .domca-sub-btn { color: #6a8fa8 !important; }
|
||||
body.body--dark .domca-sub-btn:hover { color: #48CAE4 !important; }
|
||||
body.body--light .domca-sub-btn { color: #5a7a90 !important; }
|
||||
body.body--light .domca-sub-btn:hover { color: #0077B6 !important; }
|
||||
|
||||
/* ── DOMCA expansion panel in drawer ── */
|
||||
.domca-drawer .q-expansion-item {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
letter-spacing: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.domca-drawer .q-expansion-item .q-item {
|
||||
padding: 0.35rem 1.2rem !important;
|
||||
min-height: 32px !important;
|
||||
}
|
||||
.domca-drawer .q-expansion-item .q-expansion-item__content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.domca-drawer .q-expansion-item + .q-expansion-item {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
body.body--dark .domca-drawer .q-expansion-item { color: #8badc4 !important; }
|
||||
body.body--dark .domca-drawer .q-expansion-item__container { background: transparent !important; }
|
||||
body.body--dark .domca-drawer .q-item { color: #8badc4 !important; }
|
||||
body.body--light .domca-drawer .q-expansion-item { color: #3d6380 !important; }
|
||||
body.body--light .domca-drawer .q-item { color: #3d6380 !important; }
|
||||
|
||||
/* ── Landing page centering ── */
|
||||
.domca-landing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.domca-landing svg {
|
||||
width: min(90vw, 800px);
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Panel container — responsive single column ── */
|
||||
.domca-panel {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Responsive heights — override fixed Tailwind heights in panels ── */
|
||||
.domca-panel .h-40 { height: calc(100vh - 20rem) !important; min-height: 10rem; }
|
||||
.domca-panel .h-32 { height: calc(100vh - 24rem) !important; min-height: 8rem; }
|
||||
.domca-panel .h-72 { height: calc(100vh - 12rem) !important; min-height: 14rem; }
|
||||
.domca-panel .h-96 { height: calc(100vh - 8rem) !important; min-height: 16rem; }
|
||||
.domca-panel .max-h-48 { max-height: calc(100vh - 16rem) !important; min-height: 6rem; }
|
||||
|
||||
/* ── Allow narrow viewports down to 320px ── */
|
||||
body, .q-layout, .q-page {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
.q-drawer { max-width: 80vw !important; width: 260px !important; min-width: 200px !important; }
|
||||
|
||||
/* ── Mobile optimisations ── */
|
||||
@media (max-width: 640px) {
|
||||
.domca-landing svg { width: 98vw; }
|
||||
.domca-panel { padding: 0.25rem; }
|
||||
.domca-panel .q-card { border-radius: 8px !important; }
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.domca-landing { padding: 0.25rem; }
|
||||
.domca-landing svg { width: 100vw; }
|
||||
.q-header { padding-left: 0.5rem !important; padding-right: 0.5rem !important; }
|
||||
}
|
||||
|
||||
/* ── Footer label ── */
|
||||
.domca-footer {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* ── Header text: icon-only on narrow viewports ── */
|
||||
@media (max-width: 599px) {
|
||||
.domca-header-text { display: none !important; }
|
||||
}
|
||||
</style>
|
||||
'''
|
||||
|
||||
# ── Landing SVG loader ────────────────────────────────────────────────
|
||||
# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign}
|
||||
# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder
|
||||
# when the file is missing.
|
||||
|
||||
|
||||
def _load_landing_svg() -> str:
|
||||
"""Load the landing page SVG from disk.
|
||||
|
||||
Returns:
|
||||
SVG markup string with ``{callsign}`` replaced by the
|
||||
configured operator callsign.
|
||||
"""
|
||||
path = config.LANDING_SVG_PATH
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
return raw.replace("{callsign}", config.OPERATOR_CALLSIGN)
|
||||
except FileNotFoundError:
|
||||
return (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 100">'
|
||||
'<text x="200" y="55" text-anchor="middle" '
|
||||
'font-family="\'JetBrains Mono\',monospace" font-size="14" '
|
||||
f'fill="var(--title)">Landing SVG not found: {path.name}</text>'
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
|
||||
# ── Standalone menu items (no submenus) ──────────────────────────────
|
||||
|
||||
_STANDALONE_ITEMS = [
|
||||
('\U0001f465', 'CONTACTS', 'contacts'),
|
||||
('\U0001f5fa\ufe0f', 'MAP', 'map'),
|
||||
('\U0001f4e1', 'DEVICE', 'device'),
|
||||
('\u26a1', 'ACTIONS', 'actions'),
|
||||
('\U0001f4ca', 'RX LOG', 'rxlog'),
|
||||
('\U0001f4cb', 'BBS', 'bbs'),
|
||||
]
|
||||
|
||||
_EXT_LINKS = config.EXT_LINKS
|
||||
|
||||
# ── Shared button styles ─────────────────────────────────────────────
|
||||
|
||||
_SUB_BTN_STYLE = (
|
||||
"font-family: 'JetBrains Mono', monospace; "
|
||||
"letter-spacing: 1px; font-size: 0.72rem; "
|
||||
"padding: 0.2rem 1.2rem 0.2rem 2.4rem"
|
||||
)
|
||||
|
||||
_MENU_BTN_STYLE = (
|
||||
"font-family: 'JetBrains Mono', monospace; "
|
||||
"letter-spacing: 2px; font-size: 0.8rem; "
|
||||
"padding: 0.35rem 1.2rem"
|
||||
)
|
||||
|
||||
|
||||
class DashboardPage:
|
||||
"""Main dashboard rendered at ``/``.
|
||||
|
||||
Args:
|
||||
shared: SharedDataReader for data access and command dispatch.
|
||||
"""
|
||||
|
||||
def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore) -> None:
|
||||
self._shared = shared
|
||||
self._pin_store = pin_store
|
||||
self._room_password_store = room_password_store
|
||||
|
||||
# BBS service and config store (singletons shared with bot routing)
|
||||
self._bbs_config_store = BbsConfigStore()
|
||||
self._bbs_service = BbsService()
|
||||
self._bbs_handler = BbsCommandHandler(
|
||||
self._bbs_service, self._bbs_config_store
|
||||
)
|
||||
|
||||
# Panels (created fresh on each render)
|
||||
self._device: DevicePanel | None = None
|
||||
self._contacts: ContactsPanel | None = None
|
||||
self._map: MapPanel | None = None
|
||||
self._messages: MessagesPanel | None = None
|
||||
self._actions: ActionsPanel | None = None
|
||||
self._rxlog: RxLogPanel | None = None
|
||||
self._room_server: RoomServerPanel | None = None
|
||||
self._bbs: BbsPanel | None = None
|
||||
|
||||
# Header status label
|
||||
self._status_label = None
|
||||
|
||||
# Local first-render flag
|
||||
self._initialized: bool = False
|
||||
|
||||
# Panel switching state (layout)
|
||||
self._panel_containers: dict = {}
|
||||
self._active_panel: str = 'landing'
|
||||
self._drawer = None
|
||||
self._menu_buttons: dict = {}
|
||||
|
||||
# Submenu containers (for dynamic channel/room items)
|
||||
self._msg_sub_container = None
|
||||
self._archive_sub_container = None
|
||||
self._rooms_sub_container = None
|
||||
self._last_channel_fingerprint = None
|
||||
self._last_rooms_fingerprint = None
|
||||
|
||||
# Archive page reference (for inline channel switching)
|
||||
self._archive_page: ArchivePage | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self) -> None:
|
||||
"""Build the complete dashboard layout and start the timer."""
|
||||
self._initialized = False
|
||||
|
||||
# Reset fingerprints: render() creates new (empty) NiceGUI
|
||||
# containers, so _update_submenus must rebuild into them even
|
||||
# when the channel/room data hasn't changed since last session.
|
||||
self._last_channel_fingerprint = None
|
||||
self._last_rooms_fingerprint = None
|
||||
|
||||
# Create panel instances (UNCHANGED functional wiring)
|
||||
put_cmd = self._shared.put_command
|
||||
self._device = DevicePanel()
|
||||
self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server)
|
||||
self._map = MapPanel()
|
||||
self._messages = MessagesPanel(put_cmd)
|
||||
self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled)
|
||||
self._rxlog = RxLogPanel()
|
||||
self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
|
||||
self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
|
||||
|
||||
# Inject DOMCA theme (fonts + CSS variables)
|
||||
ui.add_head_html(_DOMCA_HEAD)
|
||||
|
||||
# Default to dark mode (DOMCA theme)
|
||||
dark = ui.dark_mode(True)
|
||||
dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value))
|
||||
self._map.set_ui_dark_mode(dark.value)
|
||||
|
||||
# ── Left Drawer (must be created before header for Quasar) ────
|
||||
self._drawer = ui.left_drawer(value=False, bordered=True).classes(
|
||||
'domca-drawer'
|
||||
).style('padding: 0')
|
||||
|
||||
with self._drawer:
|
||||
# DOMCA branding (clickable → landing page)
|
||||
with ui.column().style('padding: 0.2rem 1.2rem 0'):
|
||||
ui.button(
|
||||
'DOMCA',
|
||||
on_click=lambda: self._navigate_panel('landing'),
|
||||
).props('flat no-caps').style(
|
||||
"font-family: 'Exo 2', sans-serif; font-size: 1.4rem; "
|
||||
"font-weight: 800; color: var(--title); letter-spacing: 4px; "
|
||||
"margin-bottom: 0.3rem; padding: 0"
|
||||
)
|
||||
|
||||
self._menu_buttons = {}
|
||||
|
||||
# ── 💬 MESSAGES (expandable with channel submenu) ──────
|
||||
with ui.expansion(
|
||||
'\U0001f4ac MESSAGES', icon=None, value=False,
|
||||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||||
self._msg_sub_container = ui.column().classes('w-full gap-0')
|
||||
with self._msg_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('messages', channel=None)
|
||||
)
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('messages', channel='DM')
|
||||
)
|
||||
# Dynamic channel items populated by _update_submenus
|
||||
|
||||
# ── 🏠 ROOMS (expandable with room submenu) ───────────
|
||||
with ui.expansion(
|
||||
'\U0001f3e0 ROOMS', icon=None, value=False,
|
||||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||||
self._rooms_sub_container = ui.column().classes('w-full gap-0')
|
||||
with self._rooms_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('rooms')
|
||||
)
|
||||
# Pre-populate from persisted rooms
|
||||
for entry in self._room_password_store.get_rooms():
|
||||
short = entry.name or entry.pubkey[:12]
|
||||
self._make_sub_btn(
|
||||
f'\U0001f3e0 {short}',
|
||||
lambda: self._navigate_panel('rooms'),
|
||||
)
|
||||
|
||||
# ── 📚 ARCHIVE (expandable with channel submenu) ──────
|
||||
with ui.expansion(
|
||||
'\U0001f4da ARCHIVE', icon=None, value=False,
|
||||
).props('dense header-class="q-pa-none"').classes('w-full'):
|
||||
self._archive_sub_container = ui.column().classes('w-full gap-0')
|
||||
with self._archive_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('archive', channel=None)
|
||||
)
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('archive', channel='DM')
|
||||
)
|
||||
# Dynamic channel items populated by _update_submenus
|
||||
|
||||
ui.separator().classes('my-1')
|
||||
|
||||
# ── Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG)
|
||||
for icon, label, panel_id in _STANDALONE_ITEMS:
|
||||
btn = ui.button(
|
||||
f'{icon} {label}',
|
||||
on_click=lambda pid=panel_id: self._navigate_panel(pid),
|
||||
).props('flat no-caps align=left').classes(
|
||||
'w-full justify-start domca-menu-btn'
|
||||
).style(_MENU_BTN_STYLE)
|
||||
self._menu_buttons[panel_id] = btn
|
||||
|
||||
ui.separator().classes('my-2')
|
||||
|
||||
# External links (same as domca.nl navigation)
|
||||
with ui.column().style('padding: 0 1.2rem'):
|
||||
for label, url in _EXT_LINKS:
|
||||
ui.link(label, url, new_tab=True).classes(
|
||||
'domca-ext-link'
|
||||
).style(
|
||||
"font-family: 'JetBrains Mono', monospace; "
|
||||
"letter-spacing: 2px; font-size: 0.72rem; "
|
||||
"text-decoration: none; opacity: 0.6; "
|
||||
"display: block; padding: 0.35rem 0"
|
||||
)
|
||||
|
||||
# Footer in drawer
|
||||
ui.space()
|
||||
ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem')
|
||||
|
||||
# ── Header ────────────────────────────────────────────────
|
||||
with ui.header().classes('items-center px-4 py-2 shadow-md'):
|
||||
menu_btn = ui.button(
|
||||
icon='menu',
|
||||
on_click=lambda: self._drawer.toggle(),
|
||||
).props('flat round dense color=white')
|
||||
|
||||
# Swap icon: menu ↔ close
|
||||
self._drawer.on_value_change(
|
||||
lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}')
|
||||
)
|
||||
|
||||
ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes(
|
||||
'text-lg font-bold ml-2 domca-header-text'
|
||||
).style("font-family: 'JetBrains Mono', monospace")
|
||||
|
||||
# Transport mode badge
|
||||
_is_ble = config.TRANSPORT == "ble"
|
||||
_badge_icon = '🔵' if _is_ble else '🟢'
|
||||
_badge_label = 'BLE' if _is_ble else 'Serial'
|
||||
ui.label(f'{_badge_icon} {_badge_label}').classes(
|
||||
'text-xs ml-2 domca-header-text'
|
||||
).style(
|
||||
"font-family: 'JetBrains Mono', monospace; "
|
||||
"opacity: 0.65; letter-spacing: 1px"
|
||||
)
|
||||
|
||||
ui.space()
|
||||
|
||||
_initial_status = self._shared.get_snapshot().get('status', 'Starting...')
|
||||
self._status_label = ui.label(_initial_status).classes(
|
||||
'text-sm opacity-70 domca-header-text'
|
||||
)
|
||||
|
||||
ui.button(
|
||||
icon='brightness_6',
|
||||
on_click=lambda: dark.toggle(),
|
||||
).props('flat round dense color=white').tooltip('Toggle dark / light')
|
||||
|
||||
# ── Main Content Area ─────────────────────────────────────
|
||||
self._panel_containers = {}
|
||||
|
||||
# Landing page (SVG splash from file — visible by default)
|
||||
landing = ui.column().classes('domca-landing w-full')
|
||||
with landing:
|
||||
ui.html(_load_landing_svg())
|
||||
self._panel_containers['landing'] = landing
|
||||
|
||||
# Panel containers (hidden by default, shown on menu click)
|
||||
panel_defs = [
|
||||
('messages', self._messages),
|
||||
('contacts', self._contacts),
|
||||
('map', self._map),
|
||||
('device', self._device),
|
||||
('actions', self._actions),
|
||||
('rxlog', self._rxlog),
|
||||
('rooms', self._room_server),
|
||||
('bbs', self._bbs),
|
||||
]
|
||||
|
||||
for panel_id, panel_obj in panel_defs:
|
||||
container = ui.column().classes('domca-panel')
|
||||
container.set_visibility(False)
|
||||
with container:
|
||||
panel_obj.render()
|
||||
self._panel_containers[panel_id] = container
|
||||
|
||||
# Archive panel (inline — replaces separate /archive page)
|
||||
archive_container = ui.column().classes('domca-panel')
|
||||
archive_container.set_visibility(False)
|
||||
with archive_container:
|
||||
self._archive_page = ArchivePage(self._shared)
|
||||
self._archive_page.render()
|
||||
self._panel_containers['archive'] = archive_container
|
||||
|
||||
self._active_panel = 'landing'
|
||||
|
||||
# Start update timer
|
||||
self._apply_url_state()
|
||||
ui.timer(0.5, self._update_ui)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Submenu button helper (layout only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_sub_btn(label: str, on_click) -> ui.button:
|
||||
"""Create a submenu button in the drawer."""
|
||||
return ui.button(
|
||||
label,
|
||||
on_click=on_click,
|
||||
).props('flat no-caps align=left').classes(
|
||||
'w-full justify-start domca-sub-btn'
|
||||
).style(_SUB_BTN_STYLE)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dynamic submenu updates (layout — called from _update_ui)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_submenus(self, data: dict) -> None:
|
||||
"""Rebuild channel/room submenu items when data changes.
|
||||
|
||||
Only the dynamic items are rebuilt; the container is cleared and
|
||||
ALL items (static + dynamic) are re-rendered.
|
||||
"""
|
||||
# ── Channel submenus (Messages + Archive) ──
|
||||
channels = data.get('channels', [])
|
||||
ch_fingerprint = tuple((ch['idx'], ch['name']) for ch in channels)
|
||||
|
||||
if ch_fingerprint != self._last_channel_fingerprint and channels:
|
||||
self._last_channel_fingerprint = ch_fingerprint
|
||||
|
||||
# Rebuild Messages submenu
|
||||
if self._msg_sub_container:
|
||||
self._msg_sub_container.clear()
|
||||
with self._msg_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('messages', channel=None)
|
||||
)
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('messages', channel='DM')
|
||||
)
|
||||
for ch in channels:
|
||||
idx = ch['idx']
|
||||
name = ch['name']
|
||||
self._make_sub_btn(
|
||||
f"[{idx}] {name}",
|
||||
lambda i=idx: self._navigate_panel('messages', channel=i),
|
||||
)
|
||||
|
||||
# Rebuild Archive submenu
|
||||
if self._archive_sub_container:
|
||||
self._archive_sub_container.clear()
|
||||
with self._archive_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('archive', channel=None)
|
||||
)
|
||||
self._make_sub_btn(
|
||||
'DM', lambda: self._navigate_panel('archive', channel='DM')
|
||||
)
|
||||
for ch in channels:
|
||||
idx = ch['idx']
|
||||
name = ch['name']
|
||||
self._make_sub_btn(
|
||||
f"[{idx}] {name}",
|
||||
lambda n=name: self._navigate_panel('archive', channel=n),
|
||||
)
|
||||
|
||||
# ── Room submenus ──
|
||||
rooms = self._room_password_store.get_rooms()
|
||||
rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms)
|
||||
|
||||
if rooms_fingerprint != self._last_rooms_fingerprint:
|
||||
self._last_rooms_fingerprint = rooms_fingerprint
|
||||
|
||||
if self._rooms_sub_container:
|
||||
self._rooms_sub_container.clear()
|
||||
with self._rooms_sub_container:
|
||||
self._make_sub_btn(
|
||||
'ALL', lambda: self._navigate_panel('rooms')
|
||||
)
|
||||
for entry in rooms:
|
||||
short = entry.name or entry.pubkey[:12]
|
||||
self._make_sub_btn(
|
||||
f'\U0001f3e0 {short}',
|
||||
lambda: self._navigate_panel('rooms'),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Panel switching (layout helper — no functional logic)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_url_state(self) -> None:
|
||||
"""Apply panel selection from URL query params on first render."""
|
||||
try:
|
||||
params = ui.context.client.request.query_params
|
||||
except Exception:
|
||||
return
|
||||
|
||||
panel = params.get('panel') or 'landing'
|
||||
channel = params.get('channel')
|
||||
|
||||
if panel not in self._panel_containers:
|
||||
panel = 'landing'
|
||||
channel = None
|
||||
|
||||
if panel == 'messages':
|
||||
if channel is None or channel.lower() == 'all':
|
||||
channel = None
|
||||
elif channel.upper() == 'DM':
|
||||
channel = 'DM'
|
||||
else:
|
||||
channel = int(channel) if channel.isdigit() else None
|
||||
elif panel == 'archive':
|
||||
if channel is None or channel.lower() == 'all':
|
||||
channel = None
|
||||
elif channel.upper() == 'DM':
|
||||
channel = 'DM'
|
||||
else:
|
||||
channel = None
|
||||
|
||||
self._show_panel(panel, channel)
|
||||
|
||||
def _build_panel_url(self, panel_id: str, channel=None) -> str:
|
||||
params = {'panel': panel_id}
|
||||
if channel is not None:
|
||||
params['channel'] = str(channel)
|
||||
return '/?' + urlencode(params)
|
||||
|
||||
def _navigate_panel(self, panel_id: str, channel=None) -> None:
|
||||
"""Navigate with panel id in the URL so browser back restores state."""
|
||||
ui.navigate.to(self._build_panel_url(panel_id, channel))
|
||||
|
||||
def _show_panel(self, panel_id: str, channel=None) -> None:
|
||||
"""Show the selected panel, hide all others, close the drawer.
|
||||
|
||||
Args:
|
||||
panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms').
|
||||
channel: Optional channel filter.
|
||||
For messages: None=all, 'DM'=DM only, int=channel idx.
|
||||
For archive: None=all, 'DM'=DM only, str=channel name.
|
||||
"""
|
||||
for pid, container in self._panel_containers.items():
|
||||
container.set_visibility(pid == panel_id)
|
||||
self._active_panel = panel_id
|
||||
|
||||
# Apply channel filter to messages panel
|
||||
if panel_id == 'messages' and self._messages:
|
||||
self._messages.set_active_channel(channel)
|
||||
|
||||
# Apply channel filter to archive panel
|
||||
if panel_id == 'archive' and self._archive_page:
|
||||
self._archive_page.set_channel_filter(channel)
|
||||
|
||||
self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
|
||||
|
||||
# Update active menu highlight (standalone buttons only)
|
||||
for pid, btn in self._menu_buttons.items():
|
||||
if pid == panel_id:
|
||||
btn.classes('domca-menu-active', remove='')
|
||||
else:
|
||||
btn.classes(remove='domca-menu-active')
|
||||
|
||||
# Close drawer after selection
|
||||
if self._drawer:
|
||||
self._drawer.hide()
|
||||
|
||||
def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
|
||||
"""Refresh only the currently visible panel.
|
||||
|
||||
This is used directly after a panel switch so the user does not
|
||||
need to wait for the next 500 ms dashboard tick.
|
||||
"""
|
||||
data = self._shared.get_snapshot()
|
||||
|
||||
if data.get('channels'):
|
||||
self._messages.update_filters(data)
|
||||
self._messages.update_channel_options(data['channels'])
|
||||
self._update_submenus(data)
|
||||
|
||||
if self._active_panel == 'device':
|
||||
self._device.update(data)
|
||||
elif self._active_panel == 'map':
|
||||
if force_map_center:
|
||||
data['force_center'] = True
|
||||
self._map.update(data)
|
||||
elif self._active_panel == 'actions':
|
||||
self._actions.update(data)
|
||||
elif self._active_panel == 'contacts':
|
||||
self._contacts.update(data)
|
||||
elif self._active_panel == 'messages':
|
||||
self._messages.update(
|
||||
data,
|
||||
self._messages.channel_filters,
|
||||
self._messages.last_channels,
|
||||
room_pubkeys=(
|
||||
self._room_server.get_room_pubkeys()
|
||||
if self._room_server else None
|
||||
),
|
||||
)
|
||||
elif self._active_panel == 'rooms':
|
||||
self._room_server.update(data)
|
||||
elif self._active_panel == 'rxlog':
|
||||
self._rxlog.update(data)
|
||||
elif self._active_panel == 'bbs':
|
||||
if self._bbs:
|
||||
self._bbs.update(data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room Server callback (from ContactsPanel)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None:
|
||||
"""Handle adding a Room Server from the contacts panel.
|
||||
|
||||
Delegates to the RoomServerPanel which persists the entry,
|
||||
creates the UI card and sends the login command.
|
||||
"""
|
||||
if self._room_server:
|
||||
self._room_server.add_room(pubkey, name, password)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Timer-driven UI update
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _update_ui(self) -> None:
|
||||
try:
|
||||
if not self._status_label:
|
||||
return
|
||||
|
||||
# Atomic snapshot + flag clear: eliminates race condition
|
||||
# where worker sets channels_updated between separate
|
||||
# get_snapshot() and clear_update_flags() calls.
|
||||
data = self._shared.get_snapshot_and_clear_flags()
|
||||
is_first = not self._initialized
|
||||
|
||||
# Mark initialised immediately — even if a panel update
|
||||
# crashes below, we must NOT retry the full first-render
|
||||
# path every 500 ms (that causes the infinite rebuild).
|
||||
if is_first:
|
||||
self._initialized = True
|
||||
|
||||
# Always update status
|
||||
self._status_label.text = data['status']
|
||||
|
||||
# Channel-dependent drawer/submenu state may stay global.
|
||||
# The helpers below already contain equality checks, so this
|
||||
# remains cheap while keeping navigation consistent.
|
||||
if data['channels']:
|
||||
self._messages.update_filters(data)
|
||||
self._messages.update_channel_options(data['channels'])
|
||||
self._update_submenus(data)
|
||||
|
||||
if self._active_panel == 'device':
|
||||
if data['device_updated'] or is_first:
|
||||
self._device.update(data)
|
||||
|
||||
elif self._active_panel == 'map':
|
||||
# Keep sending snapshots while the map panel is active.
|
||||
# The browser runtime coalesces pending payloads, so only
|
||||
# the newest snapshot is applied.
|
||||
self._map.update(data)
|
||||
|
||||
elif self._active_panel == 'actions':
|
||||
if data['channels_updated'] or is_first:
|
||||
self._actions.update(data)
|
||||
|
||||
elif self._active_panel == 'contacts':
|
||||
if data['contacts_updated'] or is_first:
|
||||
self._contacts.update(data)
|
||||
|
||||
elif self._active_panel == 'messages':
|
||||
self._messages.update(
|
||||
data,
|
||||
self._messages.channel_filters,
|
||||
self._messages.last_channels,
|
||||
room_pubkeys=(
|
||||
self._room_server.get_room_pubkeys()
|
||||
if self._room_server else None
|
||||
),
|
||||
)
|
||||
|
||||
elif self._active_panel == 'rooms':
|
||||
self._room_server.update(data)
|
||||
|
||||
elif self._active_panel == 'rxlog':
|
||||
if data['rxlog_updated'] or is_first:
|
||||
self._rxlog.update(data)
|
||||
|
||||
elif self._active_panel == 'bbs':
|
||||
if self._bbs:
|
||||
self._bbs.update(data)
|
||||
|
||||
# Signal worker that GUI is ready for data
|
||||
if is_first and data['channels'] and data['contacts']:
|
||||
self._shared.mark_gui_initialized()
|
||||
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if "deleted" not in err and "client" not in err:
|
||||
import traceback
|
||||
print(f"GUI update error: {e}")
|
||||
traceback.print_exc()
|
||||
18
meshcore_gui/meshcore_gui/gui/panels/__init__.py
Normal file
18
meshcore_gui/meshcore_gui/gui/panels/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Individual dashboard panels — each panel is a single-responsibility class.
|
||||
|
||||
Re-exports all panels for convenient importing::
|
||||
|
||||
from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ...
|
||||
"""
|
||||
|
||||
from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401
|
||||
from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401
|
||||
535
meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
Normal file
535
meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""BBS panel -- board-based Bulletin Board System viewer and configuration."""
|
||||
|
||||
import re
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
from meshcore_gui.services.bbs_config_store import (
|
||||
BbsBoard,
|
||||
BbsConfigStore,
|
||||
DEFAULT_CATEGORIES,
|
||||
DEFAULT_RETENTION_HOURS,
|
||||
)
|
||||
from meshcore_gui.services.bbs_service import BbsMessage, BbsService
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
"""Convert a board name to a safe id slug.
|
||||
|
||||
Args:
|
||||
name: Human-readable board name.
|
||||
|
||||
Returns:
|
||||
Lowercase alphanumeric + underscore string.
|
||||
"""
|
||||
return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board"
|
||||
|
||||
|
||||
class BbsPanel:
|
||||
"""BBS panel: board selector, filters, message list, post form and settings.
|
||||
|
||||
The settings section lets users create, configure and delete boards.
|
||||
Each board can span one or more device channels (from SharedData).
|
||||
Configuration is persisted via BbsConfigStore.
|
||||
|
||||
Args:
|
||||
put_command: Callable to enqueue a command dict for the worker.
|
||||
bbs_service: Shared BbsService instance.
|
||||
config_store: Shared BbsConfigStore instance.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
put_command: Callable[[Dict], None],
|
||||
bbs_service: BbsService,
|
||||
config_store: BbsConfigStore,
|
||||
) -> None:
|
||||
self._put_command = put_command
|
||||
self._service = bbs_service
|
||||
self._config_store = config_store
|
||||
|
||||
# Active view state
|
||||
self._active_board: Optional[BbsBoard] = None
|
||||
self._active_region: Optional[str] = None
|
||||
self._active_category: Optional[str] = None
|
||||
|
||||
# UI refs -- message view
|
||||
self._board_btn_row = None
|
||||
self._region_row = None
|
||||
self._region_select = None
|
||||
self._category_select = None
|
||||
self._text_input = None
|
||||
self._post_region_row = None
|
||||
self._post_region_select = None
|
||||
self._post_category_select = None
|
||||
self._msg_list_container = None
|
||||
|
||||
# UI refs -- settings
|
||||
self._boards_settings_container = None
|
||||
self._new_board_name_input = None
|
||||
self._new_board_channel_checks: Dict[int, object] = {}
|
||||
|
||||
# Cached device channels (updated by update())
|
||||
self._device_channels: List[Dict] = []
|
||||
self._last_ch_fingerprint: tuple = ()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Render
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def render(self) -> None:
|
||||
"""Build the complete BBS panel layout."""
|
||||
# ---- Message view card --------------------------------------
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600')
|
||||
|
||||
self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap')
|
||||
with self._board_btn_row:
|
||||
ui.label('Board:').classes('text-sm text-gray-600')
|
||||
|
||||
ui.separator()
|
||||
|
||||
with ui.row().classes('w-full items-center gap-4 flex-wrap'):
|
||||
ui.label('Filter:').classes('text-sm text-gray-600')
|
||||
|
||||
self._region_row = ui.row().classes('items-center gap-2')
|
||||
with self._region_row:
|
||||
ui.label('Region:').classes('text-xs text-gray-600')
|
||||
self._region_select = ui.select(
|
||||
options=[], value=None,
|
||||
on_change=lambda e: self._on_region_filter(e.value),
|
||||
).classes('text-xs').style('min-width: 120px')
|
||||
|
||||
with ui.row().classes('items-center gap-2'):
|
||||
ui.label('Category:').classes('text-xs text-gray-600')
|
||||
self._category_select = ui.select(
|
||||
options=[], value=None,
|
||||
on_change=lambda e: self._on_category_filter(e.value),
|
||||
).classes('text-xs').style('min-width: 120px')
|
||||
|
||||
ui.button(
|
||||
'Refresh', on_click=self._refresh_messages,
|
||||
).props('flat no-caps').classes('text-xs')
|
||||
|
||||
ui.separator()
|
||||
|
||||
self._msg_list_container = ui.column().classes(
|
||||
'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2'
|
||||
)
|
||||
|
||||
ui.separator()
|
||||
|
||||
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
|
||||
ui.label('Post:').classes('text-sm text-gray-600')
|
||||
|
||||
self._post_region_row = ui.row().classes('items-center gap-1')
|
||||
with self._post_region_row:
|
||||
self._post_region_select = ui.select(
|
||||
options=[], label='Region',
|
||||
).classes('text-xs').style('min-width: 110px')
|
||||
|
||||
self._post_category_select = ui.select(
|
||||
options=[], label='Category',
|
||||
).classes('text-xs').style('min-width: 110px')
|
||||
|
||||
self._text_input = ui.input(
|
||||
placeholder='Message text...',
|
||||
).classes('flex-grow text-sm')
|
||||
|
||||
ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
|
||||
|
||||
# ---- Settings card ------------------------------------------
|
||||
with ui.card().classes('w-full'):
|
||||
ui.label('BBS Settings').classes('font-bold text-gray-600')
|
||||
ui.label(
|
||||
'Create boards and assign device channels. '
|
||||
'One board can cover multiple channels.'
|
||||
).classes('text-xs text-gray-500')
|
||||
ui.separator()
|
||||
|
||||
# New board form
|
||||
with ui.row().classes('w-full items-center gap-2 flex-wrap'):
|
||||
ui.label('New board:').classes('text-sm text-gray-600')
|
||||
self._new_board_name_input = ui.input(
|
||||
placeholder='Board name...',
|
||||
).classes('text-xs').style('min-width: 160px')
|
||||
ui.button(
|
||||
'Create', on_click=self._on_create_board,
|
||||
).props('no-caps').classes('text-xs')
|
||||
|
||||
ui.separator()
|
||||
self._boards_settings_container = ui.column().classes('w-full gap-3')
|
||||
with self._boards_settings_container:
|
||||
ui.label('No boards configured yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
|
||||
# Initial render
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Board selector (message view)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_board_buttons(self) -> None:
|
||||
"""Rebuild board selector buttons from current config."""
|
||||
if not self._board_btn_row:
|
||||
return
|
||||
self._board_btn_row.clear()
|
||||
boards = self._config_store.get_boards()
|
||||
with self._board_btn_row:
|
||||
ui.label('Board:').classes('text-sm text-gray-600')
|
||||
if not boards:
|
||||
ui.label('No boards configured.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
for board in boards:
|
||||
ui.button(
|
||||
board.name,
|
||||
on_click=lambda b=board: self._select_board(b),
|
||||
).props('flat no-caps').classes('text-xs')
|
||||
|
||||
# Auto-select first board if none active or active was deleted
|
||||
ids = [b.id for b in boards]
|
||||
if boards and (self._active_board is None or self._active_board.id not in ids):
|
||||
self._select_board(boards[0])
|
||||
|
||||
def _select_board(self, board: BbsBoard) -> None:
|
||||
"""Activate a board and rebuild filter selects.
|
||||
|
||||
Args:
|
||||
board: Board to activate.
|
||||
"""
|
||||
self._active_board = board
|
||||
self._active_region = None
|
||||
self._active_category = None
|
||||
|
||||
has_regions = bool(board.regions)
|
||||
if self._region_row:
|
||||
self._region_row.set_visibility(has_regions)
|
||||
if self._post_region_row:
|
||||
self._post_region_row.set_visibility(has_regions)
|
||||
|
||||
region_opts = ['(all)'] + board.regions
|
||||
if self._region_select:
|
||||
self._region_select.options = region_opts
|
||||
self._region_select.value = '(all)'
|
||||
if self._post_region_select:
|
||||
self._post_region_select.options = board.regions
|
||||
self._post_region_select.value = board.regions[0] if board.regions else None
|
||||
|
||||
cat_opts = ['(all)'] + board.categories
|
||||
if self._category_select:
|
||||
self._category_select.options = cat_opts
|
||||
self._category_select.value = '(all)'
|
||||
if self._post_category_select:
|
||||
self._post_category_select.options = board.categories
|
||||
self._post_category_select.value = board.categories[0] if board.categories else None
|
||||
|
||||
self._refresh_messages()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Filters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_region_filter(self, value: Optional[str]) -> None:
|
||||
self._active_region = None if (not value or value == '(all)') else value
|
||||
self._refresh_messages()
|
||||
|
||||
def _on_category_filter(self, value: Optional[str]) -> None:
|
||||
self._active_category = None if (not value or value == '(all)') else value
|
||||
self._refresh_messages()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message list
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _refresh_messages(self) -> None:
|
||||
if not self._msg_list_container:
|
||||
return
|
||||
self._msg_list_container.clear()
|
||||
with self._msg_list_container:
|
||||
if self._active_board is None:
|
||||
ui.label('Select a board above.').classes('text-xs text-gray-400 italic')
|
||||
return
|
||||
if not self._active_board.channels:
|
||||
ui.label('No channels assigned to this board.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
messages = self._service.get_all_messages(
|
||||
channels=self._active_board.channels,
|
||||
region=self._active_region,
|
||||
category=self._active_category,
|
||||
)
|
||||
if not messages:
|
||||
ui.label('No messages.').classes('text-xs text-gray-400 italic')
|
||||
return
|
||||
for msg in messages:
|
||||
self._render_message_row(msg)
|
||||
|
||||
def _render_message_row(self, msg: BbsMessage) -> None:
|
||||
ts = msg.timestamp[:16].replace('T', ' ')
|
||||
region_label = f' [{msg.region}]' if msg.region else ''
|
||||
header = f'{ts} {msg.sender} [{msg.category}]{region_label}'
|
||||
with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'):
|
||||
ui.label(header).classes('text-xs text-gray-500')
|
||||
ui.label(msg.text).classes('text-sm')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Post
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_post(self) -> None:
|
||||
if self._active_board is None:
|
||||
ui.notify('Select a board first.', type='warning')
|
||||
return
|
||||
if not self._active_board.channels:
|
||||
ui.notify('No channels assigned to this board.', type='warning')
|
||||
return
|
||||
|
||||
text = (self._text_input.value or '').strip() if self._text_input else ''
|
||||
if not text:
|
||||
ui.notify('Message text cannot be empty.', type='warning')
|
||||
return
|
||||
|
||||
category = (
|
||||
self._post_category_select.value if self._post_category_select
|
||||
else (self._active_board.categories[0] if self._active_board.categories else '')
|
||||
)
|
||||
if not category:
|
||||
ui.notify('Please select a category.', type='warning')
|
||||
return
|
||||
|
||||
region = ''
|
||||
if self._active_board.regions and self._post_region_select:
|
||||
region = self._post_region_select.value or ''
|
||||
|
||||
# Post on first assigned channel (primary channel for outgoing)
|
||||
target_channel = self._active_board.channels[0]
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=target_channel,
|
||||
region=region, category=category,
|
||||
sender='Me', sender_key='', text=text,
|
||||
)
|
||||
self._service.post_message(msg)
|
||||
|
||||
region_part = f'{region} ' if region else ''
|
||||
mesh_text = f'!bbs post {region_part}{category} {text}'
|
||||
self._put_command({
|
||||
'action': 'send_message',
|
||||
'channel': target_channel,
|
||||
'text': mesh_text,
|
||||
})
|
||||
debug_print(
|
||||
f'BBS panel: posted to board={self._active_board.id} '
|
||||
f'ch={target_channel} {mesh_text[:60]}'
|
||||
)
|
||||
|
||||
if self._text_input:
|
||||
self._text_input.value = ''
|
||||
self._refresh_messages()
|
||||
ui.notify('Message posted.', type='positive')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings -- board list
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rebuild_boards_settings(self) -> None:
|
||||
"""Rebuild the settings section for all configured boards."""
|
||||
if not self._boards_settings_container:
|
||||
return
|
||||
self._boards_settings_container.clear()
|
||||
boards = self._config_store.get_boards()
|
||||
with self._boards_settings_container:
|
||||
if not boards:
|
||||
ui.label('No boards configured yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
return
|
||||
for board in boards:
|
||||
self._render_board_settings_row(board)
|
||||
|
||||
def _render_board_settings_row(self, board: BbsBoard) -> None:
|
||||
"""Render one settings expansion for a single board.
|
||||
|
||||
Args:
|
||||
board: Board to render.
|
||||
"""
|
||||
with ui.expansion(
|
||||
board.name, value=False,
|
||||
).classes('w-full').props('dense'):
|
||||
with ui.column().classes('w-full gap-2 p-2'):
|
||||
|
||||
# Name
|
||||
name_input = ui.input(
|
||||
label='Board name', value=board.name,
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Channel assignment
|
||||
ui.label('Channels (select which device channels belong to this board):').classes(
|
||||
'text-xs text-gray-600'
|
||||
)
|
||||
ch_checks: Dict[int, object] = {}
|
||||
with ui.row().classes('flex-wrap gap-2'):
|
||||
if not self._device_channels:
|
||||
ui.label('No device channels known yet.').classes(
|
||||
'text-xs text-gray-400 italic'
|
||||
)
|
||||
for ch in self._device_channels:
|
||||
idx = ch.get('idx', ch.get('index', 0))
|
||||
ch_name = ch.get('name', f'Ch {idx}')
|
||||
cb = ui.checkbox(
|
||||
f'[{idx}] {ch_name}',
|
||||
value=idx in board.channels,
|
||||
).classes('text-xs')
|
||||
ch_checks[idx] = cb
|
||||
|
||||
# Categories
|
||||
cats_input = ui.input(
|
||||
label='Categories (comma-separated)',
|
||||
value=', '.join(board.categories),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Regions
|
||||
regions_input = ui.input(
|
||||
label='Regions (comma-separated, leave empty for none)',
|
||||
value=', '.join(board.regions),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
# Retention
|
||||
retention_input = ui.input(
|
||||
label='Retention (hours)',
|
||||
value=str(board.retention_hours),
|
||||
).classes('text-xs').style('max-width: 160px')
|
||||
|
||||
# Whitelist
|
||||
wl_input = ui.input(
|
||||
label='Allowed keys (comma-separated hex, empty = all)',
|
||||
value=', '.join(board.allowed_keys),
|
||||
).classes('w-full text-xs')
|
||||
|
||||
with ui.row().classes('gap-2'):
|
||||
def _save(
|
||||
bid=board.id,
|
||||
ni=name_input,
|
||||
cc=ch_checks,
|
||||
ci=cats_input,
|
||||
ri=regions_input,
|
||||
ret=retention_input,
|
||||
wli=wl_input,
|
||||
) -> None:
|
||||
new_name = (ni.value or '').strip() or bid
|
||||
selected_channels = [
|
||||
idx for idx, cb in cc.items() if cb.value
|
||||
]
|
||||
categories = [
|
||||
c.strip().upper()
|
||||
for c in (ci.value or '').split(',') if c.strip()
|
||||
] or list(DEFAULT_CATEGORIES)
|
||||
regions = [
|
||||
r.strip()
|
||||
for r in (ri.value or '').split(',') if r.strip()
|
||||
]
|
||||
try:
|
||||
retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS)
|
||||
except ValueError:
|
||||
retention_hours = DEFAULT_RETENTION_HOURS
|
||||
allowed_keys = [
|
||||
k.strip()
|
||||
for k in (wli.value or '').split(',') if k.strip()
|
||||
]
|
||||
updated = BbsBoard(
|
||||
id=bid,
|
||||
name=new_name,
|
||||
channels=selected_channels,
|
||||
categories=categories,
|
||||
regions=regions,
|
||||
retention_hours=retention_hours,
|
||||
allowed_keys=allowed_keys,
|
||||
)
|
||||
self._config_store.set_board(updated)
|
||||
debug_print(
|
||||
f'BBS settings: saved board {bid} '
|
||||
f'channels={selected_channels}'
|
||||
)
|
||||
ui.notify(f'Board "{new_name}" saved.', type='positive')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
def _delete(bid=board.id, bname=board.name) -> None:
|
||||
self._config_store.delete_board(bid)
|
||||
if self._active_board and self._active_board.id == bid:
|
||||
self._active_board = None
|
||||
debug_print(f'BBS settings: deleted board {bid}')
|
||||
ui.notify(f'Board "{bname}" deleted.', type='warning')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
ui.button('Save', on_click=_save).props('no-caps').classes('text-xs')
|
||||
ui.button(
|
||||
'Delete', on_click=_delete,
|
||||
).props('no-caps flat color=negative').classes('text-xs')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings -- create new board
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_create_board(self) -> None:
|
||||
"""Handle the Create button for a new board."""
|
||||
name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else ''
|
||||
if not name:
|
||||
ui.notify('Enter a board name first.', type='warning')
|
||||
return
|
||||
|
||||
board_id = _slug(name)
|
||||
# Make id unique if needed
|
||||
base_id = board_id
|
||||
counter = 2
|
||||
while self._config_store.board_id_exists(board_id):
|
||||
board_id = f'{base_id}_{counter}'
|
||||
counter += 1
|
||||
|
||||
board = BbsBoard(
|
||||
id=board_id,
|
||||
name=name,
|
||||
channels=[],
|
||||
categories=list(DEFAULT_CATEGORIES),
|
||||
regions=[],
|
||||
retention_hours=DEFAULT_RETENTION_HOURS,
|
||||
allowed_keys=[],
|
||||
)
|
||||
self._config_store.set_board(board)
|
||||
debug_print(f'BBS settings: created board {board_id}')
|
||||
if self._new_board_name_input:
|
||||
self._new_board_name_input.value = ''
|
||||
ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive')
|
||||
self._rebuild_board_buttons()
|
||||
self._rebuild_boards_settings()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# External update hook
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self, data: Dict) -> None:
|
||||
"""Called by the dashboard timer with the SharedData snapshot.
|
||||
|
||||
Rebuilds the settings channel checkboxes when the device channel
|
||||
list changes.
|
||||
|
||||
Args:
|
||||
data: SharedData snapshot dict.
|
||||
"""
|
||||
device_channels = data.get('channels', [])
|
||||
fingerprint = tuple(
|
||||
(ch.get('idx', 0), ch.get('name', '')) for ch in device_channels
|
||||
)
|
||||
if fingerprint != self._last_ch_fingerprint:
|
||||
self._last_ch_fingerprint = fingerprint
|
||||
self._device_channels = device_channels
|
||||
self._rebuild_boards_settings()
|
||||
302
meshcore_gui/meshcore_gui/services/bbs_config_store.py
Normal file
302
meshcore_gui/meshcore_gui/services/bbs_config_store.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
BBS board configuration store for MeshCore GUI.
|
||||
|
||||
Persists BBS board configuration to
|
||||
``~/.meshcore-gui/bbs/bbs_config.json``.
|
||||
|
||||
A **board** groups one or more MeshCore channel indices into a single
|
||||
bulletin board. Messages posted on any of the board's channels are
|
||||
visible in the board view. This supports two usage patterns:
|
||||
|
||||
- One board per channel (classic per-channel BBS)
|
||||
- One board spanning multiple channels (shared bulletin board)
|
||||
|
||||
Config version history
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
v1 — per-channel config (list of channels with enabled flag).
|
||||
v2 — board-based config (list of boards, each with a channels list).
|
||||
Automatic migration from v1 on first load.
|
||||
|
||||
Thread safety
|
||||
~~~~~~~~~~~~~
|
||||
All public methods acquire an internal ``threading.Lock``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs"
|
||||
BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json"
|
||||
|
||||
CONFIG_VERSION: int = 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"]
|
||||
DEFAULT_REGIONS: List[str] = []
|
||||
DEFAULT_RETENTION_HOURS: int = 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BbsBoard:
|
||||
"""A BBS board grouping one or more MeshCore channels.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``).
|
||||
name: Human-readable board name.
|
||||
channels: List of MeshCore channel indices assigned to this board.
|
||||
categories: Valid category tags for this board.
|
||||
regions: Optional region tags; empty = no region filtering.
|
||||
retention_hours: Message retention period in hours.
|
||||
allowed_keys: Sender public key whitelist (empty = all allowed).
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
channels: List[int] = field(default_factory=list)
|
||||
categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
|
||||
regions: List[str] = field(default_factory=list)
|
||||
retention_hours: int = DEFAULT_RETENTION_HOURS
|
||||
allowed_keys: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Serialise to a JSON-compatible dict."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"channels": list(self.channels),
|
||||
"categories": list(self.categories),
|
||||
"regions": list(self.regions),
|
||||
"retention_hours": self.retention_hours,
|
||||
"allowed_keys": list(self.allowed_keys),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict) -> "BbsBoard":
|
||||
"""Deserialise from a config dict."""
|
||||
return BbsBoard(
|
||||
id=d.get("id", ""),
|
||||
name=d.get("name", ""),
|
||||
channels=list(d.get("channels", [])),
|
||||
categories=list(d.get("categories", DEFAULT_CATEGORIES)),
|
||||
regions=list(d.get("regions", [])),
|
||||
retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)),
|
||||
allowed_keys=list(d.get("allowed_keys", [])),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BbsConfigStore:
|
||||
"""Persistent store for BBS board configuration.
|
||||
|
||||
Args:
|
||||
config_path: Path to the JSON config file.
|
||||
Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None:
|
||||
self._path = config_path
|
||||
self._lock = threading.Lock()
|
||||
self._boards: List[BbsBoard] = []
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Load / save
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load config from disk; migrate v1 → v2 if needed."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self._path.exists():
|
||||
self._save_unlocked()
|
||||
debug_print("BBS config: created new config file (v2)")
|
||||
return
|
||||
|
||||
try:
|
||||
raw = self._path.read_text(encoding="utf-8")
|
||||
data = json.loads(raw)
|
||||
version = data.get("version", 1)
|
||||
|
||||
if version == CONFIG_VERSION:
|
||||
self._boards = [
|
||||
BbsBoard.from_dict(b) for b in data.get("boards", [])
|
||||
]
|
||||
debug_print(f"BBS config: loaded {len(self._boards)} boards")
|
||||
|
||||
elif version == 1:
|
||||
# Migrate: each v1 channel → one board
|
||||
self._boards = self._migrate_v1(data.get("channels", []))
|
||||
self._save_unlocked()
|
||||
debug_print(
|
||||
f"BBS config: migrated v1 → v2 ({len(self._boards)} boards)"
|
||||
)
|
||||
else:
|
||||
debug_print(
|
||||
f"BBS config: unknown version {version}, using empty config"
|
||||
)
|
||||
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
debug_print(f"BBS config: load error ({exc}), using empty config")
|
||||
|
||||
@staticmethod
|
||||
def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]:
|
||||
"""Convert v1 per-channel entries to v2 boards.
|
||||
|
||||
Only enabled channels are migrated.
|
||||
|
||||
Args:
|
||||
v1_channels: List of v1 channel config dicts.
|
||||
|
||||
Returns:
|
||||
List of ``BbsBoard`` instances.
|
||||
"""
|
||||
boards = []
|
||||
for ch in v1_channels:
|
||||
if not ch.get("enabled", False):
|
||||
continue
|
||||
idx = ch.get("channel", 0)
|
||||
board_id = f"ch{idx}"
|
||||
boards.append(BbsBoard(
|
||||
id=board_id,
|
||||
name=ch.get("name", f"Channel {idx}"),
|
||||
channels=[idx],
|
||||
categories=list(ch.get("categories", DEFAULT_CATEGORIES)),
|
||||
regions=list(ch.get("regions", [])),
|
||||
retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)),
|
||||
allowed_keys=list(ch.get("allowed_keys", [])),
|
||||
))
|
||||
return boards
|
||||
|
||||
def _save_unlocked(self) -> None:
|
||||
"""Write config to disk. MUST be called with self._lock held."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
"version": CONFIG_VERSION,
|
||||
"boards": [b.to_dict() for b in self._boards],
|
||||
}
|
||||
tmp = self._path.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps(data, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(self._path)
|
||||
|
||||
def save(self) -> None:
|
||||
"""Flush current configuration to disk."""
|
||||
with self._lock:
|
||||
self._save_unlocked()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Board queries
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_boards(self) -> List[BbsBoard]:
|
||||
"""Return a copy of all configured boards.
|
||||
|
||||
Returns:
|
||||
List of ``BbsBoard`` instances.
|
||||
"""
|
||||
with self._lock:
|
||||
return list(self._boards)
|
||||
|
||||
def get_board(self, board_id: str) -> Optional[BbsBoard]:
|
||||
"""Return a board by its id, or ``None``.
|
||||
|
||||
Args:
|
||||
board_id: Board identifier string.
|
||||
|
||||
Returns:
|
||||
``BbsBoard`` instance or ``None``.
|
||||
"""
|
||||
with self._lock:
|
||||
for b in self._boards:
|
||||
if b.id == board_id:
|
||||
return BbsBoard.from_dict(b.to_dict())
|
||||
return None
|
||||
|
||||
def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]:
|
||||
"""Return the first board that includes *channel_idx*, or ``None``.
|
||||
|
||||
Used by ``BbsCommandHandler`` to route incoming mesh commands.
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index.
|
||||
|
||||
Returns:
|
||||
``BbsBoard`` instance or ``None``.
|
||||
"""
|
||||
with self._lock:
|
||||
for b in self._boards:
|
||||
if channel_idx in b.channels:
|
||||
return BbsBoard.from_dict(b.to_dict())
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Board management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_board(self, board: BbsBoard) -> None:
|
||||
"""Insert or replace a board (matched by ``board.id``).
|
||||
|
||||
Args:
|
||||
board: ``BbsBoard`` to persist.
|
||||
"""
|
||||
with self._lock:
|
||||
for i, b in enumerate(self._boards):
|
||||
if b.id == board.id:
|
||||
self._boards[i] = BbsBoard.from_dict(board.to_dict())
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: updated board '{board.id}'")
|
||||
return
|
||||
self._boards.append(BbsBoard.from_dict(board.to_dict()))
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: added board '{board.id}'")
|
||||
|
||||
def delete_board(self, board_id: str) -> bool:
|
||||
"""Remove a board by id.
|
||||
|
||||
Args:
|
||||
board_id: Board identifier to remove.
|
||||
|
||||
Returns:
|
||||
``True`` if removed, ``False`` if not found.
|
||||
"""
|
||||
with self._lock:
|
||||
before = len(self._boards)
|
||||
self._boards = [b for b in self._boards if b.id != board_id]
|
||||
if len(self._boards) < before:
|
||||
self._save_unlocked()
|
||||
debug_print(f"BBS config: deleted board '{board_id}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def board_id_exists(self, board_id: str) -> bool:
|
||||
"""Check whether a board id is already in use.
|
||||
|
||||
Args:
|
||||
board_id: Board identifier to check.
|
||||
|
||||
Returns:
|
||||
``True`` if a board with this id exists.
|
||||
"""
|
||||
with self._lock:
|
||||
return any(b.id == board_id for b in self._boards)
|
||||
468
meshcore_gui/meshcore_gui/services/bbs_service.py
Normal file
468
meshcore_gui/meshcore_gui/services/bbs_service.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
Offline Bulletin Board System (BBS) service for MeshCore GUI.
|
||||
|
||||
Stores BBS messages in a local SQLite database. Messages are keyed by
|
||||
their originating MeshCore channel index. A **board** (see
|
||||
:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
|
||||
more channel indices to a single bulletin board, so queries are always
|
||||
issued as ``WHERE channel IN (...)``.
|
||||
|
||||
Architecture
|
||||
~~~~~~~~~~~~
|
||||
- ``BbsService`` -- persistence layer (SQLite, retention, queries).
|
||||
- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
|
||||
delegates to ``BbsService``. Returns reply text.
|
||||
|
||||
Thread safety
|
||||
~~~~~~~~~~~~~
|
||||
SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
|
||||
multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
|
||||
|
||||
Storage
|
||||
~~~~~~~
|
||||
``~/.meshcore-gui/bbs/bbs_messages.db``
|
||||
``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
BBS_DIR = Path.home() / ".meshcore-gui" / "bbs"
|
||||
BBS_DB_PATH = BBS_DIR / "bbs_messages.db"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class BbsMessage:
|
||||
"""A single BBS message.
|
||||
|
||||
Attributes:
|
||||
id: Database row id (``None`` before insert).
|
||||
channel: MeshCore channel index the message arrived on.
|
||||
region: Region tag (empty string when board has no regions).
|
||||
category: Category tag.
|
||||
sender: Display name of the sender.
|
||||
sender_key: Public key of the sender (hex string).
|
||||
text: Message body.
|
||||
timestamp: UTC ISO-8601 timestamp string.
|
||||
"""
|
||||
|
||||
channel: int
|
||||
region: str
|
||||
category: str
|
||||
sender: str
|
||||
sender_key: str
|
||||
text: str
|
||||
timestamp: str = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
id: Optional[int] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BbsService:
|
||||
"""SQLite-backed BBS storage service.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self) -> None:
|
||||
"""Create the database directory and schema if not present."""
|
||||
BBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=3000")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS bbs_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel INTEGER NOT NULL,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
sender_key TEXT NOT NULL DEFAULT '',
|
||||
text TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)"
|
||||
)
|
||||
conn.commit()
|
||||
debug_print(f"BBS: database ready at {self._db_path}")
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def post_message(self, msg: BbsMessage) -> int:
|
||||
"""Insert a BBS message and return its row id.
|
||||
|
||||
Args:
|
||||
msg: ``BbsMessage`` dataclass to persist.
|
||||
|
||||
Returns:
|
||||
Assigned ``rowid`` (also set on ``msg.id``).
|
||||
"""
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO bbs_messages
|
||||
(channel, region, category, sender, sender_key, text, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(msg.channel, msg.region, msg.category,
|
||||
msg.sender, msg.sender_key, msg.text, msg.timestamp),
|
||||
)
|
||||
conn.commit()
|
||||
msg.id = cur.lastrowid
|
||||
debug_print(
|
||||
f"BBS: posted id={msg.id} ch={msg.channel} "
|
||||
f"cat={msg.category} sender={msg.sender}"
|
||||
)
|
||||
return msg.id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read (channels is a list to support multi-channel boards)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
channels: List[int],
|
||||
region: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> List[BbsMessage]:
|
||||
"""Return the *limit* most recent messages for a set of channels.
|
||||
|
||||
Args:
|
||||
channels: MeshCore channel indices to query (board's channel list).
|
||||
region: Optional region filter.
|
||||
category: Optional category filter.
|
||||
limit: Maximum number of messages to return.
|
||||
|
||||
Returns:
|
||||
List of ``BbsMessage`` objects, newest first.
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
query = (
|
||||
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
f"FROM bbs_messages WHERE channel IN ({placeholders})"
|
||||
)
|
||||
params: list = list(channels)
|
||||
if region:
|
||||
query += " AND region = ?"
|
||||
params.append(region)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
def get_all_messages(
|
||||
self,
|
||||
channels: List[int],
|
||||
region: Optional[str] = None,
|
||||
category: Optional[str] = None,
|
||||
) -> List[BbsMessage]:
|
||||
"""Return all messages for a set of channels (oldest first).
|
||||
|
||||
Args:
|
||||
channels: MeshCore channel indices to query.
|
||||
region: Optional region filter.
|
||||
category: Optional category filter.
|
||||
|
||||
Returns:
|
||||
List of ``BbsMessage`` objects, oldest first.
|
||||
"""
|
||||
if not channels:
|
||||
return []
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
query = (
|
||||
f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
|
||||
f"FROM bbs_messages WHERE channel IN ({placeholders})"
|
||||
)
|
||||
params: list = list(channels)
|
||||
if region:
|
||||
query += " AND region = ?"
|
||||
params.append(region)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
query += " ORDER BY timestamp ASC"
|
||||
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [self._row_to_msg(r) for r in rows]
|
||||
|
||||
@staticmethod
|
||||
def _row_to_msg(row: tuple) -> BbsMessage:
|
||||
return BbsMessage(
|
||||
id=row[0], channel=row[1], region=row[2], category=row[3],
|
||||
sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7],
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Retention
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def purge_expired(self, channels: List[int], retention_hours: int) -> int:
|
||||
"""Delete messages older than *retention_hours* for a set of channels.
|
||||
|
||||
Args:
|
||||
channels: MeshCore channel indices to purge.
|
||||
retention_hours: Messages older than this are deleted.
|
||||
|
||||
Returns:
|
||||
Number of rows deleted.
|
||||
"""
|
||||
if not channels:
|
||||
return 0
|
||||
cutoff = (
|
||||
datetime.now(timezone.utc) - timedelta(hours=retention_hours)
|
||||
).isoformat()
|
||||
placeholders = ",".join("?" * len(channels))
|
||||
with self._lock:
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
|
||||
list(channels) + [cutoff],
|
||||
)
|
||||
conn.commit()
|
||||
deleted = cur.rowcount
|
||||
if deleted:
|
||||
debug_print(
|
||||
f"BBS: purged {deleted} expired messages from ch={channels}"
|
||||
)
|
||||
return deleted
|
||||
|
||||
def purge_all_expired(self, boards) -> None:
|
||||
"""Run retention cleanup for all boards.
|
||||
|
||||
Args:
|
||||
boards: Iterable of ``BbsBoard`` instances.
|
||||
"""
|
||||
for board in boards:
|
||||
self.purge_expired(board.channels, board.retention_hours)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BbsCommandHandler:
|
||||
"""Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
|
||||
|
||||
Looks up the board for the incoming channel via ``BbsConfigStore``
|
||||
so that a single board spanning multiple channels handles commands
|
||||
from all of them.
|
||||
|
||||
Args:
|
||||
service: Shared ``BbsService`` instance.
|
||||
config_store: ``BbsConfigStore`` instance for live board config.
|
||||
"""
|
||||
|
||||
READ_LIMIT: int = 5
|
||||
|
||||
def __init__(self, service: BbsService, config_store) -> None:
|
||||
self._service = service
|
||||
self._config_store = config_store
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def handle(
|
||||
self,
|
||||
channel_idx: int,
|
||||
sender: str,
|
||||
sender_key: str,
|
||||
text: str,
|
||||
) -> Optional[str]:
|
||||
"""Parse an incoming message and return a reply string (or ``None``).
|
||||
|
||||
Args:
|
||||
channel_idx: MeshCore channel index the message arrived on.
|
||||
sender: Display name of the sender.
|
||||
sender_key: Public key of the sender (hex string).
|
||||
text: Raw message text.
|
||||
|
||||
Returns:
|
||||
Reply string, or ``None`` if no reply should be sent.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text.lower().startswith("!bbs"):
|
||||
return None
|
||||
|
||||
board = self._config_store.get_board_for_channel(channel_idx)
|
||||
if board is None:
|
||||
return None
|
||||
|
||||
# Whitelist check
|
||||
if board.allowed_keys and sender_key not in board.allowed_keys:
|
||||
debug_print(
|
||||
f"BBS: silently dropping msg from {sender} "
|
||||
f"(key not in whitelist for board '{board.id}')"
|
||||
)
|
||||
return None
|
||||
|
||||
parts = text.split(None, 1)
|
||||
args = parts[1].strip() if len(parts) > 1 else ""
|
||||
return self._dispatch(board, channel_idx, sender, sender_key, args)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _dispatch(self, board, channel_idx, sender, sender_key, args):
|
||||
sub = args.split(None, 1)[0].lower() if args else ""
|
||||
rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else ""
|
||||
if sub == "post":
|
||||
return self._handle_post(board, channel_idx, sender, sender_key, rest)
|
||||
if sub == "read":
|
||||
return self._handle_read(board, rest)
|
||||
if sub == "help" or not sub:
|
||||
return self._handle_help(board)
|
||||
return f"Unknown command '{sub}'. {self._handle_help(board)}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# post
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_post(self, board, channel_idx, sender, sender_key, args):
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split(None, 2) if args else []
|
||||
|
||||
if regions:
|
||||
if len(tokens) < 3:
|
||||
return (
|
||||
f"Usage: !bbs post [region] [category] [text] | "
|
||||
f"Regions: {', '.join(regions)} | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
)
|
||||
region, category, text = tokens[0], tokens[1], tokens[2]
|
||||
valid_r = [r.upper() for r in regions]
|
||||
if region.upper() not in valid_r:
|
||||
return f"Invalid region '{region}'. Valid: {', '.join(regions)}"
|
||||
region = regions[valid_r.index(region.upper())]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
else:
|
||||
if len(tokens) < 2:
|
||||
return (
|
||||
f"Usage: !bbs post [category] [text] | "
|
||||
f"Categories: {', '.join(categories)}"
|
||||
)
|
||||
region = ""
|
||||
category, text = tokens[0], tokens[1]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if category.upper() not in valid_c:
|
||||
return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
|
||||
category = categories[valid_c.index(category.upper())]
|
||||
|
||||
msg = BbsMessage(
|
||||
channel=channel_idx,
|
||||
region=region, category=category,
|
||||
sender=sender, sender_key=sender_key, text=text,
|
||||
)
|
||||
self._service.post_message(msg)
|
||||
region_label = f" [{region}]" if region else ""
|
||||
return f"Posted [{category}]{region_label}: {text[:60]}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# read
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_read(self, board, args):
|
||||
regions = board.regions
|
||||
categories = board.categories
|
||||
tokens = args.split() if args else []
|
||||
region = None
|
||||
category = None
|
||||
|
||||
if regions:
|
||||
valid_r = [r.upper() for r in regions]
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_r:
|
||||
region = regions[valid_r.index(tokens[0].upper())]
|
||||
if len(tokens) >= 2:
|
||||
if tokens[1].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[1].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}"
|
||||
else:
|
||||
return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}"
|
||||
else:
|
||||
valid_c = [c.upper() for c in categories]
|
||||
if tokens:
|
||||
if tokens[0].upper() in valid_c:
|
||||
category = categories[valid_c.index(tokens[0].upper())]
|
||||
else:
|
||||
return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}"
|
||||
|
||||
messages = self._service.get_messages(
|
||||
board.channels, region=region, category=category, limit=self.READ_LIMIT,
|
||||
)
|
||||
if not messages:
|
||||
return "BBS: no messages found."
|
||||
lines = []
|
||||
for m in messages:
|
||||
ts = m.timestamp[:16].replace("T", " ")
|
||||
region_label = f"[{m.region}] " if m.region else ""
|
||||
lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# help
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_help(self, board) -> str:
|
||||
cats = ", ".join(board.categories)
|
||||
if board.regions:
|
||||
regs = ", ".join(board.regions)
|
||||
return (
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [region] [cat] [text] | "
|
||||
f"!bbs read [region] [cat] | "
|
||||
f"Regions: {regs} | Categories: {cats}"
|
||||
)
|
||||
return (
|
||||
f"BBS [{board.name}] | "
|
||||
f"!bbs post [cat] [text] | "
|
||||
f"!bbs read [cat] | "
|
||||
f"Categories: {cats}"
|
||||
)
|
||||
221
meshcore_gui/meshcore_gui/services/bot.py
Normal file
221
meshcore_gui/meshcore_gui/services/bot.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Keyword-triggered auto-reply bot for MeshCore GUI.
|
||||
|
||||
Extracted from SerialWorker to satisfy the Single Responsibility Principle.
|
||||
The bot listens on a configured channel and replies to messages that
|
||||
contain recognised keywords.
|
||||
|
||||
Open/Closed
|
||||
~~~~~~~~~~~
|
||||
New keywords are added via ``BotConfig.keywords`` (data) without
|
||||
modifying the ``MeshBot`` class (code). Custom matching strategies
|
||||
can be implemented by subclassing and overriding ``_match_keyword``.
|
||||
|
||||
BBS integration
|
||||
~~~~~~~~~~~~~~~
|
||||
``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a
|
||||
:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one
|
||||
is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is
|
||||
``None`` (default), BBS routing is simply skipped.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore_gui.services.bbs_service import BbsCommandHandler
|
||||
|
||||
from meshcore_gui.config import debug_print
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Bot defaults (previously in config.py)
|
||||
# ==============================================================================
|
||||
|
||||
# Channel indices the bot listens on (must match device channels).
|
||||
BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot
|
||||
|
||||
# Display name prepended to every bot reply.
|
||||
BOT_NAME: str = "ZwolsBotje"
|
||||
|
||||
# Minimum seconds between two bot replies (prevents reply-storms).
|
||||
BOT_COOLDOWN_SECONDS: float = 5.0
|
||||
|
||||
# Keyword → reply template mapping.
|
||||
# Available variables: {bot}, {sender}, {snr}, {path}
|
||||
# The bot checks whether the incoming message text *contains* the keyword
|
||||
# (case-insensitive). First match wins.
|
||||
BOT_KEYWORDS: Dict[str, str] = {
|
||||
'test': '@[{sender}], rcvd | SNR {snr} | {path}',
|
||||
'ping': 'Pong!',
|
||||
'help': 'test, ping, help',
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotConfig:
|
||||
"""Configuration for :class:`MeshBot`.
|
||||
|
||||
Attributes:
|
||||
channels: Channel indices to listen on.
|
||||
name: Display name prepended to replies.
|
||||
cooldown_seconds: Minimum seconds between replies.
|
||||
keywords: Keyword → reply template mapping.
|
||||
"""
|
||||
|
||||
channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS))
|
||||
name: str = BOT_NAME
|
||||
cooldown_seconds: float = BOT_COOLDOWN_SECONDS
|
||||
keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS))
|
||||
|
||||
|
||||
class MeshBot:
|
||||
"""Keyword-triggered auto-reply bot.
|
||||
|
||||
The bot checks incoming messages against a set of keyword → template
|
||||
pairs. When a keyword is found (case-insensitive substring match,
|
||||
first match wins), the template is expanded and queued as a channel
|
||||
message via *command_sink*.
|
||||
|
||||
Args:
|
||||
config: Bot configuration.
|
||||
command_sink: Callable that enqueues a command dict for the
|
||||
worker (typically ``shared.put_command``).
|
||||
enabled_check: Callable that returns ``True`` when the bot is
|
||||
enabled (typically ``shared.is_bot_enabled``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: BotConfig,
|
||||
command_sink: Callable[[Dict], None],
|
||||
enabled_check: Callable[[], bool],
|
||||
bbs_handler: Optional["BbsCommandHandler"] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._sink = command_sink
|
||||
self._enabled = enabled_check
|
||||
self._last_reply: float = 0.0
|
||||
self._bbs_handler = bbs_handler
|
||||
|
||||
def check_and_reply(
|
||||
self,
|
||||
sender: str,
|
||||
text: str,
|
||||
channel_idx: Optional[int],
|
||||
snr: Optional[float],
|
||||
path_len: int,
|
||||
path_hashes: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""Evaluate an incoming message and queue a reply if appropriate.
|
||||
|
||||
Guards (in order):
|
||||
1. Bot is enabled (checkbox in GUI).
|
||||
2. Message is on the configured channel.
|
||||
3. Sender is not the bot itself.
|
||||
4. Sender name does not end with ``'Bot'`` (prevent loops).
|
||||
5. Cooldown period has elapsed.
|
||||
6. Message text contains a recognised keyword.
|
||||
"""
|
||||
# Guard 1: enabled?
|
||||
if not self._enabled():
|
||||
return
|
||||
|
||||
# Guard 2: correct channel?
|
||||
if channel_idx not in self._config.channels:
|
||||
return
|
||||
|
||||
# Guard 3: own messages?
|
||||
if sender == "Me" or (text and text.startswith(self._config.name)):
|
||||
return
|
||||
|
||||
# Guard 4: other bots?
|
||||
if sender and sender.rstrip().lower().endswith("bot"):
|
||||
debug_print(f"BOT: skipping message from other bot '{sender}'")
|
||||
return
|
||||
|
||||
# Guard 5: cooldown?
|
||||
now = time.time()
|
||||
if now - self._last_reply < self._config.cooldown_seconds:
|
||||
debug_print("BOT: cooldown active, skipping")
|
||||
return
|
||||
|
||||
# BBS routing: delegate !bbs commands to BbsCommandHandler
|
||||
if self._bbs_handler is not None:
|
||||
text_stripped = (text or "").strip()
|
||||
if text_stripped.lower().startswith("!bbs"):
|
||||
bbs_reply = self._bbs_handler.handle(
|
||||
channel_idx=channel_idx,
|
||||
sender=sender,
|
||||
sender_key="", # sender_key not available at this call-site
|
||||
text=text_stripped,
|
||||
)
|
||||
if bbs_reply is not None:
|
||||
self._last_reply = now
|
||||
self._sink({
|
||||
"action": "send_message",
|
||||
"channel": channel_idx,
|
||||
"text": bbs_reply,
|
||||
"_bot": True,
|
||||
})
|
||||
debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}")
|
||||
return # Do not fall through to keyword matching
|
||||
|
||||
# Guard 6: keyword match
|
||||
template = self._match_keyword(text)
|
||||
if template is None:
|
||||
return
|
||||
|
||||
# Build reply
|
||||
path_str = self._format_path(path_len, path_hashes)
|
||||
snr_str = f"{snr:.1f}" if snr is not None else "?"
|
||||
reply = template.format(
|
||||
bot=self._config.name,
|
||||
sender=sender or "?",
|
||||
snr=snr_str,
|
||||
path=path_str,
|
||||
)
|
||||
|
||||
self._last_reply = now
|
||||
|
||||
self._sink({
|
||||
"action": "send_message",
|
||||
"channel": channel_idx,
|
||||
"text": reply,
|
||||
"_bot": True,
|
||||
})
|
||||
debug_print(f"BOT: queued reply to '{sender}': {reply}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Extension point (OCP)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _match_keyword(self, text: str) -> Optional[str]:
|
||||
"""Return the reply template for the first matching keyword.
|
||||
|
||||
Override this method for custom matching strategies (regex,
|
||||
exact match, priority ordering, etc.).
|
||||
|
||||
Returns:
|
||||
Template string, or ``None`` if no keyword matched.
|
||||
"""
|
||||
text_lower = (text or "").lower()
|
||||
for keyword, template in self._config.keywords.items():
|
||||
if keyword in text_lower:
|
||||
return template
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_path(
|
||||
path_len: int,
|
||||
path_hashes: Optional[List[str]],
|
||||
) -> str:
|
||||
"""Format path info as ``path(N); ``path(0)``."""
|
||||
if not path_len:
|
||||
return "path(0)"
|
||||
return f"path({path_len})"
|
||||
Reference in New Issue
Block a user