`. NiceGUI/Quasar does not include Tailwind CSS,
- so `h-96` had no effect and the container rendered at height 0. Leaflet initialized
- on a zero-height element and produced a blank map.
-- π **Route map not rendered when no node has GPS coordinates** β `_render_map`
- returned early before creating the Leaflet container when `payload['nodes']` was
- empty. Fixed: container is always created; a notice label is shown instead.
-
-### Changed
-- π `meshcore_gui/static/leaflet_map_panel.js` β Added size guard in `ensureMap`:
- returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map
- state exists yet. `processPending` retries on the next tick once the panel is visible.
-- π `meshcore_gui/gui/dashboard.py` β Consolidated two conditional map-update blocks
- into a single unconditional update while the MAP panel is active. Added `h-96` to the
- DOMCA CSS height overrides for consistency with the route page map container.
-- π `meshcore_gui/gui/route_page.py` β Replaced `h-96` Tailwind class on the route
- map host `
` with an explicit inline `style` (height: 24rem). Removed early
- `return` guard so the Leaflet container is always created.
-
-### Impact
-- MAP panel now renders reliably on first open regardless of contact/GPS availability
-- Route map now always shows with correct height even when route nodes have no GPS
-- No breaking changes outside the three files listed above
-
----
-## [1.13.0] - 2026-03-09 β Leaflet Map Runtime Stabilization
-
-### Added
-- β `meshcore_gui/static/leaflet_map_panel.js` β Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry, clustering and theme handling independent from NiceGUI redraw cycles
-- β `meshcore_gui/static/leaflet_map_panel.css` β Styling for browser-side node markers, cluster icons and map container
-- β `meshcore_gui/services/map_snapshot_service.py` β Snapshot service that normalizes device/contact map data into a compact payload for the browser runtime
-- β Browser-side map state management for center, zoom and theme
-- β Theme persistence across reconnect events via browser storage fallback
-- β Browser-side contact clustering via `Leaflet.markercluster`
-- β Separate non-clustered device marker layer so the own device remains individually visible
-
-### Changed
-- π `meshcore_gui/gui/panels/map_panel.py` β Replaced NiceGUI Leaflet wrapper usage with a pure browser-managed Leaflet container while preserving the existing card layout, theme toggle and center-on-device control
-- π Leaflet bootstrap moved out of inline Python into a dedicated browser runtime loaded from `/static`
-- π Asset loading order is now explicit: Leaflet first, then `Leaflet.markercluster`, then the MeshCore panel runtime
-- π Map initialization now occurs only once per container; NiceGUI refresh cycles no longer recreate the map
-- π Dashboard update loop now sends compact map snapshots instead of triggering redraws
-- π Snapshot processing in the browser is coalesced so only the newest payload is applied
-- π Map markers are managed in separate device/contact layers and updated incrementally by stable node id
-- π Contact markers are rendered inside a persistent cluster layer while the device marker remains outside clustering
-- π Theme switching moved to a dedicated theme channel instead of being embedded in snapshot data
-
-### Fixed
-- π **Map disappearing during dashboard refresh cycles** β prevented repeated map reinitialization caused by the 500 ms NiceGUI update loop
-- π **Markers disappearing between refreshes** β marker updates are now incremental and keyed by node id
-- π **Blank map container on load** β browser bootstrap now waits for DOM host, Leaflet runtime and panel runtime before initialization
-- π **Leaflet clustering bootstrap failure (`L is not defined`)** β resolved by enforcing correct script dependency order before the panel runtime starts
-- π **MarkerClusterGroup failure (`Map has no maxZoom specified`)** β the map now defines `maxZoom` during initial creation before the cluster layer is attached
-- π **Half-initialized map retry cascade (`Map container is already initialized`)** β map state is now registered safely during initialization so a failed attempt cannot trigger a second `L.map(...)` on the same container
-- π **Race condition between queued snapshot and theme selection** β explicit theme changes can no longer be overwritten by stale snapshot payloads
-- π **Viewport jumping back to default center/zoom** β stored viewport is no longer reapplied on each snapshot update
-- π **Theme reverting to default during reconnect** β effective map theme is restored before snapshot processing resumes
-
-### Impact
-- Leaflet map is now managed entirely in the browser and is no longer recreated on each dashboard refresh
-- Node markers remain stable and no longer flicker or disappear during the 500 ms update cycle
-- Dense contact sets can now be rendered with clustering without violating the browser-owned map lifecycle
-- Theme switching and viewport state persist reliably across reconnect events
-- No breaking changes outside the map subsystem
----
-## [1.12.1] - 2026-03-08 β Minor change bot
-### Changed
-- π `meshcore_gui/services/bot.py`: remove path id's
-### Impact
-- No breaking changes β all existing functionality preserved serial.
-
----
-
-## [1.12.0] - 2026-02-26 β MeshCore Observer Fase 1
-
-### Added
-- β **MeshCore Observer daemon** β New standalone read-only daemon (`meshcore_observer.py`) that reads archive JSON files produced by meshcore_gui and meshcore_bridge, aggregates them, and presents a unified NiceGUI monitoring dashboard on port 9093.
-- β **ArchiveWatcher** β Core component that polls `~/.meshcore-gui/archive/` for `*_messages.json` and `*_rxlog.json` files, tracks mtime changes, and returns only new entries since previous poll. Thread-safe, zero writes, graceful on corrupt JSON.
-- β **Observer dashboard panels** β Sources overview, aggregated messages feed (sorted by timestamp), aggregated RX log table, and statistics panel with uptime/counters/per-source breakdown. Full DOMCA theme (dark + light mode).
-- β **Source filter** β Dropdown to filter messages and RX log by archive source.
-- β **Channel filter** β Dropdown to filter messages by channel name.
-- β **ObserverConfig** β YAML-based configuration with `from_yaml()` classmethod, defaults work without config file.
-- β **observer_config.yaml** β Documented config template with all options.
-- β **install_observer.sh** β systemd installer (`/opt/meshcore-observer/`, `/etc/meshcore/observer_config.yaml`), with `--uninstall` option.
-- β **RxLogEntry raw packet fields** β 5 new fields on `RxLogEntry` dataclass: `raw_payload`, `packet_len`, `payload_len`, `route_type`, `packet_type_num` (all with defaults, backward compatible).
-- β **EventHandler.on_rx_log() metadata** β Raw payload hex and packet metadata now passed through to RxLogEntry and archived (preparation for Fase 2 LetsMesh uplink).
-
-### Changed
-- π `meshcore_gui/core/models.py`: RxLogEntry +5 fields with defaults (backward compatible).
-- π `meshcore_gui/ble/events.py`: on_rx_log() fills raw_payload and metadata (~10 lines added).
-- π `meshcore_gui/services/message_archive.py`: add_rx_log() serializes the 5 new RxLogEntry fields.
-- π `meshcore_gui/config.py`: Version bumped to `1.12.0`.
-
-### Impact
-- **No breaking changes** β All new RxLogEntry fields have defaults; existing archives and code work identically.
-- **New daemon** β meshcore_observer is fully standalone; no imports from meshcore_gui (reads only JSON files).
-
----
-
-### Added
-- β **Serial CLI flags** β `--baud=BAUD` and `--serial-cx-dly=SECONDS` for serial configuration at startup.
-
-### Changed
-- π **Connection layer** β Switched from BLE to serial (`MeshCore.create_serial`) with serial reconnect handling.
-- π `config.py`: Added `SERIAL_BAUDRATE`, `SERIAL_CX_DELAY`, `DEFAULT_TIMEOUT`, `MESHCORE_LIB_DEBUG`; removed BLE PIN settings; version bumped to `1.10.0`.
-- π `meshcore_gui.py` / `meshcore_gui/__main__.py`: Updated usage, banners and defaults for serial ports.
-- π Docs: Updated README and core docs for serial usage; BLE documents marked as legacy.
-
-### Impact
-- No breaking changes β all existing functionality preserved serial.
-
----
-
-## [1.9.11] - 2026-02-19 β Message Dedup Hotfix
-
-### Fixed
-- π **Duplicate messages after (re)connect** β `load_recent_from_archive()` appended archived messages on every connect attempt without clearing existing entries; after N failed connects, each message appeared N times. Method is now idempotent: clears the in-memory list before loading.
-- π **Persistent duplicate messages** β Live BLE events for messages already loaded from archive were not suppressed because the `DualDeduplicator` was never seeded with archived content. Added `_seed_dedup_from_messages()` in `BLEWorker` after cache/archive load and after reconnect.
-- π **Last-line-of-defence dedup in SharedData** β `add_message()` now maintains a fingerprint set (`message_hash` or `channel:sender:text`) and silently skips messages whose fingerprint is already tracked. This guards against duplicates regardless of their source.
-- π **Messages panel empty on first click** β `_show_panel()` made the container visible but relied on the next 500 ms timer tick to populate it. Added an immediate `_messages.update()` call so content is rendered the moment the panel becomes visible.
-
-### Changed
-- π `core/shared_data.py`: Added `_message_fingerprints` set and `_message_fingerprint()` static method; `add_message()` checks fingerprint before insert and evicts fingerprints when messages are rotated out; `load_recent_from_archive()` clears messages and fingerprints before loading (idempotent)
-- π `ble/worker.py`: Added `_seed_dedup_from_messages()` helper; called after `_apply_cache()` and after reconnect `_load_data()` to seed `DualDeduplicator` with existing messages
-- π `gui/dashboard.py`: `_show_panel()` now forces an immediate `_messages.update()` when the messages panel is shown, eliminating the stale-content flash
-- π `config.py`: Version bumped to `1.9.11`
-
-### Impact
-- Eliminates all duplicate message display scenarios: initial connect, failed retries, reconnect, and BLE event replay
-- No breaking changes β all existing functionality preserved
-- Fingerprint set is bounded to the same 100-message cap as the message list
-
----
-
-## [1.9.10] - 2026-02-19 β Map Tooltips & Separate Own-Position Marker
-
-### Added
-- β **Map marker tooltips** β All markers on the Leaflet map now show a tooltip on hover with the node name and type icon (π±, π‘, π ) from `TYPE_ICONS`
-- β **Separate own-position marker** β The device's own position is now tracked as a dedicated `_own_marker`, independent from contact markers. This prevents the own marker from being removed/recreated on every contact update cycle
-
-### Changed
-- π `gui/panels/map_panel.py`: Renamed `_markers` to `_contacts_markers`; added `_own_marker` attribute; own position marker is only updated when `device_updated` flag is set (not every timer tick); contact markers are only rebuilt when `contacts_updated` is set; added `TYPE_ICONS` import for tooltip icons
-- π `gui/dashboard.py`: Added `self._map.update(data)` call in the `device_updated` block so the own-position marker updates when device info changes (e.g. GPS position update)
-- π `config.py`: Version bumped to `1.9.10`
-
-### Impact
-- Map centering on own device now works correctly and updates only when position actually changes
-- Contact markers are no longer needlessly destroyed and recreated on every UI timer tick β only on actual contact data changes
-- Tooltips make it easy to identify nodes on the map without clicking
-- No breaking changes β all existing map functionality preserved
-
-### Credits
-- Based on [PR #16](https://github.com/pe1hvh/meshcore-gui/pull/16) by [@rich257](https://github.com/rich257)
-
----
-
-## [1.9.9] - 2026-02-18 β Variable Landing Page & Operator Callsign
-
-### Added
-- β **Configurable operator callsign** β New `OPERATOR_CALLSIGN` constant in `config.py` (default: `"PE1HVH"`). Used in the landing page SVG and the drawer footer copyright label. Change this single value to personalize the entire GUI for a different operator
-- β **External landing page SVG** β The DOMCA splash screen is now loaded from a standalone file (`static/landing_default.svg`) instead of being hardcoded in `dashboard.py`. New `LANDING_SVG_PATH` constant in `config.py` points to the SVG file. The placeholder `{callsign}` in the SVG is replaced at runtime with `OPERATOR_CALLSIGN`
-- β **Landing page customization** β To use a custom landing page: copy `landing_default.svg` (or create your own SVG), use `{callsign}` wherever the operator callsign should appear, and point `LANDING_SVG_PATH` to your file. The default SVG includes an instructive comment block explaining the placeholder mechanism
-
-### Changed
-- π `config.py`: Added `OPERATOR_CALLSIGN` and `LANDING_SVG_PATH` constants in new **OPERATOR / LANDING PAGE** section; version bumped to `1.9.9`
-- π `gui/dashboard.py`: Removed hardcoded `_DOMCA_SVG` string (~70 lines); added `_load_landing_svg()` helper that reads SVG from disk and replaces `{callsign}` placeholder; CSS variable `--pe1hvh` renamed to `--callsign`; drawer footer copyright label now uses `config.OPERATOR_CALLSIGN`
-
-### Added (files)
-- β `static/landing_default.svg` β The original DOMCA splash SVG extracted as a standalone file, with `{callsign}` placeholder and `--callsign` CSS variable. Serves as both the default landing page and a reference template for custom SVGs
-
-### Impact
-- Out-of-the-box behavior is identical to v1.9.8 (same DOMCA branding, same PE1HVH callsign)
-- Operators personalize by changing 1β2 lines in `config.py` β no code modifications needed
-- Fallback: if the SVG file is missing, a minimal placeholder text is shown instead of a crash
-- No breaking changes β all existing dashboard functionality (panels, menus, timer, theming) unchanged
-
----
-
-## [1.9.8] - 2026-02-17 β Bugfix: Route Page Sender ID, Type & Location Not Populated
-
-### Fixed
-- π **Sender ID, Type and Location empty in Route Page** β After the v4.1 refactoring to `RouteBuilder`/`RouteNode`, the sender contact lookup relied solely on `SharedData.get_contact_by_prefix()` (live lock-based) and `get_contact_by_name()`. When both failed (e.g. empty `sender_pubkey` from RX_LOG decode, or name mismatch), `route['sender']` remained `None` and the route table fell through to a hardcoded fallback with `type: '-'`, `location: '-'`. The contact data was available in the snapshot `data['contacts']` but was never searched
-- π **Route table fallback row ignored available contact data** β When `route['sender']` was `None`, the `_render_route_table` method used a static fallback row without attempting to find the contact in the data snapshot. Even when the contact was present in `data['contacts']` with valid type and location, these fields showed as `'-'`
-
-### Changed
-- π `services/route_builder.py`: Added two additional fallback strategies in `build()` after the existing SharedData lookups: (3) bidirectional pubkey prefix match against `data['contacts']` snapshot, (4) case-insensitive `adv_name` match against `data['contacts']` snapshot. Added helper methods `_find_contact_by_pubkey()` and `_find_contact_by_adv_name()` for snapshot-based lookups
-- π `gui/route_page.py`: Added defensive fallback in `_render_route_table()` sender section β when `route['sender']` is `None`, attempts to find the contact in the snapshot via `_find_sender_contact()` before falling back to the static `'-'` row. Added `_find_sender_contact()` helper method
-
-### Impact
-- Sender ID (hash), Type and Location are now populated correctly in the route table when the contact is known
-- Four-layer lookup chain ensures maximum resolution: (1) SharedData pubkey lookup, (2) SharedData name lookup, (3) snapshot pubkey lookup, (4) snapshot name lookup
-- Defensive fallback in route_page guarantees data is shown even if RouteBuilder misses it
-- No breaking changes β all existing route page behavior, styling and data flows unchanged
-
----
-
-## [1.9.7] - 2026-02-17 β Layout Fix: Archive Filter Toggle & Route Page Styling
-
-### Changed
-- π `gui/archive_page.py`: Archive filter card now hidden by default; toggle visibility via a `filter_list` icon button placed right-aligned on the same row as the "π Archive" title. Header restructured from single label to `ui.row()` with `justify-between` layout
-- π `gui/route_page.py`: Route page now uses DOMCA theme (imported from `dashboard.py`) with dark mode as default, consistent with the main dashboard. Header restyled from `bg-blue-600` to Quasar-themed header with JetBrains Mono font. Content container changed from `w-full max-w-4xl mx-auto` to `domca-panel` class for consistent responsive sizing
-- π `gui/dashboard.py`: Added `domca-header-text` CSS class with `@media (max-width: 599px)` rule to hide header text on narrow viewports; applied to version label and status label
-- π `gui/route_page.py`: Header label also uses `domca-header-text` class for consistent responsive behaviour
-
-### Added
-- β **Archive filter toggle** β `filter_list` icon button in archive header row toggles the filter card visibility on click
-- β **Route page close button** β `X` (close) icon button added right-aligned in the route page header; calls `window.close()` to close the browser tab
-- β **Responsive header** β On viewports < 600px, header text labels are hidden; only icon buttons (menu, dark mode toggle, close) remain visible
-
-### Impact
-- Archive page is cleaner by default β filters only shown when needed
-- Route page visually consistent with the main dashboard (DOMCA theme, dark mode, responsive panel width)
-- Headers degrade gracefully on mobile (< 600px): only icon buttons visible, no text overflow
-- No functional changes β all event handlers, callbacks, data bindings, logic and imports are identical to the input
-
----
-
-## [1.9.6] - 2026-02-17 β Bugfix: Channel Discovery Reliability
-
-### Fixed
-- π **Channels not appearing (especially on mobile)** β Channel discovery aborted too early on slow BLE connections. The `_discover_channels()` probe used a single attempt per channel slot and stopped after just 2 consecutive empty responses. On mobile BLE stacks (WebBluetooth via NiceGUI) where GATT responses are slower, this caused discovery to abort before finding any channels, falling back to only `[0] Public`
-- π **Race condition: channel update flag lost between threads** β `get_snapshot()` and `clear_update_flags()` were two separate calls, each acquiring the lock independently. If the BLE worker set `channels_updated = True` between these two calls, the GUI consumed the flag via `get_snapshot()` but then `clear_update_flags()` reset it β causing the channel submenu and dropdown to never populate
-- π **Channels disappear on browser reconnect** β When a browser tab is closed and reopened, `render()` creates new (empty) NiceGUI containers for the drawer submenus, but did not reset `_last_channel_fingerprint`. The `_update_submenus()` method compared the new fingerprint against the stale one, found them equal, and skipped the rebuild β leaving the new containers permanently empty. Fixed by resetting both `_last_channel_fingerprint` and `_last_rooms_fingerprint` in `render()`
-
-### Changed
-- π `core/shared_data.py`: New atomic method `get_snapshot_and_clear_flags()` that reads the snapshot and resets all update flags in a single lock acquisition. Internally refactored to `_build_snapshot_unlocked()` helper. Existing `get_snapshot()` and `clear_update_flags()` retained for backward compatibility
-- π `ble/worker.py`: `_discover_channels()` β `max_attempts` increased from 1 to 2 per channel slot; inter-attempt `delay` increased from 0.5s to 1.0s; consecutive error threshold raised from 2 to 3; inter-channel pause increased from 0.15s to 0.3s for mobile BLE stack breathing room
-- π `gui/dashboard.py`: `_update_ui()` now uses `get_snapshot_and_clear_flags()` instead of separate `get_snapshot()` + `clear_update_flags()`; `render()` now resets `_last_channel_fingerprint` and `_last_rooms_fingerprint` to `None` so that `_update_submenus()` rebuilds into the freshly created containers; channel-dependent updates (`update_filters`, `update_channel_options`, `_update_submenus`) now run unconditionally when channel data exists β safe because each method has internal idempotency checks
-- π `gui/panels/messages_panel.py`: `update_channel_options()` now includes an equality check on options dict to skip redundant `.update()` calls to the NiceGUI client on every 500ms timer tick
-
-### Impact
-- Channel discovery now survives transient BLE timeouts that are common on mobile connections
-- Atomic snapshot eliminates the threading race condition that caused channels to silently never appear
-- Browser close+reopen no longer loses channels β the single-instance timer race on the shared `DashboardPage` is fully mitigated
-- No breaking changes β all existing API methods retained, all other functionality unchanged
-
----
-
-## [1.9.5] - 2026-02-16 β Layout Fix: RX Log Table Responsive Sizing
-
-### Fixed
-- π **RX Log table did not adapt to panel/card size** β The table used `max-h-48` (a maximum height cap) instead of a responsive fixed height, causing it to remain small regardless of available space. Changed to `h-40` which is overridden by the existing dashboard CSS to `calc(100vh - 20rem)` β the same responsive pattern used by the Messages panel
-- π **RX Log table did not fill card width** β Added `w-full` class to the table element so it stretches to the full width of the parent card
-- π **RX Log card did not fill panel height** β Added `flex-grow` class to the card container so it expands to fill the available panel space
-
-### Changed
-- π `gui/panels/rxlog_panel.py`: Card classes `'w-full'` β `'w-full flex-grow'` (line 45); table classes `'text-xs max-h-48 overflow-y-auto'` β `'w-full text-xs h-40 overflow-y-auto'` (line 65)
-
-### Impact
-- RX Log table now fills the panel consistently on both desktop and mobile viewports
-- Layout is consistent with other panels (Messages, Contacts) that use the same `h-40` responsive height pattern
-- No functional changes β all event handlers, callbacks, data bindings, logica and imports are identical to the input
-
----
-
-## [1.9.4] - 2026-02-16 β BLE Address Log Prefix & Entry Point Cleanup
-
-### Added
-- β **BLE address prefix in log filename** β Log file is now named `_meshcore_gui.log` (e.g. `AA_BB_CC_DD_EE_FF_meshcore_gui.log`) instead of the generic `meshcore_gui.log`. Makes it easy to identify which device produced which log file when running multiple instances
- - New helper `_sanitize_ble_address()` strips `literal:` prefix and replaces colons with underscores
- - New function `configure_log_file(ble_address)` updates `LOG_FILE` at runtime before the logger is initialised
- - Rotated backups follow the same naming pattern automatically
-
-### Removed
-- β **`meshcore_gui/meshcore_gui.py`** β Redundant copy of `main()` that was never imported. All three entry points (`meshcore_gui.py` root, `__main__.py`, and `meshcore_gui/meshcore_gui.py`) contained near-identical copies of the same logic, causing changes to be missed (as demonstrated by this fix). `__main__.py` is now the single source of truth; root `meshcore_gui.py` is a thin wrapper that imports from it
-
-### Changed
-- π `config.py`: Added `_sanitize_ble_address()` and `configure_log_file()`; version bumped to `1.9.4`
-- π `__main__.py`: Added `config.configure_log_file(ble_address)` call before any debug output
-- π `meshcore_gui.py` (root): Reduced to 4-line wrapper importing `main` from `__main__`
-
-### Impact
-- Log files are now identifiable per BLE device
-- Single source of truth for `main()` eliminates future sync issues between entry points
-- Both startup methods (`python meshcore_gui.py` and `python -m meshcore_gui`) remain functional
-- No breaking changes β defaults and all existing behaviour unchanged
----
-
-## [1.9.3] - 2026-02-16 β Bugfix: Map Default Location & Payload Type Decoding
-
-### Fixed
-- π **Map centred on hardcoded Zwolle instead of device location** β All Leaflet maps used magic-number coordinates `(52.5, 6.0)` as initial centre and fallback. These are now replaced by a single configurable constant `DEFAULT_MAP_CENTER` in `config.py`. Once the device reports a valid `adv_lat`/`adv_lon`, maps re-centre on the actual device position (existing behaviour, unchanged)
-- π **Payload type shown as raw integer** β Payload type is now retrieved from the decoded payload and translated to human-readable text using MeshCoreDecoder functions, instead of displaying the raw numeric type value
-
-### Changed
-- π `config.py`: Added `DEFAULT_MAP_CENTER` (default: `(52.5168, 6.0830)`) and `DEFAULT_MAP_ZOOM` (default: `9`) constants in new **MAP DEFAULTS** section. Version bumped to `1.9.2`
-- π `gui/panels/map_panel.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; `ui.leaflet(center=...)` uses config constants instead of hardcoded values
-- π `gui/route_page.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; fallback coordinates (`or 52.5` / `or 6.0`) replaced by `DEFAULT_MAP_CENTER[0]` / `[1]`; zoom uses `DEFAULT_MAP_ZOOM`
-
-### Impact
-- Map default location is now a single-point-of-change in `config.py`
-- Payload type is displayed as readable text instead of a raw number
-- No breaking changes β all existing map behaviour (re-centre on device position, contact markers) unchanged
-
-## [1.9.2] - 2026-02-15 β CLI Parameters & Cleanup
-
-### Added
-- β **`--port=PORT` CLI parameter** β Web server port is now configurable at startup (default: `8081`). Allows running multiple instances simultaneously on different ports
-- β **`--ble-pin=PIN` CLI parameter** β BLE pairing PIN is now configurable at startup (default: `123456`). Eliminates the need to edit `config.py` for devices with a non-default PIN, and works in systemd service files
-- β **Per-device log file** β Debug log file now includes the BLE address in its filename (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`), so multiple instances log to separate files
-
-### Fixed
-- π **BLE PIN not applied from CLI** β `ble/worker.py` imported `BLE_PIN` as a constant at module load time (`from config import BLE_PIN`), capturing the default value `"123456"` before CLI parsing could override `config.BLE_PIN`. Changed to runtime access via `config.BLE_PIN` so the `--ble-pin` parameter is correctly passed to the BLE agent
-
-### Removed
-- β **Redundant `meshcore_gui/meshcore_gui.py`** β This file was a near-identical copy of both `meshcore_gui.py` (top-level) and `meshcore_gui/__main__.py`, but was never imported or referenced. Removed to eliminate maintenance risk. The two remaining entry points cover all startup methods: `python meshcore_gui.py` and `python -m meshcore_gui`
-
-### Impact
-- Multiple instances can run side-by-side with different ports, PINs and log files
-- Service deployments no longer require editing `config.py` β all runtime settings via CLI
-- No breaking changes β all defaults are unchanged
-
----
-
-## [1.9.1] - 2026-02-14 β Bugfix: Dual Reconnect Conflict
-
-### Fixed
-- π **Library reconnect interfered with application reconnect** β The meshcore library's internal `auto_reconnect` (visible in logs as `"Attempting reconnection 1/3"`) ran a fast 3-attempt reconnect cycle without bond cleanup. This prevented the application's own `reconnect_loop` (which does `remove_bond()` + backoff) from succeeding, because BlueZ retained a stale bond β `"failed to discover service"`
-
-### Changed
-- π `ble/worker.py`: Set `auto_reconnect=False` in both `MeshCore.create_ble()` call sites (`_connect()` and `_create_fresh_connection()`), so only the application's bond-aware `reconnect_loop` handles reconnection
-- π `ble/worker.py`: Added `"failed to discover"` and `"service discovery"` to disconnect detection keywords for defensive coverage
-
-### Impact
-- Eliminates the ~9 second wasted library reconnect cycle after every BLE disconnect
-- Application's `reconnect_loop` (with bond cleanup) now runs immediately after disconnect detection
-- No breaking changes β the application reconnect logic was already fully functional
-
----
-
-## [1.9.0] - 2026-02-14 β BLE Connection Stability
-
-### Added
-- β **Built-in BLE PIN agent** β New `ble/ble_agent.py` registers a D-Bus agent with BlueZ to handle PIN pairing requests automatically. Eliminates the need for external `bt-agent.service` and `bluez-tools` package
- - Uses `dbus_fast` (already a dependency of `bleak`, no new packages)
- - Supports `RequestPinCode`, `RequestPasskey`, `DisplayPasskey`, `RequestConfirmation`, `AuthorizeService` callbacks
- - Configurable PIN via `BLE_PIN` in `config.py` (default: `123456`)
-- β **Automatic bond cleanup** β New `ble/ble_reconnect.py` provides `remove_bond()` function that removes stale BLE bonds via D-Bus, equivalent to `bluetoothctl remove `. Called automatically on startup and before each reconnect attempt
-- β **Automatic reconnect after disconnect** β BLEWorker main loop now detects BLE disconnects (via connection error exceptions) and automatically triggers a reconnect sequence: bond removal β linear backoff wait β fresh connection β re-wire handlers β reload device data
- - Configurable via `RECONNECT_MAX_RETRIES` (default: 5) and `RECONNECT_BASE_DELAY` (default: 5.0s)
- - After all retries exhausted: waits 60s then starts a new retry cycle (infinite recovery)
-- β **Generic install script** β `install_ble_stable.sh` auto-detects user, project directory, venv path and entry point to generate systemd service and D-Bus policy. Supports `--uninstall` flag
-
-### Changed
-- π **`ble/worker.py`** β `_async_main()` rewritten with three phases: (1) start PIN agent, (2) remove stale bond, (3) connect + main loop with disconnect detection. Reconnect logic re-wires all event handlers and reloads device data after successful reconnection
-- π **`config.py`** β Added `BLE_PIN`, `RECONNECT_MAX_RETRIES`, `RECONNECT_BASE_DELAY` constants
-
-### Removed
-- β **`bt-agent.service` dependency** β No longer needed; PIN pairing is handled by the built-in agent
-- β **`bluez-tools` system package** β No longer needed
-- β **`~/.meshcore-ble-pin` file** β No longer needed
-- β **Manual `bluetoothctl remove` before startup** β Handled automatically
-- β **`ExecStartPre` in systemd service** β Bond cleanup is internal
-
-### Impact
-- Zero external dependencies for BLE pairing on Linux
-- Automatic recovery from the T1000e ~2 hour BLE disconnect issue
-- No manual intervention needed after BLE connection loss
-- Single systemd service (`meshcore-gui.service`) manages everything
-- No breaking changes to existing functionality
-
----
-
-## [1.8.0] - 2026-02-14 β DRY Message Construction & Archive Layout Unification
-
-### Fixed
-- π **Case-sensitive prefix matching** β `get_contact_name_by_prefix()` and `get_contact_by_prefix()` in `shared_data.py` failed to match path hashes (uppercase, e.g. `'B8'`) against contact pubkeys (lowercase, e.g. `'b8a3f2...'`). Added `.lower()` to both sides of the comparison, consistent with `_resolve_path_names()` which already had it
-- π **Route page 404 from archive** β Archive page linked to `/route/{hash}` but route was registered as `/route/{msg_index:int}`, causing a JSON parse error for hex hash strings. Route parameter changed to `str` with 3-strategy lookup (index β memory hash β archive fallback)
-- π **Three entry points out of sync** β `meshcore_gui.py` (root), `meshcore_gui/meshcore_gui.py` (inner) and `meshcore_gui/__main__.py` had diverging route registrations. All three now use identical `/route/{msg_key}` with `str` parameter
-
-### Changed
-- π **`core/models.py` β DRY factory methods and formatting**
- - `Message.now_timestamp()`: static method replacing 7Γ hardcoded `datetime.now().strftime('%H:%M:%S')` across `events.py` and `commands.py`
- - `Message.incoming()`: classmethod factory for received messages (`direction='in'`, auto-timestamp)
- - `Message.outgoing()`: classmethod factory for sent messages (`sender='Me'`, `direction='out'`, auto-timestamp)
- - `Message.format_line(channel_names)`: single-line display formatting (`"12:34:56 β [Public] [2hβ] PE1ABC: Hello mesh!"`), replacing duplicate inline formatting in `messages_panel.py` and `archive_page.py`
-- π **`ble/events.py`** β 4Γ `Message(...)` constructors replaced by `Message.incoming()`; `datetime` import removed
-- π **`ble/commands.py`** β 3Γ `Message(...)` constructors replaced by `Message.outgoing()`; `datetime` import removed
-- π **`gui/panels/messages_panel.py`** β 15 lines inline formatting replaced by single `msg.format_line(channel_names)` call
-- π **`gui/archive_page.py` β Layout unified with main page**
- - Multi-row card layout replaced by single-line `msg.format_line()` in monospace container (same style as main page)
- - DM added to channel filter dropdown (post-filter on `channel is None`)
- - Message click opens `/route/{message_hash}` in new tab (was: no click handler on archive messages)
- - Removed `_render_message_card()` (98 lines) and `_render_archive_route()` (75 lines)
- - Removed `RouteBuilder` dependency and `TYPE_LABELS` import
- - File reduced from 445 to 267 lines
-- π **`gui/route_page.py`** β `render(msg_index: int)` β `render(msg_key: str)` with 3-strategy message lookup: (1) numeric index from in-memory list, (2) hash match in memory, (3) `archive.get_message_by_hash()` fallback
-- π **`services/message_archive.py`** β New method `get_message_by_hash(hash)` for single-message lookup by packet hash
-- π **`__main__.py` + `meshcore_gui.py` (both)** β Route changed from `/route/{msg_index}` (int) to `/route/{msg_key}` (str)
-
-### Impact
-- DRY: timestamp formatting 7β1 definition, message construction 7β2 factories, line formatting 2β1 method
-- Archive page visually consistent with main messages panel (single-line, monospace)
-- Archive messages now clickable to open route visualization (was: only in-memory messages)
-- Case-insensitive prefix matching fixes path name resolution for contacts with uppercase path hashes
-- No breaking changes to BLE protocol handling, dedup, bot, or data storage
-
-### Known Limitations
-- DM filter in archive uses post-filtering (query without channel filter + filter on `channel is None`); becomes exact when `query_messages()` gets native DM support
-
-### Parked for later
-- Multi-path tracking (enrich RxLogEntry with multiple path observations)
-- Events correlation improvements (only if proven data loss after `.lower()` fix)
-
----
-
-## [1.7.0] - 2026-02-13 β Archive Channel Name Persistence
-
-### Added
-- β **Channel name stored in archive** β Messages now persist `channel_name` alongside the numeric `channel` index in `_messages.json`, so archived messages retain their human-readable channel name even when the device is not connected
- - `Message` dataclass: new field `channel_name: str` (default `""`, backward compatible)
- - `SharedData.add_message()`: automatically resolves `channel_name` from the live channels list when not already set (new helper `_resolve_channel_name()`)
- - `MessageArchive.add_message()`: writes `channel_name` to the JSON dict
-- β **Archive channel selector built from archived data** β Channel filter dropdown on `/archive` now populated via `SELECT DISTINCT channel_name` on the archive instead of the live BLE channels list
- - New method `MessageArchive.get_distinct_channel_names()` returns sorted unique channel names from stored messages
- - Selector shows only channels that actually have archived messages
-- β **Archive filter on channel name** β `MessageArchive.query_messages()` parameter changed from `channel: Optional[int]` to `channel_name: Optional[str]` (exact match on name string)
-
-### Changed
-- π `core/models.py`: Added `channel_name` field to `Message` dataclass and `from_dict()`
-- π `core/shared_data.py`: `add_message()` resolves channel name; added `_resolve_channel_name()` helper
-- π `services/message_archive.py`: `channel_name` persisted in JSON; `query_messages()` filters by name; new `get_distinct_channel_names()` method
-- π `gui/archive_page.py`: Channel selector built from `archive.get_distinct_channel_names()`; filter state changed from `_channel_filter` (int) to `_channel_name_filter` (str); message cards show `channel_name` directly from archive
-
-### Fixed
-- π **Main page empty after startup** β After a restart the messages panel showed no messages until new live BLE traffic arrived. `SharedData.load_recent_from_archive()` now loads up to 100 recent archived messages during the cache-first startup phase, so historical messages are immediately visible
- - New method `SharedData.load_recent_from_archive(limit)` β reads from `MessageArchive.query_messages()` and populates the in-memory list without re-archiving
- - `BLEWorker._apply_cache()` calls `load_recent_from_archive()` at the end of cache loading
-
-### Impact
-- Archived messages now self-contained β channel name visible without live BLE connection
-- Main page immediately shows historical messages after startup (no waiting for live BLE traffic)
-- Backward compatible β old archive entries without `channel_name` fall back to `"Ch "`
-- No breaking changes to existing functionality
-
----
-
-## [1.6.0] - 2026-02-13 β Dashboard Layout Consolidation
-
-### Changed
-- π **Messages panel consolidated** β Filter checkboxes (DM + channels) and message input (text field, channel selector, Send button) are now integrated into the Messages panel, replacing the separate Filter and Input panels
- - DM + channel checkboxes displayed centered in the Messages header row, between the "π¬ Messages" label and the "π Archive" button
- - Message input row (text field, channel selector, Send button) placed below the message list within the same card
- - `messages_panel.py`: Constructor now accepts `put_command` callable; added `update_filters(data)`, `update_channel_options(channels)` methods and `channel_filters`, `last_channels` properties (all logic 1:1 from FilterPanel/InputPanel); `update()` signature unchanged
-- π **Actions panel expanded** β BOT toggle checkbox moved from Filter panel to Actions panel, below the Refresh/Advert buttons
- - `actions_panel.py`: Constructor now accepts `set_bot_enabled` callable; added `update(data)` method for BOT state sync; `_on_bot_toggle()` logic 1:1 from FilterPanel
-- π **Dashboard layout simplified** β Centre column reduced from 4 panels (Map β Input β Filter β Messages) to 2 panels (Map β Messages)
- - `dashboard.py`: FilterPanel and InputPanel no longer rendered; all dependencies rerouted to MessagesPanel and ActionsPanel; `_update_ui()` call-sites updated accordingly
-
-### Removed (from layout, files retained)
-- β **Filter panel** no longer rendered as separate panel β `filter_panel.py` retained in codebase but not instantiated in dashboard
-- β **Input panel** no longer rendered as separate panel β `input_panel.py` retained in codebase but not instantiated in dashboard
-
-### Impact
-- Cleaner, more compact dashboard: 2 fewer panels in the centre column
-- All functionality preserved β message filtering, send, BOT toggle, archive all work identically
-- No breaking changes to BLE, services, core or other panels
-
----
-
-
-
-## [1.5.0] - 2026-02-11 β Room Server Support, Dynamic Channel Discovery & Contact Management
-
-### Added
-- β **Room Server panel** β Dedicated per-room-server message panel in the centre column below Messages. Each Room Server (type=3 contact) gets its own `ui.card()` with login/logout controls and message display
- - Click a Room Server contact to open an add/login dialog with password field
- - After login: messages are displayed in the room card; send messages directly from the room panel
- - Password row + login button automatically replaced by Logout button after successful login
- - Room Server author attribution via `signature` field (txt_type=2) β real message author is resolved from the 4-byte pubkey prefix, not the room server pubkey
- - New panel: `gui/panels/room_server_panel.py` β per-room card management with login state tracking
-- β **Room Server password store** β Passwords stored outside the repository in `~/.meshcore-gui/room_passwords/.json`
- - New service: `services/room_password_store.py` β JSON-backed persistent password storage per BLE device, analogous to `PinStore`
- - Room panels are restored from stored passwords on app restart
-- β **Dynamic channel discovery** β Channels are now auto-discovered from the device at startup via `get_channel()` BLE probing, replacing the hardcoded `CHANNELS_CONFIG`
- - Single-attempt probe per channel slot with early stop after 2 consecutive empty slots
- - Channel name and encryption key extracted in a single pass (combined discovery + key loading)
- - Configurable channel caching via `CHANNEL_CACHE_ENABLED` (default: `False` β always fresh from device)
- - `MAX_CHANNELS` setting (default: 8) controls how many slots are probed
-- β **Individual contact deletion** β ποΈ delete button per unpinned contact in the contacts list, with confirmation dialog
- - New command: `remove_single_contact` in BLE command handler
- - Pinned contacts are protected (no delete button shown)
-- β **"Also delete from history" option** β Checkbox in the Clean up confirmation dialog to also remove locally cached contact data
-
-
-- β **Room Server protocol research** β `RoomServer_Companion_App_Onderzoek.md` documents the full companion app message flow (login, push protocol, signature mechanism, auto_message_fetching)
-
-### Changed
-- π `config.py`: Removed `CHANNELS_CONFIG` constant; added `MAX_CHANNELS` (default: 8) and `CHANNEL_CACHE_ENABLED` (default: `False`)
-- π `ble/worker.py`: Replaced hardcoded channel loading with `_discover_channels()` method; added `_try_get_channel_info()` helper; `_apply_cache()` respects `CHANNEL_CACHE_ENABLED` setting; removed `_load_channel_keys()` (integrated into discovery pass)
-- π `ble/commands.py`: Added `login_room`, `send_room_msg` and `remove_single_contact` command handlers
-- π `gui/panels/contacts_panel.py`: Contact click now dispatches by type β type=3 (Room Server) opens room dialog, others open DM dialog; added `on_add_room` callback parameter; added ποΈ delete button per unpinned contact
-- π `gui/panels/messages_panel.py`: Room Server messages filtered from general message view via `_is_room_message()` with prefix matching; `update()` accepts `room_pubkeys` parameter
-- π `gui/dashboard.py`: Added `RoomServerPanel` in centre column; `_update_ui()` passes `room_pubkeys` to Messages panel; added `_on_add_room_server` callback
-- π `gui/panels/filter_panel.py`: Channel filter checkboxes now built dynamically from discovered channels (no hardcoded references)
-- π `services/bot.py`: Removed stale comment referencing hardcoded channels
-
-### Fixed
-- π **Room Server messages appeared as DM** β Messages from Room Servers (txt_type=2) were displayed in the general Messages panel as direct messages. They are now filtered out and shown exclusively in the Room Server panel
-- π **Historical room messages not shown after login** β Post-login fetch loop was polling `get_msg()` before room server had time to push messages over LoRa RF (10β75s per message). Removed redundant fetch loop; the library's `auto_message_fetching` handles `MESSAGES_WAITING` events correctly and event-driven
-- π **Author attribution incorrect for room messages** β Room server messages showed the room server name as sender instead of the actual message author. Now correctly resolved from the `signature` field (4-byte pubkey prefix) via contact lookup
-
-### Impact
-- Room Servers are now first-class citizens in the GUI with dedicated panels
-- Channel configuration no longer requires manual editing of `config.py`
-- Contact list management is more granular with per-contact deletion
-- No breaking changes to existing functionality (messages, DM, map, archive, bot, etc.)
-
----
-
-## [1.4.0] - 2026-02-09 β SDK Event Race Condition Fix
-
-### Fixed
-- π **BLE startup delay of ~2 minutes eliminated** β The meshcore Python SDK (`commands/base.py`) dispatched device response events before `wait_for_events()` registered its subscription. On busy networks with frequent `RX_LOG_DATA` events, this caused `send_device_query()` and `get_channel()` to fail repeatedly with `no_event_received`, wasting 110+ seconds in timeouts
-
-### Changed
-- π `meshcore` SDK `commands/base.py`: Rewritten `send()` method to subscribe to expected events **before** transmitting the BLE command (subscribe-before-send pattern), matching the approach used by the companion apps (meshcore.js, iOS, Android). Submitted upstream as [meshcore_py PR #52](https://github.com/meshcore-dev/meshcore_py/pull/52)
-
-### Impact
-- Startup time reduced from ~2+ minutes to ~10 seconds on busy networks
-- All BLE commands (`send_device_query`, `get_channel`, `get_bat`, `send_appstart`, etc.) now succeed on first attempt instead of requiring multiple retries
-- No changes to meshcore_gui code required β the fix is entirely in the meshcore SDK
-
-### Temporary Installation
-Until the fix is merged upstream, install the patched meshcore SDK:
-```bash
-pip install --force-reinstall git+https://github.com/PE1HVH/meshcore_py.git@fix/event-race-condition
-```
-
----
-
-
-
-## [1.3.2] - 2026-02-09 β Bugfix: Bot Device Name Restoration After Restart
-
-### Fixed
-- π **Bot device name not properly restored after restart/crash** β After a restart or crash with bot mode previously active, the original device name was incorrectly stored as the bot name (e.g. `NL-OV-ZWL-STDSHGN-WKC Bot`) instead of the real device name (e.g. `PE1HVH T1000e`). The original device name is now correctly preserved and restored when bot mode is disabled
-
-### Changed
-- π `commands.py`: `set_bot_name` handler now verifies that the stored original name is not already the bot name before saving
-- π `shared_data.py`: `original_device_name` is only written when it differs from `BOT_DEVICE_NAME` to prevent overwriting with the bot name on restart
-
----
-
-
-
-## [1.3.1] - 2026-02-09 β Bugfix: Auto-add AttributeError
-
-### Fixed
-- π **Auto-add error on first toggle** β Setting auto-add for the first time raised `AttributeError: 'telemetry_mode_base'`. The `set_manual_add_contacts()` SDK call now handles missing `telemetry_mode_base` attribute gracefully
-
-### Changed
-- π `commands.py`: `set_auto_add` handler wraps `set_manual_add_contacts()` call with attribute check and error handling for missing `telemetry_mode_base`
-
----
-
-
-
-## [1.3.0] - 2026-02-08 β Bot Device Name Management
-
-### Added
-- β **Bot device name switching** β When the BOT checkbox is enabled, the device name is automatically changed to a configurable bot name; when disabled, the original name is restored
- - Original device name is saved before renaming so it can be restored on BOT disable
- - Device name written to device via BLE `set_name()` SDK call
- - Graceful handling of BLE failures during name change
-- β **`BOT_DEVICE_NAME` constant** in `config.py` β Configurable fixed device name used when bot mode is active (default: `;NL-OV-ZWL-STDSHGN-WKC Bot`)
-
-### Changed
-- π `config.py`: Added `BOT_DEVICE_NAME` constant for bot mode device name
-- π `bot.py`: Removed hardcoded `BOT_NAME` prefix ("Zwolle Bot") from bot reply messages β bot replies no longer include a name prefix
-- π `filter_panel.py`: BOT checkbox toggle now triggers device name save/rename via command queue
-- π `commands.py`: Added `set_bot_name` and `restore_name` command handlers for device name switching
-- π `shared_data.py`: Added `original_device_name` field for storing the pre-bot device name
-
-### Removed
-- β `BOT_NAME` constant from `bot.py` β bot reply prefix removed; replies no longer prepend a bot display name
-
----
-
-## [1.2.0] - 2026-02-08 β Contact Maintenance Feature
-
-### Added
-- β **Pin/Unpin contacts** (Iteration A) β Toggle to pin individual contacts, protecting them from bulk deletion
- - Persistent pin state stored in `~/.meshcore-gui/cache/_pins.json`
- - Pinned contacts visually marked with yellow background
- - Pinned contacts sorted to top of contact list
- - Pin state survives app restart
- - New service: `services/pin_store.py` β JSON-backed persistent pin storage
-
-- β **Bulk delete unpinned contacts** (Iteration B) β Remove all unpinned contacts from device in one action
- - "π§Ή Clean up" button in contacts panel with confirmation dialog
- - Shows count of contacts to be removed vs. pinned contacts kept
- - Progress status updates during removal
- - Automatic device resync after completion
- - New service: `services/contact_cleaner.py` β ContactCleanerService with purge statistics
-
-- β **Auto-add contacts toggle** (Iteration C) β Control whether device automatically adds new contacts from mesh adverts
- - "π₯ Auto-add" checkbox in contacts panel (next to Clean up button)
- - Syncs with device via `set_manual_add_contacts()` SDK call
- - Inverted logic handled internally (UI "Auto-add ON" = `set_manual_add_contacts(false)`)
- - Optimistic update with automatic rollback on BLE failure
- - State synchronized from device on each GUI update cycle
-
-### Changed
-- π `contacts_panel.py`: Added pin checkbox per contact, purge button, auto-add toggle, DM dialog (all existing functionality preserved)
-- π `commands.py`: Added `purge_unpinned` and `set_auto_add` command handlers
-- π `shared_data.py`: Added `auto_add_enabled` field with thread-safe getter/setter
-- π `protocols.py`: Added `set_auto_add_enabled` and `is_auto_add_enabled` to Writer and Reader protocols
-- π `dashboard.py`: Passes `PinStore` and `set_auto_add_enabled` callback to ContactsPanel
-- π **UI language**: All Dutch strings in `contacts_panel.py` and `commands.py` translated to English
-
----
-
-### Fixed
-- π **Route table names and IDs not displayed** β Route tables in both current messages (RoutePage) and archive messages (ArchivePage) now correctly show node names and public key IDs for sender, repeaters and receiver
-
-### Changed
-- π **CHANGELOG.md**: Corrected version numbering to semantic versioning, fixed inaccurate references (archive button location, filter state persistence)
-- π **README.md**: Added Message Archive feature, updated project structure, configuration table and architecture diagram
-- π **MeshCore_GUI_Design.docx**: Added ArchivePage, MessageArchive, Models components; updated project structure, protocols, configuration and version history
-
----
-
-## [1.1.0] - 2026-02-07 β Archive Viewer Feature
-
-
-### Added
-- β **Archive Viewer Page** (`/archive`) β Full-featured message archive browser
- - Pagination (50 messages per page, configurable)
- - Channel filter dropdown (All + configured channels)
- - Time range filter (24h, 7d, 30d, 90d, All time)
- - Text search (case-insensitive)
- - Filter state stored in instance variables (reset on page reload)
- - Message cards with same styling as main messages panel
- - Clickable messages for route visualization (where available)
- - **π¬ Reply functionality** β Expandable reply panel per message
- - **πΊοΈ Inline route table** β Expandable route display per archive message with sender, repeaters and receiver (names, IDs, node types)
- - *(Note: Reply panels and inline route tables removed in v1.8.0, replaced by click-to-route navigation via message hash)*
-
-
-
-
-
-- β **MessageArchive.query_messages()** method
- - Filter by: time range, channel, text search, sender
- - Pagination support (limit, offset)
- - Returns tuple: (messages, total_count)
- - Sorting: Newest first
-
-- β **UI Integration**
- - "π Archive" button in Messages panel header (opens in new tab)
- - Back to Dashboard button in archive page
-
-
-
-- β **Reply Panel**
- - Expandable reply per message (π¬ Reply button)
- - Pre-filled with @sender mention
- - Channel selector
- - Send button with success notification
- - Auto-close expansion after send
-
-### Changed
-- π `SharedData.get_snapshot()`: Now includes `'archive'` field
-- π `MessagesPanel`: Added archive button in header row
-- π Both entry points (`__main__.py` and `meshcore_gui.py`): Register `/archive` route
-
-
-
-### Performance
-- Query: ~10ms for 10k messages with filters
-- Memory: ~10KB per page (50 messages)
-- No impact on main UI (separate page)
-
-### Known Limitations
-- ~~Route visualization only works for messages in recent buffer (last 100)~~ β Fixed in v1.8.0: archive messages now support click-to-route via `get_message_by_hash()` fallback
-- Text search is linear scan (no indexing yet)
-- Sender filter exists in API but not in UI yet
-
----
-
-## [1.0.3] - 2026-02-07 β Critical Bugfix: Archive Overwrite Prevention
-
-
-### Fixed
-- π **CRITICAL**: Fixed bug where archive was overwritten instead of appended on restart
-- π Archive now preserves existing data when read errors occur
-- π Buffer is retained for retry if existing archive cannot be read
-
-### Changed
-- π `_flush_messages()`: Early return on read error instead of overwriting
-- π `_flush_rxlog()`: Early return on read error instead of overwriting
-- π Better error messages for version mismatch and JSON decode errors
-
-### Details
-**Problem:** If the existing archive file had a JSON parse error or version mismatch,
-the flush operation would proceed with `existing_messages = []`, effectively
-overwriting all historical data with only the new buffered messages.
-
-**Solution:** The flush methods now:
-1. Try to read existing archive first
-2. If read fails (JSON error, version mismatch, IO error), abort the flush
-3. Keep buffer intact for next retry
-4. Only clear buffer after successful write
-
-**Impact:** No data loss on restart or when archive files have issues.
-
-### Testing
-- β Added `test_append_on_restart_not_overwrite()` integration test
-- β Verifies data is appended across multiple sessions
-- β All existing tests still pass
-
----
-
-## [1.0.2] - 2026-02-07 β RxLog message_hash Enhancement
-
-
-### Added
-- β `message_hash` field added to `RxLogEntry` model
-- β RxLog entries now include message_hash for correlation with messages
-- β Archive JSON includes message_hash in rxlog entries
-
-### Changed
-- π `events.py`: Restructured `on_rx_log()` to extract message_hash before creating RxLogEntry
-- π `message_archive.py`: Updated rxlog archiving to include message_hash field
-- π Tests updated to verify message_hash persistence
-
-### Benefits
-- **Correlation**: Link RX log entries to their corresponding messages
-- **Analysis**: Track which packets resulted in messages
-- **Debugging**: Better troubleshooting of packet processing
-
----
-
-## [1.0.1] - 2026-02-07 β Entry Point Fix
-
-
-### Fixed
-- β `meshcore_gui.py` (root entry point) now passes ble_address to SharedData
-- β Archive works correctly regardless of how application is started
-
-### Changed
-- π Both entry points (`meshcore_gui.py` and `meshcore_gui/__main__.py`) updated
-
----
-
-## [1.0.0] - 2026-02-07 β Message & Metadata Persistence
-
-
-### Added
-- β MessageArchive class for persistent storage
-- β Configurable retention periods (MESSAGE_RETENTION_DAYS, RXLOG_RETENTION_DAYS, CONTACT_RETENTION_DAYS)
-- β Automatic daily cleanup of old data
-- β Batch writes for performance
-- β Thread-safe with separate locks
-- β Atomic file writes
-- β Contact retention in DeviceCache
-- β Archive statistics API
-- β Comprehensive tests (20+ unit, 8+ integration)
-- β Full documentation
-
-### Storage Locations
-- `~/.meshcore-gui/archive/_messages.json`
-- `~/.meshcore-gui/archive/_rxlog.json`
-
-### Requirements Completed
-- R1: All incoming messages persistent β
-- R2: All incoming RxLog entries persistent β
-- R3: Configurable retention β
-- R4: Automatic cleanup β
-- R5: Backward compatibility β
-- R6: Contact retention β
-- R7: Archive stats API β
-
-- Fix3: Leaflet asset injection is now per page render instead of process-global, and browser bootstrap now retries until the host element, Leaflet runtime, and MeshCore panel runtime are all available. This fixes blank map containers caused by missing or late-loaded JS/CSS assets.
-
-- Fix5: Removed per-snapshot map invalidate calls, stopped forcing a default dark theme during map bootstrap, and added client-side interaction/resize guards so zooming stays responsive and the theme no longer jumps back during status-loop updates.
-
-
-## 2026-03-09 map hotfix v2
-- regular map snapshots no longer carry theme state
-- explicit theme changes are now handled only via the dedicated theme channel
-- initial map render now sends an ensure_map command plus an immediate theme sync
-- added no-op ensure_map handling in the Leaflet runtime to avoid accidental fallback behaviour
diff --git a/meshcore_gui/ble/commands.py b/meshcore_gui/ble/commands.py
index 027861f..20934bd 100644
--- a/meshcore_gui/ble/commands.py
+++ b/meshcore_gui/ble/commands.py
@@ -18,6 +18,8 @@ from meshcore_gui.services.cache import DeviceCache
class CommandHandler:
+ MAX_REPLY_LEN: int = 180
+
"""Dispatches and executes commands sent from the GUI.
Args:
@@ -67,6 +69,34 @@ class CommandHandler:
else:
debug_print(f"Unknown command action: {action}")
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ def _split_reply(self, text: str) -> List[str]:
+ """Split long replies into transport-safe chunks on line boundaries."""
+ if not text:
+ return []
+ lines = str(text).splitlines() or [str(text)]
+ chunks: List[str] = []
+ current = ""
+ for line in lines:
+ line = line.rstrip()
+ candidate = line if not current else f"{current}\n{line}"
+ if len(candidate) <= self.MAX_REPLY_LEN:
+ current = candidate
+ continue
+ if current:
+ chunks.append(current)
+ current = ""
+ while len(line) > self.MAX_REPLY_LEN:
+ chunks.append(line[:self.MAX_REPLY_LEN])
+ line = line[self.MAX_REPLY_LEN:]
+ current = line
+ if current:
+ chunks.append(current)
+ return chunks
+
# ------------------------------------------------------------------
# Individual command handlers
# ------------------------------------------------------------------
@@ -76,11 +106,13 @@ class CommandHandler:
text = cmd.get('text', '')
is_bot = cmd.get('_bot', False)
if text:
- await self._mc.commands.send_chan_msg(channel, text)
+ chunks = self._split_reply(text)
+ for idx, chunk in enumerate(chunks):
+ await self._mc.commands.send_chan_msg(channel, chunk)
+ if idx + 1 < len(chunks):
+ await asyncio.sleep(0.2)
if not is_bot:
- self._shared.add_message(Message.outgoing(
- text, channel,
- ))
+ self._shared.add_message(Message.outgoing(text, channel))
debug_print(
f"{'BOT' if is_bot else 'Sent'} message to "
f"channel {channel}: {text[:30]}"
@@ -91,10 +123,12 @@ class CommandHandler:
text = cmd.get('text', '')
contact_name = cmd.get('contact_name', pubkey[:8])
if text and pubkey:
- await self._mc.commands.send_msg(pubkey, text)
- self._shared.add_message(Message.outgoing(
- text, None, sender_pubkey=pubkey,
- ))
+ chunks = self._split_reply(text)
+ for idx, chunk in enumerate(chunks):
+ await self._mc.commands.send_msg(pubkey, chunk)
+ if idx + 1 < len(chunks):
+ await asyncio.sleep(0.2)
+ self._shared.add_message(Message.outgoing(text, None, sender_pubkey=pubkey))
debug_print(f"Sent DM to {contact_name}: {text[:30]}")
async def _cmd_send_advert(self, cmd: Dict) -> None:
diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py
index 361836c..4a818f9 100644
--- a/meshcore_gui/ble/events.py
+++ b/meshcore_gui/ble/events.py
@@ -208,7 +208,34 @@ class EventHandler:
path_len=decoded.path_length,
path_hashes=decoded.path_hashes,
)
-
+
+ # BBS channel hook: auto-whitelist sender and reply
+ # for '!bbs' on a configured BBS channel.
+ # Must run here because on_channel_msg is suppressed
+ # by content-dedup when on_rx_log already stored the
+ # message (the common path for resolved channel_idx).
+ if (
+ self._bbs_handler is not None
+ and self._command_sink is not None
+ ):
+ bbs_reply = self._bbs_handler.handle_channel_msg(
+ channel_idx=decoded.channel_idx,
+ sender=decoded.sender,
+ sender_key=sender_pubkey,
+ text=decoded.text,
+ )
+ if bbs_reply is not None:
+ debug_print(
+ f"BBS channel reply (rx_log) on "
+ f"ch{decoded.channel_idx} to "
+ f"{decoded.sender!r}: {bbs_reply[:60]}"
+ )
+ self._command_sink({
+ "action": "send_message",
+ "channel": decoded.channel_idx,
+ "text": bbs_reply,
+ })
+
# Add RX log entry with message_hash and path info (if available)
# ββ Fase 1 Observer: raw packet metadata ββ
raw_packet_len = len(payload_hex) // 2 if payload_hex else 0
@@ -276,8 +303,13 @@ class EventHandler:
f"text={msg_text[:40]!r}"
)
- sender_pubkey = ''
- if sender:
+ sender_pubkey = (
+ payload.get('pubkey_prefix')
+ or payload.get('sender_pubkey')
+ or payload.get('signature')
+ or ''
+ )
+ if not sender_pubkey and sender:
match = self._shared.get_contact_by_name(sender)
if match:
sender_pubkey, _contact = match
diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py
index d7c3dc5..f489cbf 100644
--- a/meshcore_gui/config.py
+++ b/meshcore_gui/config.py
@@ -25,7 +25,7 @@ from typing import Any, Dict, List
# ==============================================================================
-VERSION: str = "1.14.0"
+VERSION: str = "1.14.2"
# ==============================================================================
diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py
index 5abb6b9..41f7ef6 100644
--- a/meshcore_gui/gui/panels/bbs_panel.py
+++ b/meshcore_gui/gui/panels/bbs_panel.py
@@ -324,16 +324,9 @@ class BbsPanel:
)
self._service.post_message(msg)
- region_part = f'{region} ' if region else ''
- mesh_text = f'!bbs post {region_part}{category} {text}'
- self._put_command({
- 'action': 'send_message',
- 'channel': target_channel,
- 'text': mesh_text,
- })
debug_print(
- f'BBS panel: posted to board={self._active_board.id} '
- f'ch={target_channel} {mesh_text[:60]}'
+ f'BBS panel: locally posted to board={self._active_board.id} '
+ f'ch={target_channel} [{category}] {text[:60]}'
)
if self._text_input:
diff --git a/meshcore_gui/meshcore_gui/gui/dashboard.py b/meshcore_gui/meshcore_gui/gui/dashboard.py
deleted file mode 100644
index 411e67c..0000000
--- a/meshcore_gui/meshcore_gui/gui/dashboard.py
+++ /dev/null
@@ -1,850 +0,0 @@
-"""
-Main dashboard page for MeshCore GUI.
-
-Thin orchestrator that owns the layout and the 500 ms update timer.
-All visual content is delegated to individual panel classes in
-:mod:`meshcore_gui.gui.panels`.
-"""
-
-import logging
-from urllib.parse import urlencode
-
-from nicegui import ui
-
-from meshcore_gui import config
-
-from meshcore_gui.core.protocols import SharedDataReader
-from meshcore_gui.gui.panels import (
- ActionsPanel,
- BbsPanel,
- ContactsPanel,
- DevicePanel,
- MapPanel,
- MessagesPanel,
- RoomServerPanel,
- RxLogPanel,
-)
-from meshcore_gui.gui.archive_page import ArchivePage
-from meshcore_gui.services.bbs_config_store import BbsConfigStore
-from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService
-from meshcore_gui.services.pin_store import PinStore
-from meshcore_gui.services.room_password_store import RoomPasswordStore
-
-
-# Suppress the harmless "Client has been deleted" warning that NiceGUI
-# emits when a browser tab is refreshed while a ui.timer is active.
-class _DeletedClientFilter(logging.Filter):
- def filter(self, record: logging.LogRecord) -> bool:
- return 'Client has been deleted' not in record.getMessage()
-
-logging.getLogger('nicegui').addFilter(_DeletedClientFilter())
-
-
-# ββ DOMCA Theme ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar.
-# Dark/light variable sets switch via Quasar's body--dark / body--light classes.
-
-_DOMCA_HEAD = '''
-
-
-
-
-
-
-
-
-
-'''
-
-# ββ Landing SVG loader ββββββββββββββββββββββββββββββββββββββββββββββββ
-# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign}
-# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder
-# when the file is missing.
-
-
-def _load_landing_svg() -> str:
- """Load the landing page SVG from disk.
-
- Returns:
- SVG markup string with ``{callsign}`` replaced by the
- configured operator callsign.
- """
- path = config.LANDING_SVG_PATH
- try:
- raw = path.read_text(encoding="utf-8")
- return raw.replace("{callsign}", config.OPERATOR_CALLSIGN)
- except FileNotFoundError:
- return (
- ''
- )
-
-
-# ββ Standalone menu items (no submenus) ββββββββββββββββββββββββββββββ
-
-_STANDALONE_ITEMS = [
- ('\U0001f465', 'CONTACTS', 'contacts'),
- ('\U0001f5fa\ufe0f', 'MAP', 'map'),
- ('\U0001f4e1', 'DEVICE', 'device'),
- ('\u26a1', 'ACTIONS', 'actions'),
- ('\U0001f4ca', 'RX LOG', 'rxlog'),
- ('\U0001f4cb', 'BBS', 'bbs'),
-]
-
-_EXT_LINKS = config.EXT_LINKS
-
-# ββ Shared button styles βββββββββββββββββββββββββββββββββββββββββββββ
-
-_SUB_BTN_STYLE = (
- "font-family: 'JetBrains Mono', monospace; "
- "letter-spacing: 1px; font-size: 0.72rem; "
- "padding: 0.2rem 1.2rem 0.2rem 2.4rem"
-)
-
-_MENU_BTN_STYLE = (
- "font-family: 'JetBrains Mono', monospace; "
- "letter-spacing: 2px; font-size: 0.8rem; "
- "padding: 0.35rem 1.2rem"
-)
-
-
-class DashboardPage:
- """Main dashboard rendered at ``/``.
-
- Args:
- shared: SharedDataReader for data access and command dispatch.
- """
-
- def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore) -> None:
- self._shared = shared
- self._pin_store = pin_store
- self._room_password_store = room_password_store
-
- # BBS service and config store (singletons shared with bot routing)
- self._bbs_config_store = BbsConfigStore()
- self._bbs_service = BbsService()
- self._bbs_handler = BbsCommandHandler(
- self._bbs_service, self._bbs_config_store
- )
-
- # Panels (created fresh on each render)
- self._device: DevicePanel | None = None
- self._contacts: ContactsPanel | None = None
- self._map: MapPanel | None = None
- self._messages: MessagesPanel | None = None
- self._actions: ActionsPanel | None = None
- self._rxlog: RxLogPanel | None = None
- self._room_server: RoomServerPanel | None = None
- self._bbs: BbsPanel | None = None
-
- # Header status label
- self._status_label = None
-
- # Local first-render flag
- self._initialized: bool = False
-
- # Panel switching state (layout)
- self._panel_containers: dict = {}
- self._active_panel: str = 'landing'
- self._drawer = None
- self._menu_buttons: dict = {}
-
- # Submenu containers (for dynamic channel/room items)
- self._msg_sub_container = None
- self._archive_sub_container = None
- self._rooms_sub_container = None
- self._last_channel_fingerprint = None
- self._last_rooms_fingerprint = None
-
- # Archive page reference (for inline channel switching)
- self._archive_page: ArchivePage | None = None
-
- # ------------------------------------------------------------------
- # Public
- # ------------------------------------------------------------------
-
- def render(self) -> None:
- """Build the complete dashboard layout and start the timer."""
- self._initialized = False
-
- # Reset fingerprints: render() creates new (empty) NiceGUI
- # containers, so _update_submenus must rebuild into them even
- # when the channel/room data hasn't changed since last session.
- self._last_channel_fingerprint = None
- self._last_rooms_fingerprint = None
-
- # Create panel instances (UNCHANGED functional wiring)
- put_cmd = self._shared.put_command
- self._device = DevicePanel()
- self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server)
- self._map = MapPanel()
- self._messages = MessagesPanel(put_cmd)
- self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled)
- self._rxlog = RxLogPanel()
- self._room_server = RoomServerPanel(put_cmd, self._room_password_store)
- self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store)
-
- # Inject DOMCA theme (fonts + CSS variables)
- ui.add_head_html(_DOMCA_HEAD)
-
- # Default to dark mode (DOMCA theme)
- dark = ui.dark_mode(True)
- dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value))
- self._map.set_ui_dark_mode(dark.value)
-
- # ββ Left Drawer (must be created before header for Quasar) ββββ
- self._drawer = ui.left_drawer(value=False, bordered=True).classes(
- 'domca-drawer'
- ).style('padding: 0')
-
- with self._drawer:
- # DOMCA branding (clickable β landing page)
- with ui.column().style('padding: 0.2rem 1.2rem 0'):
- ui.button(
- 'DOMCA',
- on_click=lambda: self._navigate_panel('landing'),
- ).props('flat no-caps').style(
- "font-family: 'Exo 2', sans-serif; font-size: 1.4rem; "
- "font-weight: 800; color: var(--title); letter-spacing: 4px; "
- "margin-bottom: 0.3rem; padding: 0"
- )
-
- self._menu_buttons = {}
-
- # ββ π¬ MESSAGES (expandable with channel submenu) ββββββ
- with ui.expansion(
- '\U0001f4ac MESSAGES', icon=None, value=False,
- ).props('dense header-class="q-pa-none"').classes('w-full'):
- self._msg_sub_container = ui.column().classes('w-full gap-0')
- with self._msg_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('messages', channel=None)
- )
- self._make_sub_btn(
- 'DM', lambda: self._navigate_panel('messages', channel='DM')
- )
- # Dynamic channel items populated by _update_submenus
-
- # ββ π ROOMS (expandable with room submenu) βββββββββββ
- with ui.expansion(
- '\U0001f3e0 ROOMS', icon=None, value=False,
- ).props('dense header-class="q-pa-none"').classes('w-full'):
- self._rooms_sub_container = ui.column().classes('w-full gap-0')
- with self._rooms_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('rooms')
- )
- # Pre-populate from persisted rooms
- for entry in self._room_password_store.get_rooms():
- short = entry.name or entry.pubkey[:12]
- self._make_sub_btn(
- f'\U0001f3e0 {short}',
- lambda: self._navigate_panel('rooms'),
- )
-
- # ββ π ARCHIVE (expandable with channel submenu) ββββββ
- with ui.expansion(
- '\U0001f4da ARCHIVE', icon=None, value=False,
- ).props('dense header-class="q-pa-none"').classes('w-full'):
- self._archive_sub_container = ui.column().classes('w-full gap-0')
- with self._archive_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('archive', channel=None)
- )
- self._make_sub_btn(
- 'DM', lambda: self._navigate_panel('archive', channel='DM')
- )
- # Dynamic channel items populated by _update_submenus
-
- ui.separator().classes('my-1')
-
- # ββ Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG)
- for icon, label, panel_id in _STANDALONE_ITEMS:
- btn = ui.button(
- f'{icon} {label}',
- on_click=lambda pid=panel_id: self._navigate_panel(pid),
- ).props('flat no-caps align=left').classes(
- 'w-full justify-start domca-menu-btn'
- ).style(_MENU_BTN_STYLE)
- self._menu_buttons[panel_id] = btn
-
- ui.separator().classes('my-2')
-
- # External links (same as domca.nl navigation)
- with ui.column().style('padding: 0 1.2rem'):
- for label, url in _EXT_LINKS:
- ui.link(label, url, new_tab=True).classes(
- 'domca-ext-link'
- ).style(
- "font-family: 'JetBrains Mono', monospace; "
- "letter-spacing: 2px; font-size: 0.72rem; "
- "text-decoration: none; opacity: 0.6; "
- "display: block; padding: 0.35rem 0"
- )
-
- # Footer in drawer
- ui.space()
- ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem')
-
- # ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββ
- with ui.header().classes('items-center px-4 py-2 shadow-md'):
- menu_btn = ui.button(
- icon='menu',
- on_click=lambda: self._drawer.toggle(),
- ).props('flat round dense color=white')
-
- # Swap icon: menu β close
- self._drawer.on_value_change(
- lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}')
- )
-
- ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes(
- 'text-lg font-bold ml-2 domca-header-text'
- ).style("font-family: 'JetBrains Mono', monospace")
-
- # Transport mode badge
- _is_ble = config.TRANSPORT == "ble"
- _badge_icon = 'π΅' if _is_ble else 'π’'
- _badge_label = 'BLE' if _is_ble else 'Serial'
- ui.label(f'{_badge_icon} {_badge_label}').classes(
- 'text-xs ml-2 domca-header-text'
- ).style(
- "font-family: 'JetBrains Mono', monospace; "
- "opacity: 0.65; letter-spacing: 1px"
- )
-
- ui.space()
-
- _initial_status = self._shared.get_snapshot().get('status', 'Starting...')
- self._status_label = ui.label(_initial_status).classes(
- 'text-sm opacity-70 domca-header-text'
- )
-
- ui.button(
- icon='brightness_6',
- on_click=lambda: dark.toggle(),
- ).props('flat round dense color=white').tooltip('Toggle dark / light')
-
- # ββ Main Content Area βββββββββββββββββββββββββββββββββββββ
- self._panel_containers = {}
-
- # Landing page (SVG splash from file β visible by default)
- landing = ui.column().classes('domca-landing w-full')
- with landing:
- ui.html(_load_landing_svg())
- self._panel_containers['landing'] = landing
-
- # Panel containers (hidden by default, shown on menu click)
- panel_defs = [
- ('messages', self._messages),
- ('contacts', self._contacts),
- ('map', self._map),
- ('device', self._device),
- ('actions', self._actions),
- ('rxlog', self._rxlog),
- ('rooms', self._room_server),
- ('bbs', self._bbs),
- ]
-
- for panel_id, panel_obj in panel_defs:
- container = ui.column().classes('domca-panel')
- container.set_visibility(False)
- with container:
- panel_obj.render()
- self._panel_containers[panel_id] = container
-
- # Archive panel (inline β replaces separate /archive page)
- archive_container = ui.column().classes('domca-panel')
- archive_container.set_visibility(False)
- with archive_container:
- self._archive_page = ArchivePage(self._shared)
- self._archive_page.render()
- self._panel_containers['archive'] = archive_container
-
- self._active_panel = 'landing'
-
- # Start update timer
- self._apply_url_state()
- ui.timer(0.5, self._update_ui)
-
- # ------------------------------------------------------------------
- # Submenu button helper (layout only)
- # ------------------------------------------------------------------
-
- @staticmethod
- def _make_sub_btn(label: str, on_click) -> ui.button:
- """Create a submenu button in the drawer."""
- return ui.button(
- label,
- on_click=on_click,
- ).props('flat no-caps align=left').classes(
- 'w-full justify-start domca-sub-btn'
- ).style(_SUB_BTN_STYLE)
-
- # ------------------------------------------------------------------
- # Dynamic submenu updates (layout β called from _update_ui)
- # ------------------------------------------------------------------
-
- def _update_submenus(self, data: dict) -> None:
- """Rebuild channel/room submenu items when data changes.
-
- Only the dynamic items are rebuilt; the container is cleared and
- ALL items (static + dynamic) are re-rendered.
- """
- # ββ Channel submenus (Messages + Archive) ββ
- channels = data.get('channels', [])
- ch_fingerprint = tuple((ch['idx'], ch['name']) for ch in channels)
-
- if ch_fingerprint != self._last_channel_fingerprint and channels:
- self._last_channel_fingerprint = ch_fingerprint
-
- # Rebuild Messages submenu
- if self._msg_sub_container:
- self._msg_sub_container.clear()
- with self._msg_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('messages', channel=None)
- )
- self._make_sub_btn(
- 'DM', lambda: self._navigate_panel('messages', channel='DM')
- )
- for ch in channels:
- idx = ch['idx']
- name = ch['name']
- self._make_sub_btn(
- f"[{idx}] {name}",
- lambda i=idx: self._navigate_panel('messages', channel=i),
- )
-
- # Rebuild Archive submenu
- if self._archive_sub_container:
- self._archive_sub_container.clear()
- with self._archive_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('archive', channel=None)
- )
- self._make_sub_btn(
- 'DM', lambda: self._navigate_panel('archive', channel='DM')
- )
- for ch in channels:
- idx = ch['idx']
- name = ch['name']
- self._make_sub_btn(
- f"[{idx}] {name}",
- lambda n=name: self._navigate_panel('archive', channel=n),
- )
-
- # ββ Room submenus ββ
- rooms = self._room_password_store.get_rooms()
- rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms)
-
- if rooms_fingerprint != self._last_rooms_fingerprint:
- self._last_rooms_fingerprint = rooms_fingerprint
-
- if self._rooms_sub_container:
- self._rooms_sub_container.clear()
- with self._rooms_sub_container:
- self._make_sub_btn(
- 'ALL', lambda: self._navigate_panel('rooms')
- )
- for entry in rooms:
- short = entry.name or entry.pubkey[:12]
- self._make_sub_btn(
- f'\U0001f3e0 {short}',
- lambda: self._navigate_panel('rooms'),
- )
-
- # ------------------------------------------------------------------
- # Panel switching (layout helper β no functional logic)
- # ------------------------------------------------------------------
-
- def _apply_url_state(self) -> None:
- """Apply panel selection from URL query params on first render."""
- try:
- params = ui.context.client.request.query_params
- except Exception:
- return
-
- panel = params.get('panel') or 'landing'
- channel = params.get('channel')
-
- if panel not in self._panel_containers:
- panel = 'landing'
- channel = None
-
- if panel == 'messages':
- if channel is None or channel.lower() == 'all':
- channel = None
- elif channel.upper() == 'DM':
- channel = 'DM'
- else:
- channel = int(channel) if channel.isdigit() else None
- elif panel == 'archive':
- if channel is None or channel.lower() == 'all':
- channel = None
- elif channel.upper() == 'DM':
- channel = 'DM'
- else:
- channel = None
-
- self._show_panel(panel, channel)
-
- def _build_panel_url(self, panel_id: str, channel=None) -> str:
- params = {'panel': panel_id}
- if channel is not None:
- params['channel'] = str(channel)
- return '/?' + urlencode(params)
-
- def _navigate_panel(self, panel_id: str, channel=None) -> None:
- """Navigate with panel id in the URL so browser back restores state."""
- ui.navigate.to(self._build_panel_url(panel_id, channel))
-
- def _show_panel(self, panel_id: str, channel=None) -> None:
- """Show the selected panel, hide all others, close the drawer.
-
- Args:
- panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms').
- channel: Optional channel filter.
- For messages: None=all, 'DM'=DM only, int=channel idx.
- For archive: None=all, 'DM'=DM only, str=channel name.
- """
- for pid, container in self._panel_containers.items():
- container.set_visibility(pid == panel_id)
- self._active_panel = panel_id
-
- # Apply channel filter to messages panel
- if panel_id == 'messages' and self._messages:
- self._messages.set_active_channel(channel)
-
- # Apply channel filter to archive panel
- if panel_id == 'archive' and self._archive_page:
- self._archive_page.set_channel_filter(channel)
-
- self._refresh_active_panel_now(force_map_center=(panel_id == 'map'))
-
- # Update active menu highlight (standalone buttons only)
- for pid, btn in self._menu_buttons.items():
- if pid == panel_id:
- btn.classes('domca-menu-active', remove='')
- else:
- btn.classes(remove='domca-menu-active')
-
- # Close drawer after selection
- if self._drawer:
- self._drawer.hide()
-
- def _refresh_active_panel_now(self, force_map_center: bool = False) -> None:
- """Refresh only the currently visible panel.
-
- This is used directly after a panel switch so the user does not
- need to wait for the next 500 ms dashboard tick.
- """
- data = self._shared.get_snapshot()
-
- if data.get('channels'):
- self._messages.update_filters(data)
- self._messages.update_channel_options(data['channels'])
- self._update_submenus(data)
-
- if self._active_panel == 'device':
- self._device.update(data)
- elif self._active_panel == 'map':
- if force_map_center:
- data['force_center'] = True
- self._map.update(data)
- elif self._active_panel == 'actions':
- self._actions.update(data)
- elif self._active_panel == 'contacts':
- self._contacts.update(data)
- elif self._active_panel == 'messages':
- self._messages.update(
- data,
- self._messages.channel_filters,
- self._messages.last_channels,
- room_pubkeys=(
- self._room_server.get_room_pubkeys()
- if self._room_server else None
- ),
- )
- elif self._active_panel == 'rooms':
- self._room_server.update(data)
- elif self._active_panel == 'rxlog':
- self._rxlog.update(data)
- elif self._active_panel == 'bbs':
- if self._bbs:
- self._bbs.update(data)
-
- # ------------------------------------------------------------------
- # Room Server callback (from ContactsPanel)
- # ------------------------------------------------------------------
-
- def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None:
- """Handle adding a Room Server from the contacts panel.
-
- Delegates to the RoomServerPanel which persists the entry,
- creates the UI card and sends the login command.
- """
- if self._room_server:
- self._room_server.add_room(pubkey, name, password)
-
- # ------------------------------------------------------------------
- # Timer-driven UI update
- # ------------------------------------------------------------------
-
- def _update_ui(self) -> None:
- try:
- if not self._status_label:
- return
-
- # Atomic snapshot + flag clear: eliminates race condition
- # where worker sets channels_updated between separate
- # get_snapshot() and clear_update_flags() calls.
- data = self._shared.get_snapshot_and_clear_flags()
- is_first = not self._initialized
-
- # Mark initialised immediately β even if a panel update
- # crashes below, we must NOT retry the full first-render
- # path every 500 ms (that causes the infinite rebuild).
- if is_first:
- self._initialized = True
-
- # Always update status
- self._status_label.text = data['status']
-
- # Channel-dependent drawer/submenu state may stay global.
- # The helpers below already contain equality checks, so this
- # remains cheap while keeping navigation consistent.
- if data['channels']:
- self._messages.update_filters(data)
- self._messages.update_channel_options(data['channels'])
- self._update_submenus(data)
-
- if self._active_panel == 'device':
- if data['device_updated'] or is_first:
- self._device.update(data)
-
- elif self._active_panel == 'map':
- # Keep sending snapshots while the map panel is active.
- # The browser runtime coalesces pending payloads, so only
- # the newest snapshot is applied.
- self._map.update(data)
-
- elif self._active_panel == 'actions':
- if data['channels_updated'] or is_first:
- self._actions.update(data)
-
- elif self._active_panel == 'contacts':
- if data['contacts_updated'] or is_first:
- self._contacts.update(data)
-
- elif self._active_panel == 'messages':
- self._messages.update(
- data,
- self._messages.channel_filters,
- self._messages.last_channels,
- room_pubkeys=(
- self._room_server.get_room_pubkeys()
- if self._room_server else None
- ),
- )
-
- elif self._active_panel == 'rooms':
- self._room_server.update(data)
-
- elif self._active_panel == 'rxlog':
- if data['rxlog_updated'] or is_first:
- self._rxlog.update(data)
-
- elif self._active_panel == 'bbs':
- if self._bbs:
- self._bbs.update(data)
-
- # Signal worker that GUI is ready for data
- if is_first and data['channels'] and data['contacts']:
- self._shared.mark_gui_initialized()
-
- except Exception as e:
- err = str(e).lower()
- if "deleted" not in err and "client" not in err:
- import traceback
- print(f"GUI update error: {e}")
- traceback.print_exc()
diff --git a/meshcore_gui/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/meshcore_gui/gui/panels/__init__.py
deleted file mode 100644
index f9245f4..0000000
--- a/meshcore_gui/meshcore_gui/gui/panels/__init__.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""
-Individual dashboard panels β each panel is a single-responsibility class.
-
-Re-exports all panels for convenient importing::
-
- from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ...
-"""
-
-from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401
-from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401
-from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401
-from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401
-from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401
-from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401
-from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401
-from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401
-from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401
-from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401
diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
deleted file mode 100644
index 692acf1..0000000
--- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py
+++ /dev/null
@@ -1,614 +0,0 @@
-"""BBS panel -- board-based Bulletin Board System viewer and configuration."""
-
-import re
-from typing import Callable, Dict, List, Optional
-
-from nicegui import ui
-
-from meshcore_gui.config import debug_print
-from meshcore_gui.services.bbs_config_store import (
- BbsBoard,
- BbsConfigStore,
- DEFAULT_CATEGORIES,
- DEFAULT_RETENTION_HOURS,
-)
-from meshcore_gui.services.bbs_service import BbsMessage, BbsService
-from meshcore_gui.core.protocols import SharedDataReadAndLookup
-
-
-def _slug(name: str) -> str:
- """Convert a board name to a safe id slug.
-
- Args:
- name: Human-readable board name.
-
- Returns:
- Lowercase alphanumeric + underscore string.
- """
- return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board"
-
-
-# ---------------------------------------------------------------------------
-# Main BBS panel (message view only β settings live on /bbs-settings)
-# ---------------------------------------------------------------------------
-
-class BbsPanel:
- """BBS panel: board selector, category buttons, message list and post form.
-
- Settings are on a separate page (/bbs-settings), reachable via the
- gear icon in the panel header.
-
- Args:
- put_command: Callable to enqueue a command dict for the worker.
- bbs_service: Shared BbsService instance.
- config_store: Shared BbsConfigStore instance.
- """
-
- def __init__(
- self,
- put_command: Callable[[Dict], None],
- bbs_service: BbsService,
- config_store: BbsConfigStore,
- ) -> None:
- self._put_command = put_command
- self._service = bbs_service
- self._config_store = config_store
-
- # Active view state
- self._active_board: Optional[BbsBoard] = None
- self._active_category: Optional[str] = None
-
- # UI refs
- self._board_btn_row = None
- self._category_btn_row = None
- self._msg_list_container = None
- self._post_region_row = None
- self._post_region_select = None
- self._post_category_select = None
- self._text_input = None
-
- # Button refs for active highlight
- self._board_buttons: Dict[str, object] = {}
- self._category_buttons: Dict[str, object] = {}
-
- # Cached device channels (updated by update())
- self._device_channels: List[Dict] = []
- self._last_ch_fingerprint: tuple = ()
-
- # ------------------------------------------------------------------
- # Render
- # ------------------------------------------------------------------
-
- def render(self) -> None:
- """Build the BBS message view panel layout."""
- with ui.card().classes('w-full'):
- # Header row with gear icon
- with ui.row().classes('w-full items-center justify-between'):
- ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600')
- ui.button(
- icon='settings',
- on_click=lambda: ui.navigate.to('/bbs-settings'),
- ).props('flat round dense').tooltip('BBS Settings')
-
- # Board selector row
- self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
- with self._board_btn_row:
- ui.label('No active boards β open Settings to enable a channel.').classes(
- 'text-xs text-gray-400 italic'
- )
-
- ui.separator()
-
- # Category filter row (clickable buttons, replaces dropdown)
- self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap')
- with self._category_btn_row:
- ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
-
- ui.separator()
-
- # Message list
- self._msg_list_container = ui.column().classes(
- 'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2'
- ).style('max-height: calc(100vh - 24rem); min-height: 8rem')
-
- ui.separator()
-
- # Post row β keep selects for sending
- with ui.row().classes('w-full items-center gap-2 flex-wrap'):
- ui.label('Post:').classes('text-sm text-gray-600')
-
- self._post_region_row = ui.row().classes('items-center gap-1')
- with self._post_region_row:
- self._post_region_select = ui.select(
- options=[], label='Region',
- ).classes('text-xs').style('min-width: 110px')
-
- self._post_category_select = ui.select(
- options=[], label='Category',
- ).classes('text-xs').style('min-width: 110px')
-
- self._text_input = ui.input(
- placeholder='Message text...',
- ).classes('flex-grow text-sm min-w-0')
-
- ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs')
-
- # Initial render
- self._rebuild_board_buttons()
-
- # ------------------------------------------------------------------
- # Board selector
- # ------------------------------------------------------------------
-
- def _rebuild_board_buttons(self) -> None:
- """Rebuild board selector buttons from current config."""
- if not self._board_btn_row:
- return
- self._board_btn_row.clear()
- self._board_buttons = {}
- boards = self._config_store.get_boards()
- with self._board_btn_row:
- if not boards:
- ui.label('No active boards β open Settings to enable a channel.').classes(
- 'text-xs text-gray-400 italic'
- )
- return
- for board in boards:
- btn = ui.button(
- board.name,
- on_click=lambda b=board: self._select_board(b),
- ).props('flat no-caps').classes('text-xs domca-menu-btn')
- self._board_buttons[board.id] = btn
-
- ids = [b.id for b in boards]
- if boards and (self._active_board is None or self._active_board.id not in ids):
- self._select_board(boards[0])
- elif self._active_board and self._active_board.id in self._board_buttons:
- self._board_buttons[self._active_board.id].classes('domca-menu-active')
-
- def _select_board(self, board: BbsBoard) -> None:
- """Activate a board and rebuild category buttons.
-
- Args:
- board: Board to activate.
- """
- self._active_board = board
- self._active_category = None
-
- # Update board button highlights
- for bid, btn in self._board_buttons.items():
- if bid == board.id:
- btn.classes('domca-menu-active', remove='')
- else:
- btn.classes(remove='domca-menu-active')
-
- # Update post selects
- if self._post_region_row:
- self._post_region_row.set_visibility(bool(board.regions))
- if self._post_region_select:
- self._post_region_select.options = board.regions
- self._post_region_select.value = board.regions[0] if board.regions else None
- if self._post_category_select:
- self._post_category_select.options = board.categories
- self._post_category_select.value = board.categories[0] if board.categories else None
-
- self._rebuild_category_buttons()
- self._refresh_messages()
-
- # ------------------------------------------------------------------
- # Category buttons
- # ------------------------------------------------------------------
-
- def _rebuild_category_buttons(self) -> None:
- """Rebuild clickable category filter buttons for the active board."""
- if not self._category_btn_row:
- return
- self._category_btn_row.clear()
- self._category_buttons = {}
- if self._active_board is None:
- with self._category_btn_row:
- ui.label('Select a board first.').classes('text-xs text-gray-400 italic')
- return
- with self._category_btn_row:
- # "All" button
- all_btn = ui.button(
- 'ALL',
- on_click=lambda: self._on_category_filter(None),
- ).props('flat no-caps').classes('text-xs domca-menu-btn')
- self._category_buttons['__all__'] = all_btn
-
- for cat in self._active_board.categories:
- btn = ui.button(
- cat,
- on_click=lambda c=cat: self._on_category_filter(c),
- ).props('flat no-caps').classes('text-xs domca-menu-btn')
- self._category_buttons[cat] = btn
-
- # Highlight the current active category
- self._update_category_highlight()
-
- def _on_category_filter(self, category: Optional[str]) -> None:
- """Handle category button click.
-
- Args:
- category: Category string, or None for all.
- """
- self._active_category = category
- self._update_category_highlight()
- self._refresh_messages()
-
- def _update_category_highlight(self) -> None:
- """Apply domca-menu-active to the currently selected category button."""
- active_key = self._active_category if self._active_category else '__all__'
- for key, btn in self._category_buttons.items():
- if key == active_key:
- btn.classes('domca-menu-active', remove='')
- else:
- btn.classes(remove='domca-menu-active')
-
- # ------------------------------------------------------------------
- # Message list
- # ------------------------------------------------------------------
-
- def _refresh_messages(self) -> None:
- if not self._msg_list_container:
- return
- self._msg_list_container.clear()
- with self._msg_list_container:
- if self._active_board is None:
- ui.label('Select a board above.').classes('text-xs text-gray-400 italic')
- return
- if not self._active_board.channels:
- ui.label('No channels assigned to this board.').classes(
- 'text-xs text-gray-400 italic'
- )
- return
- messages = self._service.get_all_messages(
- channels=self._active_board.channels,
- region=None,
- category=self._active_category,
- )
- if not messages:
- ui.label('No messages.').classes('text-xs text-gray-400 italic')
- return
- for msg in messages:
- self._render_message_row(msg)
-
- def _render_message_row(self, msg: BbsMessage) -> None:
- ts = msg.timestamp[:16].replace('T', ' ')
- region_label = f' [{msg.region}]' if msg.region else ''
- header = f'{ts} {msg.sender} [{msg.category}]{region_label}'
- with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'):
- ui.label(header).classes('text-xs text-gray-500').style(
- 'word-break: break-all; overflow-wrap: break-word'
- )
- ui.label(msg.text).classes('text-sm').style(
- 'word-break: break-word; overflow-wrap: break-word'
- )
-
- # ------------------------------------------------------------------
- # Post
- # ------------------------------------------------------------------
-
- def _on_post(self) -> None:
- if self._active_board is None:
- ui.notify('Select a board first.', type='warning')
- return
- if not self._active_board.channels:
- ui.notify('No channels assigned to this board.', type='warning')
- return
-
- text = (self._text_input.value or '').strip() if self._text_input else ''
- if not text:
- ui.notify('Message text cannot be empty.', type='warning')
- return
-
- category = (
- self._post_category_select.value if self._post_category_select
- else (self._active_board.categories[0] if self._active_board.categories else '')
- )
- if not category:
- ui.notify('Please select a category.', type='warning')
- return
-
- region = ''
- if self._active_board.regions and self._post_region_select:
- region = self._post_region_select.value or ''
-
- target_channel = self._active_board.channels[0]
-
- msg = BbsMessage(
- channel=target_channel,
- region=region, category=category,
- sender='Me', sender_key='', text=text,
- )
- self._service.post_message(msg)
-
- region_part = f'{region} ' if region else ''
- mesh_text = f'!bbs post {region_part}{category} {text}'
- self._put_command({
- 'action': 'send_message',
- 'channel': target_channel,
- 'text': mesh_text,
- })
- debug_print(
- f'BBS panel: posted to board={self._active_board.id} '
- f'ch={target_channel} {mesh_text[:60]}'
- )
-
- if self._text_input:
- self._text_input.value = ''
- self._refresh_messages()
- ui.notify('Message posted.', type='positive')
-
- # ------------------------------------------------------------------
- # External update hook
- # ------------------------------------------------------------------
-
- def update(self, data: Dict) -> None:
- """Called by the dashboard timer with the SharedData snapshot.
-
- Args:
- data: SharedData snapshot dict.
- """
- device_channels = data.get('channels', [])
- fingerprint = tuple(
- (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels
- )
- if fingerprint != self._last_ch_fingerprint:
- self._last_ch_fingerprint = fingerprint
- self._device_channels = device_channels
- self._rebuild_board_buttons()
-
-
-# ---------------------------------------------------------------------------
-# Separate settings page (/bbs-settings)
-# ---------------------------------------------------------------------------
-
-class BbsSettingsPage:
- """Standalone BBS settings page, registered at /bbs-settings.
-
- Follows the same pattern as RoutePage: one instance, render() called
- per page load.
-
- Args:
- shared: SharedData instance (for device channel list).
- config_store: BbsConfigStore instance.
- """
-
- def __init__(
- self,
- shared: SharedDataReadAndLookup,
- config_store: BbsConfigStore,
- ) -> None:
- self._shared = shared
- self._config_store = config_store
- self._device_channels: List[Dict] = []
- self._boards_settings_container = None
-
- def render(self) -> None:
- """Render the BBS settings page."""
- from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy β avoids circular import
- data = self._shared.get_snapshot()
- self._device_channels = data.get('channels', [])
-
- ui.page_title('BBS Settings')
- ui.add_head_html(_DOMCA_HEAD)
- ui.dark_mode(True)
-
- with ui.header().classes('items-center px-4 py-2 shadow-md'):
- ui.button(
- icon='arrow_back',
- on_click=lambda: ui.run_javascript('window.history.back()'),
- ).props('flat round dense color=white').tooltip('Back')
- ui.label('π BBS Settings').classes(
- 'text-lg font-bold domca-header-text'
- ).style("font-family: 'JetBrains Mono', monospace")
- ui.space()
-
- with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'):
- with ui.card().classes('w-full'):
- ui.label('BBS Settings').classes('font-bold text-gray-600')
- ui.separator()
-
- self._boards_settings_container = ui.column().classes('w-full gap-3')
- with self._boards_settings_container:
- if not self._device_channels:
- ui.label('Connect device to see channels.').classes(
- 'text-xs text-gray-400 italic'
- )
- else:
- self._render_all()
-
- # ------------------------------------------------------------------
- # Settings rendering
- # ------------------------------------------------------------------
-
- def _render_all(self) -> None:
- """Render all channel rows and the advanced section."""
- for ch in self._device_channels:
- self._render_channel_settings_row(ch)
-
- ui.separator()
-
- with ui.expansion('Advanced', value=False).classes('w-full').props('dense'):
- ui.label('Regions and key list per channel').classes(
- 'text-xs text-gray-500 pb-1'
- )
- advanced_any = False
- for ch in self._device_channels:
- idx = ch.get('idx', ch.get('index', 0))
- board = self._config_store.get_board(f'ch{idx}')
- if board is not None:
- self._render_channel_advanced_row(ch, board)
- advanced_any = True
- if not advanced_any:
- ui.label(
- 'Enable at least one channel to see advanced options.'
- ).classes('text-xs text-gray-400 italic')
-
- def _rebuild(self) -> None:
- """Clear and re-render the settings container in-place."""
- if not self._boards_settings_container:
- return
- self._boards_settings_container.clear()
- with self._boards_settings_container:
- if not self._device_channels:
- ui.label('Connect device to see channels.').classes(
- 'text-xs text-gray-400 italic'
- )
- else:
- self._render_all()
-
- def _render_channel_settings_row(self, ch: Dict) -> None:
- """Render the standard settings row for a single device channel.
-
- Args:
- ch: Device channel dict with 'idx'/'index' and 'name' keys.
- """
- idx = ch.get('idx', ch.get('index', 0))
- ch_name = ch.get('name', f'Ch {idx}')
- board_id = f'ch{idx}'
- board = self._config_store.get_board(board_id)
-
- is_active = board is not None
- cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES)
- retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS)
-
- with ui.card().classes('w-full p-2'):
- with ui.row().classes('w-full items-center justify-between'):
- ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
- active_toggle = ui.toggle(
- {True: 'β Active', False: 'β Off'},
- value=is_active,
- ).classes('text-xs')
-
- with ui.row().classes('w-full items-center gap-2 mt-1'):
- ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0')
- cats_input = ui.input(value=cats_value).classes('text-xs flex-grow')
-
- with ui.row().classes('w-full items-center gap-2 mt-1'):
- ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0')
- retention_input = ui.input(value=retention_value).classes('text-xs').style(
- 'max-width: 80px'
- )
- ui.label('hrs').classes('text-xs text-gray-600')
-
- def _save(
- bid=board_id,
- bname=ch_name,
- bidx=idx,
- tog=active_toggle,
- ci=cats_input,
- ri=retention_input,
- ) -> None:
- if tog.value:
- existing = self._config_store.get_board(bid)
- categories = [
- c.strip().upper()
- for c in (ci.value or '').split(',') if c.strip()
- ] or list(DEFAULT_CATEGORIES)
- try:
- ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS)
- except ValueError:
- ret_hours = DEFAULT_RETENTION_HOURS
- extra_channels = (
- [c for c in existing.channels if c != bidx]
- if existing else []
- )
- updated = BbsBoard(
- id=bid,
- name=bname,
- channels=[bidx] + extra_channels,
- categories=categories,
- regions=existing.regions if existing else [],
- retention_hours=ret_hours,
- allowed_keys=existing.allowed_keys if existing else [],
- )
- self._config_store.set_board(updated)
- debug_print(f'BBS settings: channel {bid} saved')
- ui.notify(f'{bname} saved.', type='positive')
- else:
- self._config_store.delete_board(bid)
- debug_print(f'BBS settings: channel {bid} disabled')
- ui.notify(f'{bname} disabled.', type='warning')
- self._rebuild()
-
- ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1')
-
- def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None:
- """Render the advanced settings block for a single active channel.
-
- Args:
- ch: Device channel dict.
- board: Existing BbsBoard for this channel.
- """
- idx = ch.get('idx', ch.get('index', 0))
- ch_name = ch.get('name', f'Ch {idx}')
- board_id = f'ch{idx}'
-
- with ui.column().classes('w-full gap-1 py-2'):
- ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium')
-
- regions_input = ui.input(
- label='Regions (comma-separated)',
- value=', '.join(board.regions),
- ).classes('w-full text-xs')
-
- wl_input = ui.input(
- label='Allowed keys (empty = everyone on the channel)',
- value=', '.join(board.allowed_keys),
- ).classes('w-full text-xs')
-
- other_channels = [
- c for c in self._device_channels
- if c.get('idx', c.get('index', 0)) != idx
- ]
- ch_checks: Dict[int, object] = {}
- if other_channels:
- ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1')
- with ui.row().classes('flex-wrap gap-2'):
- for other_ch in other_channels:
- other_idx = other_ch.get('idx', other_ch.get('index', 0))
- other_name = other_ch.get('name', f'Ch {other_idx}')
- cb = ui.checkbox(
- f'[{other_idx}] {other_name}',
- value=other_idx in board.channels,
- ).classes('text-xs')
- ch_checks[other_idx] = cb
-
- def _save_adv(
- bid=board_id,
- bidx=idx,
- bname=ch_name,
- ri=regions_input,
- wli=wl_input,
- cc=ch_checks,
- ) -> None:
- existing = self._config_store.get_board(bid)
- if existing is None:
- ui.notify('Enable this channel first.', type='warning')
- return
- regions = [
- r.strip() for r in (ri.value or '').split(',') if r.strip()
- ]
- allowed_keys = [
- k.strip() for k in (wli.value or '').split(',') if k.strip()
- ]
- combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value]
- updated = BbsBoard(
- id=bid,
- name=bname,
- channels=combined,
- categories=existing.categories,
- regions=regions,
- retention_hours=existing.retention_hours,
- allowed_keys=allowed_keys,
- )
- self._config_store.set_board(updated)
- debug_print(f'BBS settings (advanced): {bid} saved')
- ui.notify(f'{bname} saved.', type='positive')
- self._rebuild()
-
- ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1')
- ui.separator()
diff --git a/meshcore_gui/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/meshcore_gui/services/bbs_config_store.py
deleted file mode 100644
index c05727b..0000000
--- a/meshcore_gui/meshcore_gui/services/bbs_config_store.py
+++ /dev/null
@@ -1,302 +0,0 @@
-"""
-BBS board configuration store for MeshCore GUI.
-
-Persists BBS board configuration to
-``~/.meshcore-gui/bbs/bbs_config.json``.
-
-A **board** groups one or more MeshCore channel indices into a single
-bulletin board. Messages posted on any of the board's channels are
-visible in the board view. This supports two usage patterns:
-
-- One board per channel (classic per-channel BBS)
-- One board spanning multiple channels (shared bulletin board)
-
-Config version history
-~~~~~~~~~~~~~~~~~~~~~~
-v1 β per-channel config (list of channels with enabled flag).
-v2 β board-based config (list of boards, each with a channels list).
- Automatic migration from v1 on first load.
-
-Thread safety
-~~~~~~~~~~~~~
-All public methods acquire an internal ``threading.Lock``.
-"""
-
-import json
-import threading
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Dict, List, Optional
-
-from meshcore_gui.config import debug_print
-
-# ---------------------------------------------------------------------------
-# Storage
-# ---------------------------------------------------------------------------
-
-BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs"
-BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json"
-
-CONFIG_VERSION: int = 2
-
-# ---------------------------------------------------------------------------
-# Defaults
-# ---------------------------------------------------------------------------
-
-DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"]
-DEFAULT_REGIONS: List[str] = []
-DEFAULT_RETENTION_HOURS: int = 48
-
-
-# ---------------------------------------------------------------------------
-# Data model
-# ---------------------------------------------------------------------------
-
-@dataclass
-class BbsBoard:
- """A BBS board grouping one or more MeshCore channels.
-
- Attributes:
- id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``).
- name: Human-readable board name.
- channels: List of MeshCore channel indices assigned to this board.
- categories: Valid category tags for this board.
- regions: Optional region tags; empty = no region filtering.
- retention_hours: Message retention period in hours.
- allowed_keys: Sender public key whitelist (empty = all allowed).
- """
-
- id: str
- name: str
- channels: List[int] = field(default_factory=list)
- categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES))
- regions: List[str] = field(default_factory=list)
- retention_hours: int = DEFAULT_RETENTION_HOURS
- allowed_keys: List[str] = field(default_factory=list)
-
- def to_dict(self) -> Dict:
- """Serialise to a JSON-compatible dict."""
- return {
- "id": self.id,
- "name": self.name,
- "channels": list(self.channels),
- "categories": list(self.categories),
- "regions": list(self.regions),
- "retention_hours": self.retention_hours,
- "allowed_keys": list(self.allowed_keys),
- }
-
- @staticmethod
- def from_dict(d: Dict) -> "BbsBoard":
- """Deserialise from a config dict."""
- return BbsBoard(
- id=d.get("id", ""),
- name=d.get("name", ""),
- channels=list(d.get("channels", [])),
- categories=list(d.get("categories", DEFAULT_CATEGORIES)),
- regions=list(d.get("regions", [])),
- retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)),
- allowed_keys=list(d.get("allowed_keys", [])),
- )
-
-
-# ---------------------------------------------------------------------------
-# Store
-# ---------------------------------------------------------------------------
-
-class BbsConfigStore:
- """Persistent store for BBS board configuration.
-
- Args:
- config_path: Path to the JSON config file.
- Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``.
- """
-
- def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None:
- self._path = config_path
- self._lock = threading.Lock()
- self._boards: List[BbsBoard] = []
- self._load()
-
- # ------------------------------------------------------------------
- # Load / save
- # ------------------------------------------------------------------
-
- def _load(self) -> None:
- """Load config from disk; migrate v1 β v2 if needed."""
- BBS_DIR.mkdir(parents=True, exist_ok=True)
-
- if not self._path.exists():
- self._save_unlocked()
- debug_print("BBS config: created new config file (v2)")
- return
-
- try:
- raw = self._path.read_text(encoding="utf-8")
- data = json.loads(raw)
- version = data.get("version", 1)
-
- if version == CONFIG_VERSION:
- self._boards = [
- BbsBoard.from_dict(b) for b in data.get("boards", [])
- ]
- debug_print(f"BBS config: loaded {len(self._boards)} boards")
-
- elif version == 1:
- # Migrate: each v1 channel β one board
- self._boards = self._migrate_v1(data.get("channels", []))
- self._save_unlocked()
- debug_print(
- f"BBS config: migrated v1 β v2 ({len(self._boards)} boards)"
- )
- else:
- debug_print(
- f"BBS config: unknown version {version}, using empty config"
- )
-
- except (json.JSONDecodeError, OSError) as exc:
- debug_print(f"BBS config: load error ({exc}), using empty config")
-
- @staticmethod
- def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]:
- """Convert v1 per-channel entries to v2 boards.
-
- Only enabled channels are migrated.
-
- Args:
- v1_channels: List of v1 channel config dicts.
-
- Returns:
- List of ``BbsBoard`` instances.
- """
- boards = []
- for ch in v1_channels:
- if not ch.get("enabled", False):
- continue
- idx = ch.get("channel", 0)
- board_id = f"ch{idx}"
- boards.append(BbsBoard(
- id=board_id,
- name=ch.get("name", f"Channel {idx}"),
- channels=[idx],
- categories=list(ch.get("categories", DEFAULT_CATEGORIES)),
- regions=list(ch.get("regions", [])),
- retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)),
- allowed_keys=list(ch.get("allowed_keys", [])),
- ))
- return boards
-
- def _save_unlocked(self) -> None:
- """Write config to disk. MUST be called with self._lock held."""
- BBS_DIR.mkdir(parents=True, exist_ok=True)
- data = {
- "version": CONFIG_VERSION,
- "boards": [b.to_dict() for b in self._boards],
- }
- tmp = self._path.with_suffix(".tmp")
- tmp.write_text(
- json.dumps(data, indent=2, ensure_ascii=False),
- encoding="utf-8",
- )
- tmp.replace(self._path)
-
- def save(self) -> None:
- """Flush current configuration to disk."""
- with self._lock:
- self._save_unlocked()
-
- # ------------------------------------------------------------------
- # Board queries
- # ------------------------------------------------------------------
-
- def get_boards(self) -> List[BbsBoard]:
- """Return a copy of all configured boards.
-
- Returns:
- List of ``BbsBoard`` instances.
- """
- with self._lock:
- return list(self._boards)
-
- def get_board(self, board_id: str) -> Optional[BbsBoard]:
- """Return a board by its id, or ``None``.
-
- Args:
- board_id: Board identifier string.
-
- Returns:
- ``BbsBoard`` instance or ``None``.
- """
- with self._lock:
- for b in self._boards:
- if b.id == board_id:
- return BbsBoard.from_dict(b.to_dict())
- return None
-
- def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]:
- """Return the first board that includes *channel_idx*, or ``None``.
-
- Used by ``BbsCommandHandler`` to route incoming mesh commands.
-
- Args:
- channel_idx: MeshCore channel index.
-
- Returns:
- ``BbsBoard`` instance or ``None``.
- """
- with self._lock:
- for b in self._boards:
- if channel_idx in b.channels:
- return BbsBoard.from_dict(b.to_dict())
- return None
-
- # ------------------------------------------------------------------
- # Board management
- # ------------------------------------------------------------------
-
- def set_board(self, board: BbsBoard) -> None:
- """Insert or replace a board (matched by ``board.id``).
-
- Args:
- board: ``BbsBoard`` to persist.
- """
- with self._lock:
- for i, b in enumerate(self._boards):
- if b.id == board.id:
- self._boards[i] = BbsBoard.from_dict(board.to_dict())
- self._save_unlocked()
- debug_print(f"BBS config: updated board '{board.id}'")
- return
- self._boards.append(BbsBoard.from_dict(board.to_dict()))
- self._save_unlocked()
- debug_print(f"BBS config: added board '{board.id}'")
-
- def delete_board(self, board_id: str) -> bool:
- """Remove a board by id.
-
- Args:
- board_id: Board identifier to remove.
-
- Returns:
- ``True`` if removed, ``False`` if not found.
- """
- with self._lock:
- before = len(self._boards)
- self._boards = [b for b in self._boards if b.id != board_id]
- if len(self._boards) < before:
- self._save_unlocked()
- debug_print(f"BBS config: deleted board '{board_id}'")
- return True
- return False
-
- def board_id_exists(self, board_id: str) -> bool:
- """Check whether a board id is already in use.
-
- Args:
- board_id: Board identifier to check.
-
- Returns:
- ``True`` if a board with this id exists.
- """
- with self._lock:
- return any(b.id == board_id for b in self._boards)
diff --git a/meshcore_gui/meshcore_gui/services/bbs_service.py b/meshcore_gui/meshcore_gui/services/bbs_service.py
deleted file mode 100644
index 52b7968..0000000
--- a/meshcore_gui/meshcore_gui/services/bbs_service.py
+++ /dev/null
@@ -1,468 +0,0 @@
-"""
-Offline Bulletin Board System (BBS) service for MeshCore GUI.
-
-Stores BBS messages in a local SQLite database. Messages are keyed by
-their originating MeshCore channel index. A **board** (see
-:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or
-more channel indices to a single bulletin board, so queries are always
-issued as ``WHERE channel IN (...)``.
-
-Architecture
-~~~~~~~~~~~~
-- ``BbsService`` -- persistence layer (SQLite, retention, queries).
-- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and
- delegates to ``BbsService``. Returns reply text.
-
-Thread safety
-~~~~~~~~~~~~~
-SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by
-multiple application instances (e.g. 800 MHz + 433 MHz on one Pi).
-
-Storage
-~~~~~~~
-``~/.meshcore-gui/bbs/bbs_messages.db``
-``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore)
-"""
-
-import sqlite3
-import threading
-from dataclasses import dataclass, field
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from typing import Dict, List, Optional
-
-from meshcore_gui.config import debug_print
-
-BBS_DIR = Path.home() / ".meshcore-gui" / "bbs"
-BBS_DB_PATH = BBS_DIR / "bbs_messages.db"
-
-
-# ---------------------------------------------------------------------------
-# Data model
-# ---------------------------------------------------------------------------
-
-@dataclass
-class BbsMessage:
- """A single BBS message.
-
- Attributes:
- id: Database row id (``None`` before insert).
- channel: MeshCore channel index the message arrived on.
- region: Region tag (empty string when board has no regions).
- category: Category tag.
- sender: Display name of the sender.
- sender_key: Public key of the sender (hex string).
- text: Message body.
- timestamp: UTC ISO-8601 timestamp string.
- """
-
- channel: int
- region: str
- category: str
- sender: str
- sender_key: str
- text: str
- timestamp: str = field(
- default_factory=lambda: datetime.now(timezone.utc).isoformat()
- )
- id: Optional[int] = None
-
-
-# ---------------------------------------------------------------------------
-# Service
-# ---------------------------------------------------------------------------
-
-class BbsService:
- """SQLite-backed BBS storage service.
-
- Args:
- db_path: Path to the SQLite database file.
- """
-
- def __init__(self, db_path: Path = BBS_DB_PATH) -> None:
- self._db_path = db_path
- self._lock = threading.Lock()
- self._init_db()
-
- def _init_db(self) -> None:
- """Create the database directory and schema if not present."""
- BBS_DIR.mkdir(parents=True, exist_ok=True)
- with self._connect() as conn:
- conn.execute("PRAGMA journal_mode=WAL")
- conn.execute("PRAGMA busy_timeout=3000")
- conn.execute("""
- CREATE TABLE IF NOT EXISTS bbs_messages (
- id INTEGER PRIMARY KEY AUTOINCREMENT,
- channel INTEGER NOT NULL,
- region TEXT NOT NULL DEFAULT '',
- category TEXT NOT NULL,
- sender TEXT NOT NULL,
- sender_key TEXT NOT NULL DEFAULT '',
- text TEXT NOT NULL,
- timestamp TEXT NOT NULL
- )
- """)
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)"
- )
- conn.execute(
- "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)"
- )
- conn.commit()
- debug_print(f"BBS: database ready at {self._db_path}")
-
- def _connect(self) -> sqlite3.Connection:
- return sqlite3.connect(str(self._db_path), check_same_thread=False)
-
- # ------------------------------------------------------------------
- # Write
- # ------------------------------------------------------------------
-
- def post_message(self, msg: BbsMessage) -> int:
- """Insert a BBS message and return its row id.
-
- Args:
- msg: ``BbsMessage`` dataclass to persist.
-
- Returns:
- Assigned ``rowid`` (also set on ``msg.id``).
- """
- with self._lock:
- with self._connect() as conn:
- cur = conn.execute(
- """INSERT INTO bbs_messages
- (channel, region, category, sender, sender_key, text, timestamp)
- VALUES (?, ?, ?, ?, ?, ?, ?)""",
- (msg.channel, msg.region, msg.category,
- msg.sender, msg.sender_key, msg.text, msg.timestamp),
- )
- conn.commit()
- msg.id = cur.lastrowid
- debug_print(
- f"BBS: posted id={msg.id} ch={msg.channel} "
- f"cat={msg.category} sender={msg.sender}"
- )
- return msg.id
-
- # ------------------------------------------------------------------
- # Read (channels is a list to support multi-channel boards)
- # ------------------------------------------------------------------
-
- def get_messages(
- self,
- channels: List[int],
- region: Optional[str] = None,
- category: Optional[str] = None,
- limit: int = 5,
- ) -> List[BbsMessage]:
- """Return the *limit* most recent messages for a set of channels.
-
- Args:
- channels: MeshCore channel indices to query (board's channel list).
- region: Optional region filter.
- category: Optional category filter.
- limit: Maximum number of messages to return.
-
- Returns:
- List of ``BbsMessage`` objects, newest first.
- """
- if not channels:
- return []
- placeholders = ",".join("?" * len(channels))
- query = (
- f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
- f"FROM bbs_messages WHERE channel IN ({placeholders})"
- )
- params: list = list(channels)
- if region:
- query += " AND region = ?"
- params.append(region)
- if category:
- query += " AND category = ?"
- params.append(category)
- query += " ORDER BY timestamp DESC LIMIT ?"
- params.append(limit)
-
- with self._lock:
- with self._connect() as conn:
- rows = conn.execute(query, params).fetchall()
- return [self._row_to_msg(r) for r in rows]
-
- def get_all_messages(
- self,
- channels: List[int],
- region: Optional[str] = None,
- category: Optional[str] = None,
- ) -> List[BbsMessage]:
- """Return all messages for a set of channels (oldest first).
-
- Args:
- channels: MeshCore channel indices to query.
- region: Optional region filter.
- category: Optional category filter.
-
- Returns:
- List of ``BbsMessage`` objects, oldest first.
- """
- if not channels:
- return []
- placeholders = ",".join("?" * len(channels))
- query = (
- f"SELECT id, channel, region, category, sender, sender_key, text, timestamp "
- f"FROM bbs_messages WHERE channel IN ({placeholders})"
- )
- params: list = list(channels)
- if region:
- query += " AND region = ?"
- params.append(region)
- if category:
- query += " AND category = ?"
- params.append(category)
- query += " ORDER BY timestamp ASC"
-
- with self._lock:
- with self._connect() as conn:
- rows = conn.execute(query, params).fetchall()
- return [self._row_to_msg(r) for r in rows]
-
- @staticmethod
- def _row_to_msg(row: tuple) -> BbsMessage:
- return BbsMessage(
- id=row[0], channel=row[1], region=row[2], category=row[3],
- sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7],
- )
-
- # ------------------------------------------------------------------
- # Retention
- # ------------------------------------------------------------------
-
- def purge_expired(self, channels: List[int], retention_hours: int) -> int:
- """Delete messages older than *retention_hours* for a set of channels.
-
- Args:
- channels: MeshCore channel indices to purge.
- retention_hours: Messages older than this are deleted.
-
- Returns:
- Number of rows deleted.
- """
- if not channels:
- return 0
- cutoff = (
- datetime.now(timezone.utc) - timedelta(hours=retention_hours)
- ).isoformat()
- placeholders = ",".join("?" * len(channels))
- with self._lock:
- with self._connect() as conn:
- cur = conn.execute(
- f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?",
- list(channels) + [cutoff],
- )
- conn.commit()
- deleted = cur.rowcount
- if deleted:
- debug_print(
- f"BBS: purged {deleted} expired messages from ch={channels}"
- )
- return deleted
-
- def purge_all_expired(self, boards) -> None:
- """Run retention cleanup for all boards.
-
- Args:
- boards: Iterable of ``BbsBoard`` instances.
- """
- for board in boards:
- self.purge_expired(board.channels, board.retention_hours)
-
-
-# ---------------------------------------------------------------------------
-# Command handler
-# ---------------------------------------------------------------------------
-
-class BbsCommandHandler:
- """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`.
-
- Looks up the board for the incoming channel via ``BbsConfigStore``
- so that a single board spanning multiple channels handles commands
- from all of them.
-
- Args:
- service: Shared ``BbsService`` instance.
- config_store: ``BbsConfigStore`` instance for live board config.
- """
-
- READ_LIMIT: int = 5
-
- def __init__(self, service: BbsService, config_store) -> None:
- self._service = service
- self._config_store = config_store
-
- # ------------------------------------------------------------------
- # Public entry point
- # ------------------------------------------------------------------
-
- def handle(
- self,
- channel_idx: int,
- sender: str,
- sender_key: str,
- text: str,
- ) -> Optional[str]:
- """Parse an incoming message and return a reply string (or ``None``).
-
- Args:
- channel_idx: MeshCore channel index the message arrived on.
- sender: Display name of the sender.
- sender_key: Public key of the sender (hex string).
- text: Raw message text.
-
- Returns:
- Reply string, or ``None`` if no reply should be sent.
- """
- text = (text or "").strip()
- if not text.lower().startswith("!bbs"):
- return None
-
- board = self._config_store.get_board_for_channel(channel_idx)
- if board is None:
- return None
-
- # Whitelist check
- if board.allowed_keys and sender_key not in board.allowed_keys:
- debug_print(
- f"BBS: silently dropping msg from {sender} "
- f"(key not in whitelist for board '{board.id}')"
- )
- return None
-
- parts = text.split(None, 1)
- args = parts[1].strip() if len(parts) > 1 else ""
- return self._dispatch(board, channel_idx, sender, sender_key, args)
-
- # ------------------------------------------------------------------
- # Dispatch
- # ------------------------------------------------------------------
-
- def _dispatch(self, board, channel_idx, sender, sender_key, args):
- sub = args.split(None, 1)[0].lower() if args else ""
- rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else ""
- if sub == "post":
- return self._handle_post(board, channel_idx, sender, sender_key, rest)
- if sub == "read":
- return self._handle_read(board, rest)
- if sub == "help" or not sub:
- return self._handle_help(board)
- return f"Unknown command '{sub}'. {self._handle_help(board)}"
-
- # ------------------------------------------------------------------
- # post
- # ------------------------------------------------------------------
-
- def _handle_post(self, board, channel_idx, sender, sender_key, args):
- regions = board.regions
- categories = board.categories
- tokens = args.split(None, 2) if args else []
-
- if regions:
- if len(tokens) < 3:
- return (
- f"Usage: !bbs post [region] [category] [text] | "
- f"Regions: {', '.join(regions)} | "
- f"Categories: {', '.join(categories)}"
- )
- region, category, text = tokens[0], tokens[1], tokens[2]
- valid_r = [r.upper() for r in regions]
- if region.upper() not in valid_r:
- return f"Invalid region '{region}'. Valid: {', '.join(regions)}"
- region = regions[valid_r.index(region.upper())]
- valid_c = [c.upper() for c in categories]
- if category.upper() not in valid_c:
- return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
- category = categories[valid_c.index(category.upper())]
- else:
- if len(tokens) < 2:
- return (
- f"Usage: !bbs post [category] [text] | "
- f"Categories: {', '.join(categories)}"
- )
- region = ""
- category, text = tokens[0], tokens[1]
- valid_c = [c.upper() for c in categories]
- if category.upper() not in valid_c:
- return f"Invalid category '{category}'. Valid: {', '.join(categories)}"
- category = categories[valid_c.index(category.upper())]
-
- msg = BbsMessage(
- channel=channel_idx,
- region=region, category=category,
- sender=sender, sender_key=sender_key, text=text,
- )
- self._service.post_message(msg)
- region_label = f" [{region}]" if region else ""
- return f"Posted [{category}]{region_label}: {text[:60]}"
-
- # ------------------------------------------------------------------
- # read
- # ------------------------------------------------------------------
-
- def _handle_read(self, board, args):
- regions = board.regions
- categories = board.categories
- tokens = args.split() if args else []
- region = None
- category = None
-
- if regions:
- valid_r = [r.upper() for r in regions]
- valid_c = [c.upper() for c in categories]
- if tokens:
- if tokens[0].upper() in valid_r:
- region = regions[valid_r.index(tokens[0].upper())]
- if len(tokens) >= 2:
- if tokens[1].upper() in valid_c:
- category = categories[valid_c.index(tokens[1].upper())]
- else:
- return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}"
- else:
- return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}"
- else:
- valid_c = [c.upper() for c in categories]
- if tokens:
- if tokens[0].upper() in valid_c:
- category = categories[valid_c.index(tokens[0].upper())]
- else:
- return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}"
-
- messages = self._service.get_messages(
- board.channels, region=region, category=category, limit=self.READ_LIMIT,
- )
- if not messages:
- return "BBS: no messages found."
- lines = []
- for m in messages:
- ts = m.timestamp[:16].replace("T", " ")
- region_label = f"[{m.region}] " if m.region else ""
- lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}")
- return "\n".join(lines)
-
- # ------------------------------------------------------------------
- # help
- # ------------------------------------------------------------------
-
- def _handle_help(self, board) -> str:
- cats = ", ".join(board.categories)
- if board.regions:
- regs = ", ".join(board.regions)
- return (
- f"BBS [{board.name}] | "
- f"!bbs post [region] [cat] [text] | "
- f"!bbs read [region] [cat] | "
- f"Regions: {regs} | Categories: {cats}"
- )
- return (
- f"BBS [{board.name}] | "
- f"!bbs post [cat] [text] | "
- f"!bbs read [cat] | "
- f"Categories: {cats}"
- )
diff --git a/meshcore_gui/meshcore_gui/services/bot.py b/meshcore_gui/meshcore_gui/services/bot.py
deleted file mode 100644
index 9279a72..0000000
--- a/meshcore_gui/meshcore_gui/services/bot.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-Keyword-triggered auto-reply bot for MeshCore GUI.
-
-Extracted from SerialWorker to satisfy the Single Responsibility Principle.
-The bot listens on a configured channel and replies to messages that
-contain recognised keywords.
-
-Open/Closed
-~~~~~~~~~~~
-New keywords are added via ``BotConfig.keywords`` (data) without
-modifying the ``MeshBot`` class (code). Custom matching strategies
-can be implemented by subclassing and overriding ``_match_keyword``.
-
-BBS integration
-~~~~~~~~~~~~~~~
-``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a
-:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one
-is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is
-``None`` (default), BBS routing is simply skipped.
-"""
-
-import time
-from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Callable, Dict, List, Optional
-
-if TYPE_CHECKING:
- from meshcore_gui.services.bbs_service import BbsCommandHandler
-
-from meshcore_gui.config import debug_print
-
-
-# ==============================================================================
-# Bot defaults (previously in config.py)
-# ==============================================================================
-
-# Channel indices the bot listens on (must match device channels).
-BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot
-
-# Display name prepended to every bot reply.
-BOT_NAME: str = "ZwolsBotje"
-
-# Minimum seconds between two bot replies (prevents reply-storms).
-BOT_COOLDOWN_SECONDS: float = 5.0
-
-# Keyword β reply template mapping.
-# Available variables: {bot}, {sender}, {snr}, {path}
-# The bot checks whether the incoming message text *contains* the keyword
-# (case-insensitive). First match wins.
-BOT_KEYWORDS: Dict[str, str] = {
- 'test': '@[{sender}], rcvd | SNR {snr} | {path}',
- 'ping': 'Pong!',
- 'help': 'test, ping, help',
-}
-
-
-@dataclass
-class BotConfig:
- """Configuration for :class:`MeshBot`.
-
- Attributes:
- channels: Channel indices to listen on.
- name: Display name prepended to replies.
- cooldown_seconds: Minimum seconds between replies.
- keywords: Keyword β reply template mapping.
- """
-
- channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS))
- name: str = BOT_NAME
- cooldown_seconds: float = BOT_COOLDOWN_SECONDS
- keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS))
-
-
-class MeshBot:
- """Keyword-triggered auto-reply bot.
-
- The bot checks incoming messages against a set of keyword β template
- pairs. When a keyword is found (case-insensitive substring match,
- first match wins), the template is expanded and queued as a channel
- message via *command_sink*.
-
- Args:
- config: Bot configuration.
- command_sink: Callable that enqueues a command dict for the
- worker (typically ``shared.put_command``).
- enabled_check: Callable that returns ``True`` when the bot is
- enabled (typically ``shared.is_bot_enabled``).
- """
-
- def __init__(
- self,
- config: BotConfig,
- command_sink: Callable[[Dict], None],
- enabled_check: Callable[[], bool],
- bbs_handler: Optional["BbsCommandHandler"] = None,
- ) -> None:
- self._config = config
- self._sink = command_sink
- self._enabled = enabled_check
- self._last_reply: float = 0.0
- self._bbs_handler = bbs_handler
-
- def check_and_reply(
- self,
- sender: str,
- text: str,
- channel_idx: Optional[int],
- snr: Optional[float],
- path_len: int,
- path_hashes: Optional[List[str]] = None,
- ) -> None:
- """Evaluate an incoming message and queue a reply if appropriate.
-
- Guards (in order):
- 1. Bot is enabled (checkbox in GUI).
- 2. Message is on the configured channel.
- 3. Sender is not the bot itself.
- 4. Sender name does not end with ``'Bot'`` (prevent loops).
- 5. Cooldown period has elapsed.
- 6. Message text contains a recognised keyword.
- """
- # Guard 1: enabled?
- if not self._enabled():
- return
-
- # Guard 2: correct channel?
- if channel_idx not in self._config.channels:
- return
-
- # Guard 3: own messages?
- if sender == "Me" or (text and text.startswith(self._config.name)):
- return
-
- # Guard 4: other bots?
- if sender and sender.rstrip().lower().endswith("bot"):
- debug_print(f"BOT: skipping message from other bot '{sender}'")
- return
-
- # Guard 5: cooldown?
- now = time.time()
- if now - self._last_reply < self._config.cooldown_seconds:
- debug_print("BOT: cooldown active, skipping")
- return
-
- # BBS routing: delegate !bbs commands to BbsCommandHandler
- if self._bbs_handler is not None:
- text_stripped = (text or "").strip()
- if text_stripped.lower().startswith("!bbs"):
- bbs_reply = self._bbs_handler.handle(
- channel_idx=channel_idx,
- sender=sender,
- sender_key="", # sender_key not available at this call-site
- text=text_stripped,
- )
- if bbs_reply is not None:
- self._last_reply = now
- self._sink({
- "action": "send_message",
- "channel": channel_idx,
- "text": bbs_reply,
- "_bot": True,
- })
- debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}")
- return # Do not fall through to keyword matching
-
- # Guard 6: keyword match
- template = self._match_keyword(text)
- if template is None:
- return
-
- # Build reply
- path_str = self._format_path(path_len, path_hashes)
- snr_str = f"{snr:.1f}" if snr is not None else "?"
- reply = template.format(
- bot=self._config.name,
- sender=sender or "?",
- snr=snr_str,
- path=path_str,
- )
-
- self._last_reply = now
-
- self._sink({
- "action": "send_message",
- "channel": channel_idx,
- "text": reply,
- "_bot": True,
- })
- debug_print(f"BOT: queued reply to '{sender}': {reply}")
-
- # ------------------------------------------------------------------
- # Extension point (OCP)
- # ------------------------------------------------------------------
-
- def _match_keyword(self, text: str) -> Optional[str]:
- """Return the reply template for the first matching keyword.
-
- Override this method for custom matching strategies (regex,
- exact match, priority ordering, etc.).
-
- Returns:
- Template string, or ``None`` if no keyword matched.
- """
- text_lower = (text or "").lower()
- for keyword, template in self._config.keywords.items():
- if keyword in text_lower:
- return template
- return None
-
- # ------------------------------------------------------------------
- # Helpers
- # ------------------------------------------------------------------
-
- @staticmethod
- def _format_path(
- path_len: int,
- path_hashes: Optional[List[str]],
- ) -> str:
- """Format path info as ``path(N); ``path(0)``."""
- if not path_len:
- return "path(0)"
- return f"path({path_len})"
diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py
index 2eb5a35..184fcda 100644
--- a/meshcore_gui/services/bbs_config_store.py
+++ b/meshcore_gui/services/bbs_config_store.py
@@ -66,7 +66,7 @@ class BbsBoard:
categories: Valid category tags for this board.
regions: Optional region tags; empty = no region filtering.
retention_hours: Message retention period in hours.
- allowed_keys: Sender public key whitelist (empty = all allowed).
+ allowed_keys: Sender public key whitelist for DM-BBS access.
"""
id: str
@@ -330,9 +330,9 @@ class BbsConfigStore:
) -> None:
"""Save the board configuration.
- Multiple channels can be assigned. Every sender seen on any of
- these channels is automatically eligible for DM access (the
- worker calls :meth:`add_allowed_key` when it sees them).
+ Multiple channels can be assigned. DM-BBS access is controlled by the
+ whitelist; senders are added only through the explicit ``!bbs``
+ bootstrap on the linked channel.
The board id is always ``'bbs_board'``. The board name is built
from the channel names in *channel_names*.
@@ -343,8 +343,7 @@ class BbsConfigStore:
categories: Category tag list.
retention_hours: Message retention period in hours.
regions: Optional region tags.
- allowed_keys: Manual sender key whitelist seed (auto-learned
- keys are added via :meth:`add_allowed_key`).
+ allowed_keys: Manual sender key whitelist seed.
"""
name = ", ".join(
channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices)
@@ -383,8 +382,8 @@ class BbsConfigStore:
def add_allowed_key(self, sender_key: str) -> bool:
"""Add *sender_key* to the board's allowed_keys whitelist.
- Called automatically by the worker whenever a sender is seen on
- a configured BBS channel. No-op if the key is already present
+ Called when the explicit ``!bbs`` bootstrap is received on the linked
+ BBS channel. No-op if the key is already present
or if no board is configured.
Args:
diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py
index 834c62a..90951b2 100644
--- a/meshcore_gui/services/bbs_service.py
+++ b/meshcore_gui/services/bbs_service.py
@@ -377,13 +377,12 @@ class BbsCommandHandler:
"""Handle a channel message on a configured BBS channel.
Called from ``EventHandler.on_channel_msg`` **after** the message
- has been stored. Two responsibilities:
+ has been stored.
- 1. **Auto-whitelist**: every sender seen on a BBS channel gets their
- key added to ``allowed_keys`` so they can use DMs afterwards.
- 2. **Bootstrap reply**: if the message starts with ``!``, reply on
- the channel so the sender knows the BBS is active and receives
- the abbreviation table.
+ Design rule:
+ - only ``!bbs`` is handled on the linked BBS channel;
+ - it whitelists the sender public key for later DM-BBS use;
+ - help/read/post/search commands are DM-only.
Args:
channel_idx: MeshCore channel index the message arrived on.
@@ -400,46 +399,16 @@ class BbsCommandHandler:
if channel_idx not in board.channels:
return None
- # Auto-whitelist: register this sender so they can use DMs
- if sender_key:
- self._config_store.add_allowed_key(sender_key)
-
- # Bootstrap reply only for !-commands
text = (text or "").strip()
- if not text.startswith("!"):
+ if text.lower() != "!bbs":
return None
-
- first = text.split()[0].lower()
- channel_for_post = channel_idx
-
- if first in ("!p",):
- rest = text[len(first):].strip()
- return self._handle_post_short(board, channel_for_post, sender, sender_key, rest)
-
- if first in ("!r",):
- rest = text[len(first):].strip()
- return self._handle_read_short(board, rest)
-
- if first in ("!s",):
- rest = text[len(first):].strip()
- return self._handle_search(board, rest)
-
- if first in ("!help", "!h"):
- return self._handle_help(board)
-
- if first == "!bbs":
- parts = text.split(None, 2)
- sub = parts[1].lower() if len(parts) > 1 else ""
- rest = parts[2] if len(parts) > 2 else ""
- if sub == "post":
- return self._handle_post(board, channel_for_post, sender, sender_key, rest)
- if sub == "read":
- return self._handle_read(board, rest)
- if sub in ("help", ""):
- return self._handle_help(board)
- return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
-
- return None
+ if not sender_key:
+ debug_print("BBS: !bbs ignored on channel because sender key is empty")
+ return "BBS whitelist failed: sender key unknown."
+ added = self._config_store.add_allowed_key(sender_key)
+ if added:
+ return "Add to BBS OK. Use !h in DM-BBS for help."
+ return "Already on BBS whitelist. Use !h in DM-BBS for help."
# ------------------------------------------------------------------
@@ -476,13 +445,25 @@ class BbsCommandHandler:
debug_print("BBS: no board configured, ignoring DM")
return None
- # Whitelist check
- if board.allowed_keys and sender_key not in board.allowed_keys:
- debug_print(
- f"BBS: silently dropping DM from {sender} "
- f"(key not in whitelist for board '{board.id}')"
- )
- return None
+ # Whitelist check (accept full-key/prefix matches in both directions)
+ if board.allowed_keys:
+ sender_key_up = (sender_key or "").upper()
+ allowed = False
+ for key in board.allowed_keys:
+ key_up = (key or "").upper()
+ if sender_key_up and key_up and (
+ sender_key_up == key_up
+ or sender_key_up.startswith(key_up)
+ or key_up.startswith(sender_key_up)
+ ):
+ allowed = True
+ break
+ if not allowed:
+ debug_print(
+ f"BBS: silently dropping DM from {sender} "
+ f"(key not in whitelist for board '{board.id}')"
+ )
+ return None
# Channel for storing posted messages
channel_idx = board.channels[0] if board.channels else 0
@@ -511,9 +492,9 @@ class BbsCommandHandler:
return self._handle_post(board, channel_idx, sender, sender_key, rest)
if sub == "read":
return self._handle_read(board, rest)
- if sub in ("help", ""):
+ if sub == "help":
return self._handle_help(board)
- return f"Unknown subcommand '{sub}'. " + self._handle_help(board)
+ return "Use !bbs on the linked channel for whitelist bootstrap."
# Unknown !-command
return None
@@ -553,7 +534,8 @@ class BbsCommandHandler:
abbrevs = self.compute_abbreviations(categories)
# abbrevs maps prefix β full name; invert for display
inv = {v: k for k, v in abbrevs.items()}
- return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv)
+ cats_upper = [c.upper() for c in categories]
+ return " ".join(f"{inv[c]}={c}" for c in cats_upper if c in inv)
def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]:
"""Resolve *token* to a category via exact match or abbreviation.
@@ -633,7 +615,7 @@ class BbsCommandHandler:
Range syntax: ``!r U 6-10`` returns messages 6 to 10 (1-indexed,
newest first). Without a range the default is 1-5 (five most recent).
- ``!r`` without any arguments always includes the abbreviation table.
+ Bare ``!r`` returns the most recent messages across all categories.
"""
regions = board.regions
categories = board.categories
@@ -670,7 +652,7 @@ class BbsCommandHandler:
return self._format_messages(
board, region, category,
offset=offset, limit=limit,
- include_abbrevs=not args,
+ include_abbrevs=False,
)
def _handle_search(self, board, args):
diff --git a/observer_config.template.yaml b/meshcore_observer/observer_config.template.yaml
similarity index 100%
rename from observer_config.template.yaml
rename to meshcore_observer/observer_config.template.yaml
diff --git a/observer_config.yaml b/meshcore_observer/observer_config.yaml
similarity index 100%
rename from observer_config.yaml
rename to meshcore_observer/observer_config.yaml