From e3bd422dfd7e1a4d3775832dd89596e29c000f75 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:05:30 +0100 Subject: [PATCH 01/14] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- CHANGELOG.md | 25 ++ meshcore_gui/config.py | 43 +- meshcore_gui/gui/dashboard.py | 21 + meshcore_gui/gui/panels/__init__.py | 1 + meshcore_gui/gui/panels/bbs_panel.py | 310 ++++++++++++++ meshcore_gui/services/bbs_service.py | 610 +++++++++++++++++++++++++++ meshcore_gui/services/bot.py | 35 +- 7 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/services/bbs_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11beeb6..be12974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > lower CPU usage during idle operation, and more stable map rendering. --- +## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) + +### Added +- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. + - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. + - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`. + - `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel for the dashboard. + - Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen). + - Region filter (shown only when the active channel has regions configured). + - Category filter (all or specific). + - Scrollable message list with timestamp, sender, category and optional region tag. + - Post form: region select (conditional), category select, text input, Send button. + - Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`. +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsPanel` registered as standalone panel `'bbs'`; menu item `πŸ“‹ BBS` added to the drawer. +- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +### Not changed +- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services. +- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels. + +--- + ## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes ### Fixed diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index ab034ac..6b6a1dd 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -25,7 +25,7 @@ from typing import Any, Dict, List # ============================================================================== -VERSION: str = "1.13.5" +VERSION: str = "1.14.0" # ============================================================================== @@ -388,3 +388,44 @@ RXLOG_RETENTION_DAYS: int = 7 # Retention period for contacts (in days). # Contacts not seen for longer than this are removed from cache. CONTACT_RETENTION_DAYS: int = 90 + + +# ============================================================================== +# BBS β€” Bulletin Board System +# ============================================================================== + +# One entry per BBS-enabled channel. Each entry configures: +# channel β€” MeshCore channel index (never use channel 0). +# name β€” Human-readable channel name shown in the BBS panel. +# regions β€” Optional list of region tags; empty list = no region filtering. +# categories β€” List of valid category tags for this channel. +# allowed_keys β€” Whitelist of sender public keys (hex strings). +# Empty list = only channel security applies (all keys allowed). +# retention_hours β€” How long messages are kept before automatic deletion. + +BBS_CHANNELS: List[Dict] = [ + { + "channel": 2, + "name": "NoodNet Zwolle", + "regions": ["Zwolle", "Dalfsen", "OV-Algemeen"], + "categories": ["MEDISCH", "LOGISTIEK", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 3, + "name": "NoodNet OV", + "regions": [], + "categories": ["STATUS", "ALGEMEEN", "INFRA"], + "allowed_keys": [], + "retention_hours": 48, + }, + { + "channel": 4, + "name": "Dalfsen", + "regions": [], + "categories": ["MEDISCH", "STATUS", "ALGEMEEN"], + "allowed_keys": [], + "retention_hours": 24, + }, +] diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index d7cef43..e9876ac 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -16,6 +16,7 @@ from meshcore_gui import config from meshcore_gui.core.protocols import SharedDataReader from meshcore_gui.gui.panels import ( ActionsPanel, + BbsPanel, ContactsPanel, DevicePanel, MapPanel, @@ -24,6 +25,7 @@ from meshcore_gui.gui.panels import ( RxLogPanel, ) from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -264,6 +266,7 @@ _STANDALONE_ITEMS = [ ('\U0001f4e1', 'DEVICE', 'device'), ('\u26a1', 'ACTIONS', 'actions'), ('\U0001f4ca', 'RX LOG', 'rxlog'), + ('\U0001f4cb', 'BBS', 'bbs'), ] _EXT_LINKS = config.EXT_LINKS @@ -295,6 +298,13 @@ class DashboardPage: self._pin_store = pin_store self._room_password_store = room_password_store + # BBS service (singleton, shared with bot routing) + from meshcore_gui import config as _cfg + self._bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler( + self._bbs_service, _cfg.BBS_CHANNELS + ) + # Panels (created fresh on each render) self._device: DevicePanel | None = None self._contacts: ContactsPanel | None = None @@ -303,6 +313,7 @@ class DashboardPage: self._actions: ActionsPanel | None = None self._rxlog: RxLogPanel | None = None self._room_server: RoomServerPanel | None = None + self._bbs: BbsPanel | None = None # Header status label self._status_label = None @@ -349,6 +360,8 @@ class DashboardPage: self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) + from meshcore_gui import config as _cfg + self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) @@ -509,6 +522,7 @@ class DashboardPage: ('actions', self._actions), ('rxlog', self._rxlog), ('rooms', self._room_server), + ('bbs', self._bbs), ] for panel_id, panel_obj in panel_defs: @@ -735,6 +749,9 @@ class DashboardPage: self._room_server.update(data) elif self._active_panel == 'rxlog': self._rxlog.update(data) + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) # ------------------------------------------------------------------ # Room Server callback (from ContactsPanel) @@ -817,6 +834,10 @@ class DashboardPage: if data['rxlog_updated'] or is_first: self._rxlog.update(data) + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) + # Signal worker that GUI is ready for data if is_first and data['channels'] and data['contacts']: self._shared.mark_gui_initialized() diff --git a/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/gui/panels/__init__.py index 018396d..f9245f4 100644 --- a/meshcore_gui/gui/panels/__init__.py +++ b/meshcore_gui/gui/panels/__init__.py @@ -15,3 +15,4 @@ from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 +from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py new file mode 100644 index 0000000..2a55ea5 --- /dev/null +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -0,0 +1,310 @@ +"""BBS panel β€” offline Bulletin Board System viewer and post form.""" + +from typing import Callable, Dict, List, Optional + +from nicegui import ui + +from meshcore_gui.config import debug_print +from meshcore_gui.services.bbs_service import BbsMessage, BbsService + + +class BbsPanel: + """BBS panel: channel selector, region/category filters, message list and post form. + + All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`. + No direct SQLite access in this class (SOLID: SRP / DIP). + + Args: + put_command: Callable to enqueue a command dict for the worker. + bbs_service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + def __init__( + self, + put_command: Callable[[Dict], None], + bbs_service: BbsService, + channels_config: List[Dict], + ) -> None: + self._put_command = put_command + self._service = bbs_service + self._channels_config = channels_config + + # Indexed for fast lookup + self._channels_by_idx: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # UI state + self._active_channel_idx: int = ( + channels_config[0]["channel"] if channels_config else 0 + ) + self._active_region: Optional[str] = None + self._active_category: Optional[str] = None + + # UI element references + self._msg_list_container = None + self._region_select = None + self._region_row = None + self._category_select = None + self._text_input = None + self._post_region_select = None + self._post_region_row = None + self._post_category_select = None + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete BBS panel layout.""" + with ui.card().classes('w-full'): + ui.label('πŸ“‹ BBS β€” Bulletin Board System').classes('font-bold text-gray-600') + + # ── Channel selector ────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Channel:').classes('text-sm text-gray-600') + for cfg in self._channels_config: + idx = cfg["channel"] + name = cfg["name"] + ui.button( + name, + on_click=lambda i=idx: self._select_channel(i), + ).props('flat no-caps').classes('text-xs') + + ui.separator() + + # ── Filter row ──────────────────────────────────────────── + with ui.row().classes('w-full items-center gap-4'): + ui.label('Filter:').classes('text-sm text-gray-600') + + # Region filter (hidden when channel has no regions) + self._region_row = ui.row().classes('items-center gap-2') + with self._region_row: + ui.label('Region:').classes('text-xs text-gray-600') + self._region_select = ui.select( + options=[], + value=None, + on_change=lambda e: self._on_region_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + # Category filter + with ui.row().classes('items-center gap-2'): + ui.label('Category:').classes('text-xs text-gray-600') + self._category_select = ui.select( + options=[], + value=None, + on_change=lambda e: self._on_category_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + ui.button('πŸ”„ Refresh', on_click=self._refresh_messages).props('flat no-caps').classes('text-xs') + + ui.separator() + + # ── Message list ────────────────────────────────────────── + self._msg_list_container = ui.column().classes( + 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + ) + + ui.separator() + + # ── Post form ───────────────────────────────────────────── + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('Post:').classes('text-sm text-gray-600') + + # Post region select (hidden when channel has no regions) + self._post_region_row = ui.row().classes('items-center gap-1') + with self._post_region_row: + self._post_region_select = ui.select( + options=[], + label='Region', + ).classes('text-xs').style('min-width: 110px') + + # Post category select + self._post_category_select = ui.select( + options=[], + label='Category', + ).classes('text-xs').style('min-width: 110px') + + self._text_input = ui.input( + placeholder='Message text…', + ).classes('flex-grow text-sm') + + ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') + + # Initial render for the default channel + self._select_channel(self._active_channel_idx) + + # ------------------------------------------------------------------ + # Channel selection + # ------------------------------------------------------------------ + + def _select_channel(self, channel_idx: int) -> None: + """Switch the active channel and rebuild filter options. + + Args: + channel_idx: MeshCore channel index to activate. + """ + self._active_channel_idx = channel_idx + self._active_region = None + self._active_category = None + + cfg = self._channels_by_idx.get(channel_idx, {}) + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg.get("categories", []) + + # Region filter visibility + has_regions = bool(regions) + if self._region_row: + self._region_row.set_visibility(has_regions) + if self._post_region_row: + self._post_region_row.set_visibility(has_regions) + + # Populate region selects + region_opts = ["(all)"] + regions + if self._region_select: + self._region_select.options = region_opts + self._region_select.value = "(all)" + if self._post_region_select: + self._post_region_select.options = regions + self._post_region_select.value = regions[0] if regions else None + + # Populate category selects + cat_opts = ["(all)"] + categories + if self._category_select: + self._category_select.options = cat_opts + self._category_select.value = "(all)" + if self._post_category_select: + self._post_category_select.options = categories + self._post_category_select.value = categories[0] if categories else None + + self._refresh_messages() + + # ------------------------------------------------------------------ + # Filter callbacks + # ------------------------------------------------------------------ + + def _on_region_filter(self, value: Optional[str]) -> None: + """Handle region filter change. + + Args: + value: Selected region string, or ``'(all)'``. + """ + self._active_region = None if (not value or value == "(all)") else value + self._refresh_messages() + + def _on_category_filter(self, value: Optional[str]) -> None: + """Handle category filter change. + + Args: + value: Selected category string, or ``'(all)'``. + """ + self._active_category = None if (not value or value == "(all)") else value + self._refresh_messages() + + # ------------------------------------------------------------------ + # Message list refresh + # ------------------------------------------------------------------ + + def _refresh_messages(self) -> None: + """Query the BBS service and rebuild the message list UI.""" + if not self._msg_list_container: + return + + messages = self._service.get_all_messages( + channel=self._active_channel_idx, + region=self._active_region, + category=self._active_category, + ) + + self._msg_list_container.clear() + with self._msg_list_container: + if not messages: + ui.label('No messages.').classes('text-xs text-gray-400 italic') + for msg in messages: + self._render_message_row(msg) + + def _render_message_row(self, msg: BbsMessage) -> None: + """Render a single message row in the message list. + + Args: + msg: ``BbsMessage`` to display. + """ + ts = msg.timestamp[:16].replace("T", " ") + region_label = f" [{msg.region}]" if msg.region else "" + header = f"{ts} {msg.sender} [{msg.category}]{region_label}" + + with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500') + ui.label(msg.text).classes('text-sm') + + # ------------------------------------------------------------------ + # Post + # ------------------------------------------------------------------ + + def _on_post(self) -> None: + """Handle the Send button: validate inputs and post a BBS message.""" + cfg = self._channels_by_idx.get(self._active_channel_idx, {}) + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg.get("categories", []) + + text = (self._text_input.value or "").strip() if self._text_input else "" + if not text: + ui.notify("Message text cannot be empty.", type="warning") + return + + category = ( + self._post_category_select.value + if self._post_category_select + else (categories[0] if categories else "") + ) + if not category: + ui.notify("Please select a category.", type="warning") + return + + region = "" + if regions and self._post_region_select: + region = self._post_region_select.value or "" + + # Build and persist the message (GUI post β€” sender is the local device) + msg = BbsMessage( + channel=self._active_channel_idx, + region=region, + category=category, + sender="Me", + sender_key="", + text=text, + ) + self._service.post_message(msg) + + # Optionally also broadcast via the mesh (put_command enqueues for worker) + region_part = f"{region} " if region else "" + mesh_text = f"!bbs post {region_part}{category} {text}" + self._put_command({ + "action": "send_message", + "channel": self._active_channel_idx, + "text": mesh_text, + }) + + debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}") + + if self._text_input: + self._text_input.value = "" + self._refresh_messages() + ui.notify("Message posted.", type="positive") + + # ------------------------------------------------------------------ + # External update hook (called from dashboard timer) + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Called by the dashboard timer. Refreshes if new data arrived. + + Currently a lightweight no-op: the BBS panel refreshes on user + interaction. Override for real-time auto-refresh if desired. + + Args: + data: SharedData snapshot (unused; kept for interface consistency). + """ + # No-op: BBS data is local SQLite, not pushed via SharedData. + # Active refresh only happens on user action or channel switch. diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py new file mode 100644 index 0000000..18b2a6a --- /dev/null +++ b/meshcore_gui/services/bbs_service.py @@ -0,0 +1,610 @@ +""" +Offline Bulletin Board System (BBS) service for MeshCore GUI. + +Stores BBS messages in a local SQLite database, one table per channel. +Each channel is configured via ``BBS_CHANNELS`` in ``config.py``. + +Architecture +~~~~~~~~~~~~ +- ``BbsService`` β€” persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` β€” parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. + +Thread safety +~~~~~~~~~~~~~ +SQLite connections are created in the calling thread. The service uses +``check_same_thread=False`` combined with an internal ``threading.Lock`` +so it is safe to call from both the GUI thread and the worker thread. + +Storage location +~~~~~~~~~~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). +""" + +import sqlite3 +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" +BBS_DB_PATH = BBS_DIR / "bbs_messages.db" + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsMessage: + """A single BBS message. + + Attributes: + id: Database row id (``None`` before insert). + channel: MeshCore channel index. + region: Region tag (empty string when channel has no regions). + category: Category tag (e.g. ``'MEDISCH'``). + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Message body. + timestamp: UTC ISO-8601 timestamp string. + """ + + channel: int + region: str + category: str + sender: str + sender_key: str + text: str + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + id: Optional[int] = None + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + +class BbsService: + """SQLite-backed BBS storage service. + + Args: + db_path: Path to the SQLite database file. + Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``. + """ + + def __init__(self, db_path: Path = BBS_DB_PATH) -> None: + self._db_path = db_path + self._lock = threading.Lock() + self._init_db() + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + + def _init_db(self) -> None: + """Create the database directory and schema if not present.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS bbs_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel INTEGER NOT NULL, + region TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + sender TEXT NOT NULL, + sender_key TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + timestamp TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)" + ) + conn.commit() + debug_print(f"BBS: database ready at {self._db_path}") + + def _connect(self) -> sqlite3.Connection: + """Return a new SQLite connection (check_same_thread=False).""" + return sqlite3.connect( + str(self._db_path), check_same_thread=False + ) + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def post_message(self, msg: BbsMessage) -> int: + """Insert a BBS message and return its row id. + + Args: + msg: ``BbsMessage`` dataclass to persist. + + Returns: + Assigned ``rowid`` (also set on ``msg.id``). + """ + with self._lock: + with self._connect() as conn: + cur = conn.execute( + """ + INSERT INTO bbs_messages + (channel, region, category, sender, sender_key, text, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + msg.channel, + msg.region, + msg.category, + msg.sender, + msg.sender_key, + msg.text, + msg.timestamp, + ), + ) + conn.commit() + msg.id = cur.lastrowid + debug_print( + f"BBS: posted msg id={msg.id} ch={msg.channel} " + f"cat={msg.category} sender={msg.sender}" + ) + return msg.id + + # ------------------------------------------------------------------ + # Read + # ------------------------------------------------------------------ + + def get_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, + ) -> List[BbsMessage]: + """Return the *limit* most recent messages for a channel. + + Args: + channel: MeshCore channel index. + region: Optional region filter (exact match; ``None`` = all). + category: Optional category filter (exact match; ``None`` = all). + limit: Maximum number of messages to return. + + Returns: + List of ``BbsMessage`` objects, newest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + + return [ + BbsMessage( + id=row[0], + channel=row[1], + region=row[2], + category=row[3], + sender=row[4], + sender_key=row[5], + text=row[6], + timestamp=row[7], + ) + for row in rows + ] + + def get_all_messages( + self, + channel: int, + region: Optional[str] = None, + category: Optional[str] = None, + ) -> List[BbsMessage]: + """Return all messages for a channel (oldest first) for the GUI panel. + + Args: + channel: MeshCore channel index. + region: Optional region filter. + category: Optional category filter. + + Returns: + List of ``BbsMessage`` objects, oldest first. + """ + query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + params: list = [channel] + + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + + query += " ORDER BY timestamp ASC" + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + + return [ + BbsMessage( + id=row[0], + channel=row[1], + region=row[2], + category=row[3], + sender=row[4], + sender_key=row[5], + text=row[6], + timestamp=row[7], + ) + for row in rows + ] + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def purge_expired(self, channel: int, retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a channel. + + Args: + channel: MeshCore channel index. + retention_hours: Messages older than this are deleted. + + Returns: + Number of rows deleted. + """ + cutoff = ( + datetime.now(timezone.utc) - timedelta(hours=retention_hours) + ).isoformat() + + with self._lock: + with self._connect() as conn: + cur = conn.execute( + "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", + (channel, cutoff), + ) + conn.commit() + deleted = cur.rowcount + if deleted: + debug_print( + f"BBS: purged {deleted} expired messages from ch={channel}" + ) + return deleted + + def purge_all_expired(self, channels_config: List[Dict]) -> None: + """Run retention cleanup for all configured channels. + + Args: + channels_config: List of channel config dicts from ``BBS_CHANNELS``. + """ + for cfg in channels_config: + self.purge_expired(cfg["channel"], cfg["retention_hours"]) + + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- + +class BbsCommandHandler: + """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + + One handler is shared across all configured channels. Channel context + is passed per call so the handler is stateless. + + Args: + service: Shared ``BbsService`` instance. + channels_config: ``BBS_CHANNELS`` list from ``config.py``. + """ + + # Maximum messages returned per !bbs read call + READ_LIMIT: int = 5 + + def __init__( + self, + service: BbsService, + channels_config: List[Dict], + ) -> None: + self._service = service + # Index by channel number for O(1) lookup + self._channels: Dict[int, Dict] = { + cfg["channel"]: cfg for cfg in channels_config + } + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def handle( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Parse an incoming message and return a reply string (or ``None``). + + Returns ``None`` when the message is not a BBS command, the channel + is not configured, or the sender fails the whitelist check. + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string, or ``None`` if no reply should be sent. + """ + text = (text or "").strip() + if not text.lower().startswith("!bbs"): + return None + + cfg = self._channels.get(channel_idx) + if cfg is None: + return None # Channel not configured β€” ignore + + # Whitelist check + allowed = cfg.get("allowed_keys", []) + if allowed and sender_key not in allowed: + debug_print( + f"BBS: silently dropping msg from {sender} " + f"(key not in whitelist for ch={channel_idx})" + ) + return None # Silent drop β€” no error reply + + parts = text.split(None, 1) + args = parts[1].strip() if len(parts) > 1 else "" + + return self._dispatch(cfg, sender, sender_key, args) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Route to the appropriate sub-command handler. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs ``. + + Returns: + Reply string (always non-empty). + """ + sub = args.split(None, 1)[0].lower() if args else "" + rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" + + if sub == "post": + return self._handle_post(cfg, sender, sender_key, rest) + if sub == "read": + return self._handle_read(cfg, rest) + if sub == "help" or not sub: + return self._handle_help(cfg) + + return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + + # ------------------------------------------------------------------ + # Sub-command: post + # ------------------------------------------------------------------ + + def _handle_post( + self, + cfg: Dict, + sender: str, + sender_key: str, + args: str, + ) -> str: + """Handle ``!bbs post [region] [category] [text]``. + + When the channel has regions, the first token is the region, + the second is the category, and the remainder is the text. + Without regions, the first token is the category and the + remainder is the text. + + Args: + cfg: Channel configuration dict. + sender: Display name of the sender. + sender_key: Public key of the sender. + args: Everything after ``!bbs post ``. + + Returns: + Confirmation or error message string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split(None, 2) if args else [] + + if regions: + # Syntax: !bbs post [region] [category] [text] + if len(tokens) < 3: + return ( + f"Usage: !bbs post [region] [category] [text] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + region, category, text = tokens[0], tokens[1], tokens[2] + + region_upper = region.upper() + valid_regions = [r.upper() for r in regions] + if region_upper not in valid_regions: + return ( + f"Invalid region '{region}'. " + f"Valid: {', '.join(regions)}" + ) + # Normalise to configured casing + region = regions[valid_regions.index(region_upper)] + + category_upper = category.upper() + valid_cats = [c.upper() for c in categories] + if category_upper not in valid_cats: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + else: + # Syntax: !bbs post [category] [text] + if len(tokens) < 2: + return ( + f"Usage: !bbs post [category] [text] | " + f"Categories: {', '.join(categories)}" + ) + region = "" + category, text = tokens[0], tokens[1] + + category_upper = category.upper() + valid_cats = [c.upper() for c in categories] + if category_upper not in valid_cats: + return ( + f"Invalid category '{category}'. " + f"Valid: {', '.join(categories)}" + ) + category = categories[valid_cats.index(category_upper)] + + msg = BbsMessage( + channel=cfg["channel"], + region=region, + category=category, + sender=sender, + sender_key=sender_key, + text=text, + ) + self._service.post_message(msg) + region_label = f" [{region}]" if region else "" + return f"Posted [{category}]{region_label}: {text[:60]}" + + # ------------------------------------------------------------------ + # Sub-command: read + # ------------------------------------------------------------------ + + def _handle_read(self, cfg: Dict, args: str) -> str: + """Handle ``!bbs read [region] [category]``. + + With regions: ``!bbs read`` / ``!bbs read [region]`` / + ``!bbs read [region] [category]`` + Without regions: ``!bbs read`` / ``!bbs read [category]`` + + Args: + cfg: Channel configuration dict. + args: Everything after ``!bbs read ``. + + Returns: + Formatted message list or error string. + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + tokens = args.split() if args else [] + + region: Optional[str] = None + category: Optional[str] = None + + if regions: + valid_regions_upper = [r.upper() for r in regions] + valid_cats_upper = [c.upper() for c in categories] + + if len(tokens) >= 1: + tok0 = tokens[0].upper() + if tok0 in valid_regions_upper: + region = regions[valid_regions_upper.index(tok0)] + if len(tokens) >= 2: + tok1 = tokens[1].upper() + if tok1 in valid_cats_upper: + category = categories[valid_cats_upper.index(tok1)] + else: + return ( + f"Invalid category '{tokens[1]}'. " + f"Valid: {', '.join(categories)}" + ) + else: + return ( + f"Invalid region '{tokens[0]}'. " + f"Valid: {', '.join(regions)}" + ) + else: + valid_cats_upper = [c.upper() for c in categories] + if len(tokens) >= 1: + tok0 = tokens[0].upper() + if tok0 in valid_cats_upper: + category = categories[valid_cats_upper.index(tok0)] + else: + return ( + f"Invalid category '{tokens[0]}'. " + f"Valid: {', '.join(categories)}" + ) + + messages = self._service.get_messages( + cfg["channel"], region=region, category=category, + limit=self.READ_LIMIT, + ) + + if not messages: + return "BBS: no messages found." + + lines = [] + for m in messages: + ts = m.timestamp[:16].replace("T", " ") # YYYY-MM-DD HH:MM + region_label = f"[{m.region}] " if m.region else "" + lines.append( + f"{ts} {m.sender} [{m.category}] {region_label}{m.text}" + ) + return "\n".join(lines) + + # ------------------------------------------------------------------ + # Sub-command: help + # ------------------------------------------------------------------ + + def _handle_help(self, cfg: Dict) -> str: + """Return a compact command reference for this channel. + + Args: + cfg: Channel configuration dict. + + Returns: + Help string (single line). + """ + regions: List[str] = cfg.get("regions", []) + categories: List[str] = cfg["categories"] + name = cfg.get("name", f"ch{cfg['channel']}") + + if regions: + return ( + f"BBS [{name}] | " + f"!bbs post [region] [cat] [text] | " + f"!bbs read [region] [cat] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + return ( + f"BBS [{name}] | " + f"!bbs post [cat] [text] | " + f"!bbs read [cat] | " + f"Categories: {', '.join(categories)}" + ) diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 6e4465a..9279a72 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -10,11 +10,21 @@ Open/Closed New keywords are added via ``BotConfig.keywords`` (data) without modifying the ``MeshBot`` class (code). Custom matching strategies can be implemented by subclassing and overriding ``_match_keyword``. + +BBS integration +~~~~~~~~~~~~~~~ +``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a +:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one +is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is +``None`` (default), BBS routing is simply skipped. """ import time from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler from meshcore_gui.config import debug_print @@ -81,11 +91,13 @@ class MeshBot: config: BotConfig, command_sink: Callable[[Dict], None], enabled_check: Callable[[], bool], + bbs_handler: Optional["BbsCommandHandler"] = None, ) -> None: self._config = config self._sink = command_sink self._enabled = enabled_check self._last_reply: float = 0.0 + self._bbs_handler = bbs_handler def check_and_reply( self, @@ -129,6 +141,27 @@ class MeshBot: debug_print("BOT: cooldown active, skipping") return + # BBS routing: delegate !bbs commands to BbsCommandHandler + if self._bbs_handler is not None: + text_stripped = (text or "").strip() + if text_stripped.lower().startswith("!bbs"): + bbs_reply = self._bbs_handler.handle( + channel_idx=channel_idx, + sender=sender, + sender_key="", # sender_key not available at this call-site + text=text_stripped, + ) + if bbs_reply is not None: + self._last_reply = now + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": bbs_reply, + "_bot": True, + }) + debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}") + return # Do not fall through to keyword matching + # Guard 6: keyword match template = self._match_keyword(text) if template is None: From d24d38f543e8aeda521a526441406c1f651f3a4a Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:23:06 +0100 Subject: [PATCH 02/14] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- CHANGELOG.md | 32 +- meshcore_gui/config.py | 42 +-- meshcore_gui/gui/dashboard.py | 10 +- meshcore_gui/gui/panels/bbs_panel.py | 401 ++++++++++++++++------ meshcore_gui/services/bbs_config_store.py | 232 +++++++++++++ meshcore_gui/services/bbs_service.py | 212 ++++-------- 6 files changed, 608 insertions(+), 321 deletions(-) create mode 100644 meshcore_gui/services/bbs_config_store.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be12974..9d542cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,25 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) ### Added +- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`. - πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. - - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. Database at `~/.meshcore-gui/bbs/bbs_messages.db`. - - `BbsCommandHandler`: parses `!bbs post`, `!bbs read`, `!bbs help` mesh commands. Whitelist enforcement (silent drop on unknown sender key). Per-channel region/category validation with error reply. -- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel for the dashboard. - - Channel selector (NoodNet Zwolle / NoodNet OV / Dalfsen). - - Region filter (shown only when the active channel has regions configured). - - Category filter (all or specific). - - Scrollable message list with timestamp, sender, category and optional region tag. - - Post form: region select (conditional), category select, text input, Send button. - - Send broadcasts `!bbs post …` on the mesh channel so other nodes receive it. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` configuration block added; version bumped to `1.14.0`. -- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepts optional `bbs_handler` parameter. Incoming `!bbs` messages are routed to `BbsCommandHandler` before keyword matching; replies are sent on the originating channel. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsPanel` registered as standalone panel `'bbs'`; menu item `πŸ“‹ BBS` added to the drawer. + - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`. + - `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. + - Channel-selector (alleen enabled BBS channels). + - Regio-filter (alleen zichtbaar als channel regio's heeft). + - Categorie-filter. + - Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label. + - Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop. + - Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen. + - **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt. + +### Changed +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vΓ³Γ³r keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `πŸ“‹ BBS` toegevoegd aan de drawer. - πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. ### Not changed -- BLE layer, SharedData, core/models, route_page, map_panel, message_archive, all other services. -- All existing bot keyword behaviour, room server flow, archive page, contacts, map, device, actions, rxlog panels. +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services. +- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels. --- diff --git a/meshcore_gui/config.py b/meshcore_gui/config.py index 6b6a1dd..d7c3dc5 100644 --- a/meshcore_gui/config.py +++ b/meshcore_gui/config.py @@ -390,42 +390,6 @@ RXLOG_RETENTION_DAYS: int = 7 CONTACT_RETENTION_DAYS: int = 90 -# ============================================================================== -# BBS β€” Bulletin Board System -# ============================================================================== - -# One entry per BBS-enabled channel. Each entry configures: -# channel β€” MeshCore channel index (never use channel 0). -# name β€” Human-readable channel name shown in the BBS panel. -# regions β€” Optional list of region tags; empty list = no region filtering. -# categories β€” List of valid category tags for this channel. -# allowed_keys β€” Whitelist of sender public keys (hex strings). -# Empty list = only channel security applies (all keys allowed). -# retention_hours β€” How long messages are kept before automatic deletion. - -BBS_CHANNELS: List[Dict] = [ - { - "channel": 2, - "name": "NoodNet Zwolle", - "regions": ["Zwolle", "Dalfsen", "OV-Algemeen"], - "categories": ["MEDISCH", "LOGISTIEK", "STATUS", "ALGEMEEN"], - "allowed_keys": [], - "retention_hours": 48, - }, - { - "channel": 3, - "name": "NoodNet OV", - "regions": [], - "categories": ["STATUS", "ALGEMEEN", "INFRA"], - "allowed_keys": [], - "retention_hours": 48, - }, - { - "channel": 4, - "name": "Dalfsen", - "regions": [], - "categories": ["MEDISCH", "STATUS", "ALGEMEEN"], - "allowed_keys": [], - "retention_hours": 24, - }, -] +# BBS channel configuration is managed at runtime via BbsConfigStore. +# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json +# and edited through the BBS Settings panel in the GUI. diff --git a/meshcore_gui/gui/dashboard.py b/meshcore_gui/gui/dashboard.py index e9876ac..411e67c 100644 --- a/meshcore_gui/gui/dashboard.py +++ b/meshcore_gui/gui/dashboard.py @@ -25,6 +25,7 @@ from meshcore_gui.gui.panels import ( RxLogPanel, ) from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_config_store import BbsConfigStore from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -298,11 +299,11 @@ class DashboardPage: self._pin_store = pin_store self._room_password_store = room_password_store - # BBS service (singleton, shared with bot routing) - from meshcore_gui import config as _cfg + # BBS service and config store (singletons shared with bot routing) + self._bbs_config_store = BbsConfigStore() self._bbs_service = BbsService() self._bbs_handler = BbsCommandHandler( - self._bbs_service, _cfg.BBS_CHANNELS + self._bbs_service, self._bbs_config_store ) # Panels (created fresh on each render) @@ -360,8 +361,7 @@ class DashboardPage: self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) self._rxlog = RxLogPanel() self._room_server = RoomServerPanel(put_cmd, self._room_password_store) - from meshcore_gui import config as _cfg - self._bbs = BbsPanel(put_cmd, self._bbs_service, _cfg.BBS_CHANNELS) + self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store) # Inject DOMCA theme (fonts + CSS variables) ui.add_head_html(_DOMCA_HEAD) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 2a55ea5..c642ad8 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -1,56 +1,64 @@ -"""BBS panel β€” offline Bulletin Board System viewer and post form.""" +"""BBS panel -- offline Bulletin Board System viewer, post form and settings.""" from typing import Callable, Dict, List, Optional from nicegui import ui from meshcore_gui.config import debug_print +from meshcore_gui.services.bbs_config_store import ( + BbsConfigStore, + DEFAULT_CATEGORIES, + DEFAULT_RETENTION_HOURS, +) from meshcore_gui.services.bbs_service import BbsMessage, BbsService class BbsPanel: - """BBS panel: channel selector, region/category filters, message list and post form. + """BBS panel: channel selector, filters, message list, post form and settings. - All data access goes through :class:`~meshcore_gui.services.bbs_service.BbsService`. + The settings section lists all active device channels (from SharedData) + and lets the user enable/disable BBS per channel and configure + categories, regions and retention. Configuration is persisted via + BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json. + + All data access goes through BbsService and BbsConfigStore. No direct SQLite access in this class (SOLID: SRP / DIP). Args: - put_command: Callable to enqueue a command dict for the worker. - bbs_service: Shared ``BbsService`` instance. - channels_config: ``BBS_CHANNELS`` list from ``config.py``. + put_command: Callable to enqueue a command dict for the worker. + bbs_service: Shared BbsService instance. + config_store: Shared BbsConfigStore instance. """ def __init__( self, put_command: Callable[[Dict], None], bbs_service: BbsService, - channels_config: List[Dict], + config_store: BbsConfigStore, ) -> None: self._put_command = put_command self._service = bbs_service - self._channels_config = channels_config + self._config_store = config_store - # Indexed for fast lookup - self._channels_by_idx: Dict[int, Dict] = { - cfg["channel"]: cfg for cfg in channels_config - } - - # UI state - self._active_channel_idx: int = ( - channels_config[0]["channel"] if channels_config else 0 - ) + # Active view state + self._active_channel_idx: Optional[int] = None self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI element references + # UI element references -- message view self._msg_list_container = None - self._region_select = None self._region_row = None + self._region_select = None self._category_select = None self._text_input = None - self._post_region_select = None self._post_region_row = None + self._post_region_select = None self._post_category_select = None + self._channel_btn_row = None + + # UI element references -- settings + self._settings_container = None + self._last_device_channels: List[Dict] = [] # ------------------------------------------------------------------ # Render @@ -59,26 +67,20 @@ class BbsPanel: def render(self) -> None: """Build the complete BBS panel layout.""" with ui.card().classes('w-full'): - ui.label('πŸ“‹ BBS β€” Bulletin Board System').classes('font-bold text-gray-600') + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - # ── Channel selector ────────────────────────────────────── - with ui.row().classes('w-full items-center gap-4'): + # ---- Channel selector ----------------------------------- + self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._channel_btn_row: ui.label('Channel:').classes('text-sm text-gray-600') - for cfg in self._channels_config: - idx = cfg["channel"] - name = cfg["name"] - ui.button( - name, - on_click=lambda i=idx: self._select_channel(i), - ).props('flat no-caps').classes('text-xs') + # Populated by _rebuild_channel_buttons() ui.separator() - # ── Filter row ──────────────────────────────────────────── - with ui.row().classes('w-full items-center gap-4'): + # ---- Filter row ---------------------------------------- + with ui.row().classes('w-full items-center gap-4 flex-wrap'): ui.label('Filter:').classes('text-sm text-gray-600') - # Region filter (hidden when channel has no regions) self._region_row = ui.row().classes('items-center gap-2') with self._region_row: ui.label('Region:').classes('text-xs text-gray-600') @@ -88,7 +90,6 @@ class BbsPanel: on_change=lambda e: self._on_region_filter(e.value), ).classes('text-xs').style('min-width: 120px') - # Category filter with ui.row().classes('items-center gap-2'): ui.label('Category:').classes('text-xs text-gray-600') self._category_select = ui.select( @@ -97,48 +98,84 @@ class BbsPanel: on_change=lambda e: self._on_category_filter(e.value), ).classes('text-xs').style('min-width: 120px') - ui.button('πŸ”„ Refresh', on_click=self._refresh_messages).props('flat no-caps').classes('text-xs') + ui.button( + 'Refresh', on_click=self._refresh_messages + ).props('flat no-caps').classes('text-xs') ui.separator() - # ── Message list ────────────────────────────────────────── + # ---- Message list -------------------------------------- self._msg_list_container = ui.column().classes( 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' ) ui.separator() - # ── Post form ───────────────────────────────────────────── + # ---- Post form ----------------------------------------- with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') - # Post region select (hidden when channel has no regions) self._post_region_row = ui.row().classes('items-center gap-1') with self._post_region_row: self._post_region_select = ui.select( - options=[], - label='Region', + options=[], label='Region', ).classes('text-xs').style('min-width: 110px') - # Post category select self._post_category_select = ui.select( - options=[], - label='Category', + options=[], label='Category', ).classes('text-xs').style('min-width: 110px') self._text_input = ui.input( - placeholder='Message text…', + placeholder='Message text...', ).classes('flex-grow text-sm') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # Initial render for the default channel - self._select_channel(self._active_channel_idx) + # ---- Settings card ----------------------------------------- + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.label( + 'Enable BBS on a channel to allow !bbs commands and store messages.' + ).classes('text-xs text-gray-500') + ui.separator() + self._settings_container = ui.column().classes('w-full gap-2') + with self._settings_container: + ui.label('Waiting for device channels...').classes( + 'text-xs text-gray-400 italic' + ) # ------------------------------------------------------------------ - # Channel selection + # Channel selector (message view) # ------------------------------------------------------------------ + def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None: + """Rebuild the channel selector buttons for enabled BBS channels. + + Args: + enabled_channels: List of enabled channel config dicts. + """ + if not self._channel_btn_row: + return + self._channel_btn_row.clear() + with self._channel_btn_row: + ui.label('Channel:').classes('text-sm text-gray-600') + if not enabled_channels: + ui.label('No BBS channels configured.').classes( + 'text-xs text-gray-400 italic' + ) + return + for cfg in enabled_channels: + idx = cfg['channel'] + name = cfg['name'] + ui.button( + name, + on_click=lambda i=idx: self._select_channel(i), + ).props('flat no-caps').classes('text-xs') + + # Auto-select first channel when none is active yet + if self._active_channel_idx is None and enabled_channels: + self._select_channel(enabled_channels[0]['channel']) + def _select_channel(self, channel_idx: int) -> None: """Switch the active channel and rebuild filter options. @@ -149,31 +186,28 @@ class BbsPanel: self._active_region = None self._active_category = None - cfg = self._channels_by_idx.get(channel_idx, {}) - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg.get("categories", []) + cfg = self._config_store.get_channel(channel_idx) or {} + regions: List[str] = cfg.get('regions', []) + categories: List[str] = cfg.get('categories', []) - # Region filter visibility has_regions = bool(regions) if self._region_row: self._region_row.set_visibility(has_regions) if self._post_region_row: self._post_region_row.set_visibility(has_regions) - # Populate region selects - region_opts = ["(all)"] + regions + region_opts = ['(all)'] + regions if self._region_select: self._region_select.options = region_opts - self._region_select.value = "(all)" + self._region_select.value = '(all)' if self._post_region_select: self._post_region_select.options = regions self._post_region_select.value = regions[0] if regions else None - # Populate category selects - cat_opts = ["(all)"] + categories + cat_opts = ['(all)'] + categories if self._category_select: self._category_select.options = cat_opts - self._category_select.value = "(all)" + self._category_select.value = '(all)' if self._post_category_select: self._post_category_select.options = categories self._post_category_select.value = categories[0] if categories else None @@ -185,21 +219,11 @@ class BbsPanel: # ------------------------------------------------------------------ def _on_region_filter(self, value: Optional[str]) -> None: - """Handle region filter change. - - Args: - value: Selected region string, or ``'(all)'``. - """ - self._active_region = None if (not value or value == "(all)") else value + self._active_region = None if (not value or value == '(all)') else value self._refresh_messages() def _on_category_filter(self, value: Optional[str]) -> None: - """Handle category filter change. - - Args: - value: Selected category string, or ``'(all)'``. - """ - self._active_category = None if (not value or value == "(all)") else value + self._active_category = None if (not value or value == '(all)') else value self._refresh_messages() # ------------------------------------------------------------------ @@ -210,30 +234,31 @@ class BbsPanel: """Query the BBS service and rebuild the message list UI.""" if not self._msg_list_container: return - - messages = self._service.get_all_messages( - channel=self._active_channel_idx, - region=self._active_region, - category=self._active_category, - ) - self._msg_list_container.clear() with self._msg_list_container: + if self._active_channel_idx is None: + ui.label('Select a channel above.').classes('text-xs text-gray-400 italic') + return + messages = self._service.get_all_messages( + channel=self._active_channel_idx, + region=self._active_region, + category=self._active_category, + ) if not messages: ui.label('No messages.').classes('text-xs text-gray-400 italic') + return for msg in messages: self._render_message_row(msg) def _render_message_row(self, msg: BbsMessage) -> None: - """Render a single message row in the message list. + """Render a single message row. Args: - msg: ``BbsMessage`` to display. + msg: BbsMessage to display. """ - ts = msg.timestamp[:16].replace("T", " ") - region_label = f" [{msg.region}]" if msg.region else "" - header = f"{ts} {msg.sender} [{msg.category}]{region_label}" - + ts = msg.timestamp[:16].replace('T', ' ') + region_label = f' [{msg.region}]' if msg.region else '' + header = f'{ts} {msg.sender} [{msg.category}]{region_label}' with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): ui.label(header).classes('text-xs text-gray-500') ui.label(msg.text).classes('text-sm') @@ -244,67 +269,221 @@ class BbsPanel: def _on_post(self) -> None: """Handle the Send button: validate inputs and post a BBS message.""" - cfg = self._channels_by_idx.get(self._active_channel_idx, {}) - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg.get("categories", []) + if self._active_channel_idx is None: + ui.notify('Select a channel first.', type='warning') + return - text = (self._text_input.value or "").strip() if self._text_input else "" + cfg = self._config_store.get_channel(self._active_channel_idx) or {} + regions: List[str] = cfg.get('regions', []) + categories: List[str] = cfg.get('categories', []) + + text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify("Message text cannot be empty.", type="warning") + ui.notify('Message text cannot be empty.', type='warning') return category = ( self._post_category_select.value - if self._post_category_select - else (categories[0] if categories else "") + if self._post_category_select else (categories[0] if categories else '') ) if not category: - ui.notify("Please select a category.", type="warning") + ui.notify('Please select a category.', type='warning') return - region = "" + region = '' if regions and self._post_region_select: - region = self._post_region_select.value or "" + region = self._post_region_select.value or '' - # Build and persist the message (GUI post β€” sender is the local device) msg = BbsMessage( channel=self._active_channel_idx, region=region, category=category, - sender="Me", - sender_key="", + sender='Me', + sender_key='', text=text, ) self._service.post_message(msg) - # Optionally also broadcast via the mesh (put_command enqueues for worker) - region_part = f"{region} " if region else "" - mesh_text = f"!bbs post {region_part}{category} {text}" + # Broadcast on the mesh channel + region_part = f'{region} ' if region else '' + mesh_text = f'!bbs post {region_part}{category} {text}' self._put_command({ - "action": "send_message", - "channel": self._active_channel_idx, - "text": mesh_text, + 'action': 'send_message', + 'channel': self._active_channel_idx, + 'text': mesh_text, }) - - debug_print(f"BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}") + debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}') if self._text_input: - self._text_input.value = "" + self._text_input.value = '' self._refresh_messages() - ui.notify("Message posted.", type="positive") + ui.notify('Message posted.', type='positive') + + # ------------------------------------------------------------------ + # Settings panel + # ------------------------------------------------------------------ + + def _rebuild_settings(self, device_channels: List[Dict]) -> None: + """Rebuild the settings rows for all device channels. + + Called from update() when the device channel list changes. + + Args: + device_channels: Channel list from SharedData snapshot. + """ + if not self._settings_container: + return + self._settings_container.clear() + with self._settings_container: + if not device_channels: + ui.label('No channels received from device yet.').classes( + 'text-xs text-gray-400 italic' + ) + return + for ch in device_channels: + self._render_settings_row(ch) + + def _render_settings_row(self, device_ch: Dict) -> None: + """Render one settings row for a single device channel. + + Args: + device_ch: Channel dict from SharedData (keys: idx, name). + """ + ch_idx = device_ch.get('idx', device_ch.get('index', 0)) + ch_name = device_ch.get('name', f'Ch {ch_idx}') + bbs_cfg = self._config_store.get_channel(ch_idx) + is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False + + with ui.expansion( + f'[{ch_idx}] {ch_name}', + value=False, + ).classes('w-full').props('dense'): + + with ui.column().classes('w-full gap-2 p-2'): + + # Enable / disable toggle + enable_cb = ui.checkbox( + 'Enable BBS on this channel', + value=is_enabled, + ) + + # Categories input + cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES) + cats_input = ui.input( + label='Categories (comma-separated)', + value=cats_val, + ).classes('w-full text-xs') + + # Regions input + regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else '' + regions_input = ui.input( + label='Regions (comma-separated, leave empty for none)', + value=regions_val, + ).classes('w-full text-xs') + + # Retention + ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS) + retention_input = ui.input( + label='Retention (hours)', + value=ret_val, + ).classes('w-full text-xs').style('max-width: 160px') + + # Whitelist + wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else '' + whitelist_input = ui.input( + label='Allowed keys (comma-separated hex, leave empty for all)', + value=wl_val, + ).classes('w-full text-xs') + + # Save button + def _save( + idx=ch_idx, + name=ch_name, + cb=enable_cb, + cats=cats_input, + regs=regions_input, + ret=retention_input, + wl=whitelist_input, + ) -> None: + categories = [ + c.strip().upper() + for c in (cats.value or '').split(',') + if c.strip() + ] + regions = [ + r.strip() + for r in (regs.value or '').split(',') + if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wl.value or '').split(',') + if k.strip() + ] + cfg_entry = { + 'channel': idx, + 'name': name, + 'enabled': cb.value, + 'categories': categories if categories else list(DEFAULT_CATEGORIES), + 'regions': regions, + 'retention_hours': retention_hours, + 'allowed_keys': allowed_keys, + } + self._config_store.set_channel(cfg_entry) + debug_print( + f'BBS settings: saved ch={idx} enabled={cb.value} ' + f'cats={categories} regions={regions}' + ) + ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive') + # Refresh channel buttons and message view + self._refresh_after_settings_save() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + + def _refresh_after_settings_save(self) -> None: + """Rebuild the channel selector buttons after a settings save.""" + enabled = self._config_store.get_enabled_channels() + self._rebuild_channel_buttons(enabled) + # Reset active channel if it was disabled + if self._active_channel_idx is not None: + cfg = self._config_store.get_channel(self._active_channel_idx) + if not cfg or not cfg.get('enabled', False): + self._active_channel_idx = None + if self._msg_list_container: + self._msg_list_container.clear() + with self._msg_list_container: + ui.label('Select a channel above.').classes( + 'text-xs text-gray-400 italic' + ) # ------------------------------------------------------------------ # External update hook (called from dashboard timer) # ------------------------------------------------------------------ def update(self, data: Dict) -> None: - """Called by the dashboard timer. Refreshes if new data arrived. + """Called by the dashboard timer with the SharedData snapshot. - Currently a lightweight no-op: the BBS panel refreshes on user - interaction. Override for real-time auto-refresh if desired. + Rebuilds the settings panel when the device channel list changes. Args: - data: SharedData snapshot (unused; kept for interface consistency). + data: SharedData snapshot dict. """ - # No-op: BBS data is local SQLite, not pushed via SharedData. - # Active refresh only happens on user action or channel switch. + device_channels = data.get('channels', []) + + # Rebuild settings only when the channel list changes + ch_fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels + ) + last_fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels + ) + if ch_fingerprint != last_fingerprint: + self._last_device_channels = device_channels + self._rebuild_settings(device_channels) + # Also rebuild channel buttons (config may have changed) + enabled = self._config_store.get_enabled_channels() + self._rebuild_channel_buttons(enabled) diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py new file mode 100644 index 0000000..1e43842 --- /dev/null +++ b/meshcore_gui/services/bbs_config_store.py @@ -0,0 +1,232 @@ +""" +BBS channel configuration store for MeshCore GUI. + +Persists BBS channel configuration to +``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive +restarts and are managed outside of ``config.py``. + +On first use the file is created with an empty channel list. +The GUI populates it when the user enables BBS on a device channel. + +Thread safety +~~~~~~~~~~~~~ +All methods acquire an internal ``threading.Lock``. +""" + +import json +import threading +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +# --------------------------------------------------------------------------- +# Storage location +# --------------------------------------------------------------------------- + +BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" +BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" + +CONFIG_VERSION: int = 1 + +# --------------------------------------------------------------------------- +# Default values applied when a channel is first enabled +# --------------------------------------------------------------------------- + +DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] +DEFAULT_REGIONS: List[str] = [] +DEFAULT_RETENTION_HOURS: int = 48 + + +class BbsConfigStore: + """Persistent store for BBS channel configuration. + + Args: + config_path: Path to the JSON config file. + Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``. + """ + + def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: + self._path = config_path + self._lock = threading.Lock() + self._data: Dict = {"version": CONFIG_VERSION, "channels": []} + self._load() + + # ------------------------------------------------------------------ + # Load / save + # ------------------------------------------------------------------ + + def _load(self) -> None: + """Load config from disk; create defaults if file is absent.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + + if not self._path.exists(): + self._save_unlocked() + debug_print("BBS config: created new config file") + return + + try: + raw = self._path.read_text(encoding="utf-8") + data = json.loads(raw) + if data.get("version") == CONFIG_VERSION: + self._data = data + debug_print( + f"BBS config: loaded {len(self._data.get('channels', []))} channels" + ) + else: + debug_print( + f"BBS config: version mismatch " + f"(got {data.get('version')}, expected {CONFIG_VERSION}) β€” using defaults" + ) + except (json.JSONDecodeError, OSError) as exc: + debug_print(f"BBS config: load error ({exc}) β€” using defaults") + + def _save_unlocked(self) -> None: + """Write config to disk. MUST be called with self._lock held.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix(".tmp") + tmp.write_text( + json.dumps(self._data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + tmp.replace(self._path) + + def save(self) -> None: + """Flush current configuration to disk.""" + with self._lock: + self._save_unlocked() + + # ------------------------------------------------------------------ + # Channel management + # ------------------------------------------------------------------ + + def get_channels(self) -> List[Dict]: + """Return a copy of all configured channels (enabled and disabled). + + Returns: + List of channel config dicts. + """ + with self._lock: + return [ch.copy() for ch in self._data.get("channels", [])] + + def get_enabled_channels(self) -> List[Dict]: + """Return only channels with ``enabled: true``. + + Returns: + List of enabled channel config dicts. + """ + with self._lock: + return [ + ch.copy() + for ch in self._data.get("channels", []) + if ch.get("enabled", False) + ] + + def get_channel(self, channel_idx: int) -> Optional[Dict]: + """Return config for a single channel index, or ``None``. + + Args: + channel_idx: MeshCore channel index. + + Returns: + Channel config dict copy, or ``None`` if not found. + """ + with self._lock: + for ch in self._data.get("channels", []): + if ch.get("channel") == channel_idx: + return ch.copy() + return None + + def set_channel(self, channel_cfg: Dict) -> None: + """Insert or update a channel configuration entry. + + The channel is identified by the ``channel`` key in *channel_cfg*. + If an entry with the same index exists it is replaced; otherwise + a new entry is appended. + + Args: + channel_cfg: Channel config dict (must contain ``'channel'``). + """ + idx = channel_cfg["channel"] + with self._lock: + channels = self._data.setdefault("channels", []) + for i, ch in enumerate(channels): + if ch.get("channel") == idx: + channels[i] = channel_cfg.copy() + self._save_unlocked() + debug_print(f"BBS config: updated ch={idx}") + return + channels.append(channel_cfg.copy()) + self._save_unlocked() + debug_print(f"BBS config: added ch={idx}") + + def enable_channel( + self, + channel_idx: int, + name: str, + *, + categories: Optional[List[str]] = None, + regions: Optional[List[str]] = None, + retention_hours: int = DEFAULT_RETENTION_HOURS, + allowed_keys: Optional[List[str]] = None, + ) -> None: + """Enable BBS on a device channel, creating a default config if needed. + + If the channel already exists its ``enabled`` flag is set to + ``True`` and other fields are left as-is. Pass explicit keyword + arguments to override any field on a new channel. + + Args: + channel_idx: MeshCore channel index. + name: Human-readable channel name. + categories: Category list (defaults to ``DEFAULT_CATEGORIES``). + regions: Region list (defaults to empty β€” no regions). + retention_hours: Retention in hours (default 48). + allowed_keys: Sender key whitelist (default empty = all allowed). + """ + existing = self.get_channel(channel_idx) + if existing: + existing["enabled"] = True + self.set_channel(existing) + else: + self.set_channel({ + "channel": channel_idx, + "name": name, + "enabled": True, + "categories": categories if categories is not None else list(DEFAULT_CATEGORIES), + "regions": regions if regions is not None else list(DEFAULT_REGIONS), + "retention_hours": retention_hours, + "allowed_keys": allowed_keys if allowed_keys is not None else [], + }) + + def disable_channel(self, channel_idx: int) -> None: + """Set ``enabled: false`` for a channel without removing its config. + + Args: + channel_idx: MeshCore channel index. + """ + existing = self.get_channel(channel_idx) + if existing: + existing["enabled"] = False + self.set_channel(existing) + debug_print(f"BBS config: disabled ch={channel_idx}") + + def update_channel_field( + self, channel_idx: int, field: str, value + ) -> bool: + """Update a single field on an existing channel entry. + + Args: + channel_idx: MeshCore channel index. + field: Field name to update. + value: New value. + + Returns: + ``True`` if the channel was found and updated, ``False`` otherwise. + """ + existing = self.get_channel(channel_idx) + if not existing: + return False + existing[field] = value + self.set_channel(existing) + return True diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 18b2a6a..2286c08 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -2,7 +2,9 @@ Offline Bulletin Board System (BBS) service for MeshCore GUI. Stores BBS messages in a local SQLite database, one table per channel. -Each channel is configured via ``BBS_CHANNELS`` in ``config.py``. +Channel configuration is managed by +:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and +persisted to ``~/.meshcore-gui/bbs/bbs_config.json``. Architecture ~~~~~~~~~~~~ @@ -19,6 +21,7 @@ so it is safe to call from both the GUI thread and the worker thread. Storage location ~~~~~~~~~~~~~~~~ ``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore). """ import sqlite3 @@ -94,6 +97,8 @@ class BbsService: """Create the database directory and schema if not present.""" BBS_DIR.mkdir(parents=True, exist_ok=True) with self._connect() as conn: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=3000") conn.execute(""" CREATE TABLE IF NOT EXISTS bbs_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -182,7 +187,10 @@ class BbsService: Returns: List of ``BbsMessage`` objects, newest first. """ - query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + query = ( + "SELECT id, channel, region, category, sender, sender_key, text, timestamp " + "FROM bbs_messages WHERE channel = ?" + ) params: list = [channel] if region: @@ -199,19 +207,7 @@ class BbsService: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - return [ - BbsMessage( - id=row[0], - channel=row[1], - region=row[2], - category=row[3], - sender=row[4], - sender_key=row[5], - text=row[6], - timestamp=row[7], - ) - for row in rows - ] + return [self._row_to_msg(row) for row in rows] def get_all_messages( self, @@ -229,7 +225,10 @@ class BbsService: Returns: List of ``BbsMessage`` objects, oldest first. """ - query = "SELECT id, channel, region, category, sender, sender_key, text, timestamp FROM bbs_messages WHERE channel = ?" + query = ( + "SELECT id, channel, region, category, sender, sender_key, text, timestamp " + "FROM bbs_messages WHERE channel = ?" + ) params: list = [channel] if region: @@ -245,19 +244,20 @@ class BbsService: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - return [ - BbsMessage( - id=row[0], - channel=row[1], - region=row[2], - category=row[3], - sender=row[4], - sender_key=row[5], - text=row[6], - timestamp=row[7], - ) - for row in rows - ] + return [self._row_to_msg(row) for row in rows] + + @staticmethod + def _row_to_msg(row: tuple) -> BbsMessage: + return BbsMessage( + id=row[0], + channel=row[1], + region=row[2], + category=row[3], + sender=row[4], + sender_key=row[5], + text=row[6], + timestamp=row[7], + ) # ------------------------------------------------------------------ # Retention @@ -295,7 +295,7 @@ class BbsService: """Run retention cleanup for all configured channels. Args: - channels_config: List of channel config dicts from ``BBS_CHANNELS``. + channels_config: List of channel config dicts. """ for cfg in channels_config: self.purge_expired(cfg["channel"], cfg["retention_hours"]) @@ -308,27 +308,28 @@ class BbsService: class BbsCommandHandler: """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - One handler is shared across all configured channels. Channel context - is passed per call so the handler is stateless. + Channel configuration is read live from the supplied + :class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` + so that changes made in the GUI take effect immediately without + restarting the application. Args: - service: Shared ``BbsService`` instance. - channels_config: ``BBS_CHANNELS`` list from ``config.py``. + service: Shared ``BbsService`` instance. + config_store: ``BbsConfigStore`` instance for live channel config. """ - # Maximum messages returned per !bbs read call READ_LIMIT: int = 5 - def __init__( - self, - service: BbsService, - channels_config: List[Dict], - ) -> None: + def __init__(self, service: BbsService, config_store) -> None: self._service = service - # Index by channel number for O(1) lookup - self._channels: Dict[int, Dict] = { - cfg["channel"]: cfg for cfg in channels_config - } + self._config_store = config_store + + def _get_cfg(self, channel_idx: int) -> Optional[Dict]: + """Return enabled channel config, or ``None``.""" + cfg = self._config_store.get_channel(channel_idx) + if cfg and cfg.get("enabled", False): + return cfg + return None # ------------------------------------------------------------------ # Public entry point @@ -343,9 +344,6 @@ class BbsCommandHandler: ) -> Optional[str]: """Parse an incoming message and return a reply string (or ``None``). - Returns ``None`` when the message is not a BBS command, the channel - is not configured, or the sender fails the whitelist check. - Args: channel_idx: MeshCore channel index the message arrived on. sender: Display name of the sender. @@ -359,9 +357,9 @@ class BbsCommandHandler: if not text.lower().startswith("!bbs"): return None - cfg = self._channels.get(channel_idx) + cfg = self._get_cfg(channel_idx) if cfg is None: - return None # Channel not configured β€” ignore + return None # Whitelist check allowed = cfg.get("allowed_keys", []) @@ -370,35 +368,17 @@ class BbsCommandHandler: f"BBS: silently dropping msg from {sender} " f"(key not in whitelist for ch={channel_idx})" ) - return None # Silent drop β€” no error reply + return None parts = text.split(None, 1) args = parts[1].strip() if len(parts) > 1 else "" - return self._dispatch(cfg, sender, sender_key, args) # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ - def _dispatch( - self, - cfg: Dict, - sender: str, - sender_key: str, - args: str, - ) -> str: - """Route to the appropriate sub-command handler. - - Args: - cfg: Channel configuration dict. - sender: Display name of the sender. - sender_key: Public key of the sender. - args: Everything after ``!bbs ``. - - Returns: - Reply string (always non-empty). - """ + def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: sub = args.split(None, 1)[0].lower() if args else "" rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" @@ -408,42 +388,18 @@ class BbsCommandHandler: return self._handle_read(cfg, rest) if sub == "help" or not sub: return self._handle_help(cfg) - return f"Unknown command '{sub}'. {self._handle_help(cfg)}" # ------------------------------------------------------------------ # Sub-command: post # ------------------------------------------------------------------ - def _handle_post( - self, - cfg: Dict, - sender: str, - sender_key: str, - args: str, - ) -> str: - """Handle ``!bbs post [region] [category] [text]``. - - When the channel has regions, the first token is the region, - the second is the category, and the remainder is the text. - Without regions, the first token is the category and the - remainder is the text. - - Args: - cfg: Channel configuration dict. - sender: Display name of the sender. - sender_key: Public key of the sender. - args: Everything after ``!bbs post ``. - - Returns: - Confirmation or error message string. - """ + def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] tokens = args.split(None, 2) if args else [] if regions: - # Syntax: !bbs post [region] [category] [text] if len(tokens) < 3: return ( f"Usage: !bbs post [region] [category] [text] | " @@ -451,27 +407,17 @@ class BbsCommandHandler: f"Categories: {', '.join(categories)}" ) region, category, text = tokens[0], tokens[1], tokens[2] - region_upper = region.upper() valid_regions = [r.upper() for r in regions] if region_upper not in valid_regions: - return ( - f"Invalid region '{region}'. " - f"Valid: {', '.join(regions)}" - ) - # Normalise to configured casing + return f"Invalid region '{region}'. Valid: {', '.join(regions)}" region = regions[valid_regions.index(region_upper)] - category_upper = category.upper() valid_cats = [c.upper() for c in categories] if category_upper not in valid_cats: - return ( - f"Invalid category '{category}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" category = categories[valid_cats.index(category_upper)] else: - # Syntax: !bbs post [category] [text] if len(tokens) < 2: return ( f"Usage: !bbs post [category] [text] | " @@ -479,14 +425,10 @@ class BbsCommandHandler: ) region = "" category, text = tokens[0], tokens[1] - category_upper = category.upper() valid_cats = [c.upper() for c in categories] if category_upper not in valid_cats: - return ( - f"Invalid category '{category}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" category = categories[valid_cats.index(category_upper)] msg = BbsMessage( @@ -506,19 +448,6 @@ class BbsCommandHandler: # ------------------------------------------------------------------ def _handle_read(self, cfg: Dict, args: str) -> str: - """Handle ``!bbs read [region] [category]``. - - With regions: ``!bbs read`` / ``!bbs read [region]`` / - ``!bbs read [region] [category]`` - Without regions: ``!bbs read`` / ``!bbs read [category]`` - - Args: - cfg: Channel configuration dict. - args: Everything after ``!bbs read ``. - - Returns: - Formatted message list or error string. - """ regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] tokens = args.split() if args else [] @@ -529,7 +458,6 @@ class BbsCommandHandler: if regions: valid_regions_upper = [r.upper() for r in regions] valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: tok0 = tokens[0].upper() if tok0 in valid_regions_upper: @@ -539,15 +467,9 @@ class BbsCommandHandler: if tok1 in valid_cats_upper: category = categories[valid_cats_upper.index(tok1)] else: - return ( - f"Invalid category '{tokens[1]}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" else: - return ( - f"Invalid region '{tokens[0]}'. " - f"Valid: {', '.join(regions)}" - ) + return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" else: valid_cats_upper = [c.upper() for c in categories] if len(tokens) >= 1: @@ -555,14 +477,10 @@ class BbsCommandHandler: if tok0 in valid_cats_upper: category = categories[valid_cats_upper.index(tok0)] else: - return ( - f"Invalid category '{tokens[0]}'. " - f"Valid: {', '.join(categories)}" - ) + return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" messages = self._service.get_messages( - cfg["channel"], region=region, category=category, - limit=self.READ_LIMIT, + cfg["channel"], region=region, category=category, limit=self.READ_LIMIT, ) if not messages: @@ -570,11 +488,9 @@ class BbsCommandHandler: lines = [] for m in messages: - ts = m.timestamp[:16].replace("T", " ") # YYYY-MM-DD HH:MM + ts = m.timestamp[:16].replace("T", " ") region_label = f"[{m.region}] " if m.region else "" - lines.append( - f"{ts} {m.sender} [{m.category}] {region_label}{m.text}" - ) + lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}") return "\n".join(lines) # ------------------------------------------------------------------ @@ -582,18 +498,9 @@ class BbsCommandHandler: # ------------------------------------------------------------------ def _handle_help(self, cfg: Dict) -> str: - """Return a compact command reference for this channel. - - Args: - cfg: Channel configuration dict. - - Returns: - Help string (single line). - """ regions: List[str] = cfg.get("regions", []) categories: List[str] = cfg["categories"] name = cfg.get("name", f"ch{cfg['channel']}") - if regions: return ( f"BBS [{name}] | " @@ -608,3 +515,4 @@ class BbsCommandHandler: f"!bbs read [cat] | " f"Categories: {', '.join(categories)}" ) + From 430628c945fc10ba45b5c9efb7d9e2f05921cd97 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:37:10 +0100 Subject: [PATCH 03/14] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- meshcore_gui/CHANGELOG.md | 922 ++++++++++++++++++ meshcore_gui/meshcore_gui/config.py | 395 ++++++++ meshcore_gui/meshcore_gui/gui/dashboard.py | 850 ++++++++++++++++ .../meshcore_gui/gui/panels/__init__.py | 18 + .../meshcore_gui/gui/panels/bbs_panel.py | 535 ++++++++++ .../meshcore_gui/services/bbs_config_store.py | 302 ++++++ .../meshcore_gui/services/bbs_service.py | 468 +++++++++ meshcore_gui/meshcore_gui/services/bot.py | 221 +++++ 8 files changed, 3711 insertions(+) create mode 100644 meshcore_gui/CHANGELOG.md create mode 100644 meshcore_gui/meshcore_gui/config.py create mode 100644 meshcore_gui/meshcore_gui/gui/dashboard.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/__init__.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_config_store.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_service.py create mode 100644 meshcore_gui/meshcore_gui/services/bot.py diff --git a/meshcore_gui/CHANGELOG.md b/meshcore_gui/CHANGELOG.md new file mode 100644 index 0000000..6df97ad --- /dev/null +++ b/meshcore_gui/CHANGELOG.md @@ -0,0 +1,922 @@ + + +# CHANGELOG + + + +All notable changes to MeshCore GUI are documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). + + +--- + +> **πŸ“ˆ Performance note β€” v1.13.1 through v1.13.4** +> Although versions 1.13.1–1.13.4 were released as targeted bugfix releases, the +> cumulative effect of the fixes delivered a significant performance improvement: +> +> - **v1.13.1** β€” Bot non-response fix eliminated a silent failure path that caused +> repeated dedup-marked command re-evaluation on every message tick. +> - **v1.13.2** β€” Map display fixes prevented Leaflet from being initialized on hidden +> zero-size containers, removing a source of repeated failed bootstrap retries and +> associated DOM churn. +> - **v1.13.3** β€” Active panel timer gating reduced the 500 ms dashboard update work to +> only the currently visible panel, cutting unnecessary UI updates and background +> redraw load substantially β€” especially noticeable over VPN or on slower hardware. +> - **v1.13.4** β€” Room Server event classification fix and sender name resolution removed +> redundant fallback processing paths and reduced per-tick contact lookup overhead. +> +> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching, +> lower CPU usage during idle operation, and more stable map rendering. + +--- +## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) + +### Added +- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`. +- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`. +- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. + - Board-selector (knoppen per geconfigureerd board). + - Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft). + - Scrollbare berichtenlijst over alle channels van het actieve board. + - Post-formulier: post op het eerste channel van het board. + - **Settings-sectie**: boards aanmaken (naam β†’ Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieΓ«n, regio's, retentie, whitelist, Save en Delete. + +### Changed +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd; versie `1.14.0`. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `πŸ“‹ BBS` drawer-item. +- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +### Storage +``` +~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) +~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +``` + +### Not changed +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. + +--- + +## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes + +### Fixed +- πŸ›  **Route page back-button navigated to main menu regardless of origin** β€” the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page. +- πŸ›  **Map marker popup flickered on every 500 ms update tick** β€” the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible. +- πŸ›  **Map marker popup appeared with a flicker/flash on first click (main map and route map)** β€” Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts. + +### Changed +- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`. +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” `applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`. +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.5`. + +### Impact +- Back navigation from the route page now always returns to the correct origin screen. +- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed. +- Popup opening is instant on both maps; no animation artefacts on low-power hardware. + +--- +## [1.13.4] - 2026-03-12 β€” Room Server message classification fix + +### Fixed +- πŸ›  **Incoming room messages from other participants could be misclassified as normal DMs** β€” `CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`. +- πŸ›  **Incoming room traffic could be attached to the wrong key** β€” room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`. +- πŸ›  **Room login UI could stay out of sync with the actual server-confirmed state** β€” `LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key. +- πŸ›  **Room Server panel showed hex codes instead of sender names** β€” when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known. + +### Changed +- πŸ”„ `meshcore_gui/ble/events.py` β€” Broadened room payload parsing and added payload-key debug logging for incoming room traffic. +- πŸ”„ `meshcore_gui/ble/worker.py` β€” `LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history. +- πŸ”„ `meshcore_gui/config.py` β€” Version kept at `1.13.4`. + +### Impact +- Keeps the existing Room Server panel logic intact. +- Fix is limited to room event classification and room login confirmation handling. +- No intended behavioural change for ordinary DMs or channel messages. + +--- +--- +## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating + +### Changed +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick +- πŸ”„ `meshcore_gui/gui/panels/map_panel.py` β€” Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.3` + +### Fixed +- πŸ›  **Hidden panels still refreshed every 500 ms** β€” Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active +- πŸ›  **Map bootstrap activity while panel is not visible** β€” Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic +- πŸ›  **Slow navigation over VPN** β€” Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel + +### Impact +- Faster panel switching because the selected panel gets one direct refresh immediately +- Lower background UI/update load on dashboard level, especially when the map panel is not active +- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage +- No intended functional regression for route maps or visible panel behaviour + +--- +## [1.13.2] - 2026-03-11 β€” Map Display Bugfix + +### Fixed +- πŸ›  **MAP panel blank when contacts list is empty at startup** β€” dashboard update loop + had two separate conditional map-update blocks that both silently stopped firing after + tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and + remained blank indefinitely. +- πŸ›  **Leaflet map initialized on hidden (zero-size) container** β€” `processPending` in + the browser runtime called `L.map()` on the host element while it was still + `display:none` (Vue v-show, panel not yet visible). This produced a broken 0Γ—0 map + that never recovered because `ensureMap` returned the cached broken state on all + subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`: + initialization is deferred until the host has real dimensions. +- πŸ›  **Route map container had no height** β€” `route_page.py` used the Tailwind class + `h-96` for the Leaflet host `
`. NiceGUI/Quasar does not include Tailwind CSS, + so `h-96` had no effect and the container rendered at height 0. Leaflet initialized + on a zero-height element and produced a blank map. +- πŸ›  **Route map not rendered when no node has GPS coordinates** β€” `_render_map` + returned early before creating the Leaflet container when `payload['nodes']` was + empty. Fixed: container is always created; a notice label is shown instead. + +### Changed +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” Added size guard in `ensureMap`: + returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map + state exists yet. `processPending` retries on the next tick once the panel is visible. +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” Consolidated two conditional map-update blocks + into a single unconditional update while the MAP panel is active. Added `h-96` to the + DOMCA CSS height overrides for consistency with the route page map container. +- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Replaced `h-96` Tailwind class on the route + map host `
` with an explicit inline `style` (height: 24rem). Removed early + `return` guard so the Leaflet container is always created. + +### Impact +- MAP panel now renders reliably on first open regardless of contact/GPS availability +- Route map now always shows with correct height even when route nodes have no GPS +- No breaking changes outside the three files listed above + +--- +## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization + +### Added +- βœ… `meshcore_gui/static/leaflet_map_panel.js` β€” Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry, clustering and theme handling independent from NiceGUI redraw cycles +- βœ… `meshcore_gui/static/leaflet_map_panel.css` β€” Styling for browser-side node markers, cluster icons and map container +- βœ… `meshcore_gui/services/map_snapshot_service.py` β€” Snapshot service that normalizes device/contact map data into a compact payload for the browser runtime +- βœ… Browser-side map state management for center, zoom and theme +- βœ… Theme persistence across reconnect events via browser storage fallback +- βœ… Browser-side contact clustering via `Leaflet.markercluster` +- βœ… Separate non-clustered device marker layer so the own device remains individually visible + +### Changed +- πŸ”„ `meshcore_gui/gui/panels/map_panel.py` β€” Replaced NiceGUI Leaflet wrapper usage with a pure browser-managed Leaflet container while preserving the existing card layout, theme toggle and center-on-device control +- πŸ”„ Leaflet bootstrap moved out of inline Python into a dedicated browser runtime loaded from `/static` +- πŸ”„ Asset loading order is now explicit: Leaflet first, then `Leaflet.markercluster`, then the MeshCore panel runtime +- πŸ”„ Map initialization now occurs only once per container; NiceGUI refresh cycles no longer recreate the map +- πŸ”„ Dashboard update loop now sends compact map snapshots instead of triggering redraws +- πŸ”„ Snapshot processing in the browser is coalesced so only the newest payload is applied +- πŸ”„ Map markers are managed in separate device/contact layers and updated incrementally by stable node id +- πŸ”„ Contact markers are rendered inside a persistent cluster layer while the device marker remains outside clustering +- πŸ”„ Theme switching moved to a dedicated theme channel instead of being embedded in snapshot data + +### Fixed +- πŸ›  **Map disappearing during dashboard refresh cycles** β€” prevented repeated map reinitialization caused by the 500 ms NiceGUI update loop +- πŸ›  **Markers disappearing between refreshes** β€” marker updates are now incremental and keyed by node id +- πŸ›  **Blank map container on load** β€” browser bootstrap now waits for DOM host, Leaflet runtime and panel runtime before initialization +- πŸ›  **Leaflet clustering bootstrap failure (`L is not defined`)** β€” resolved by enforcing correct script dependency order before the panel runtime starts +- πŸ›  **MarkerClusterGroup failure (`Map has no maxZoom specified`)** β€” the map now defines `maxZoom` during initial creation before the cluster layer is attached +- πŸ›  **Half-initialized map retry cascade (`Map container is already initialized`)** β€” map state is now registered safely during initialization so a failed attempt cannot trigger a second `L.map(...)` on the same container +- πŸ›  **Race condition between queued snapshot and theme selection** β€” explicit theme changes can no longer be overwritten by stale snapshot payloads +- πŸ›  **Viewport jumping back to default center/zoom** β€” stored viewport is no longer reapplied on each snapshot update +- πŸ›  **Theme reverting to default during reconnect** β€” effective map theme is restored before snapshot processing resumes + +### Impact +- Leaflet map is now managed entirely in the browser and is no longer recreated on each dashboard refresh +- Node markers remain stable and no longer flicker or disappear during the 500 ms update cycle +- Dense contact sets can now be rendered with clustering without violating the browser-owned map lifecycle +- Theme switching and viewport state persist reliably across reconnect events +- No breaking changes outside the map subsystem +--- +## [1.12.1] - 2026-03-08 β€” Minor change bot +### Changed +- πŸ”„ `meshcore_gui/services/bot.py`: remove path id's +### Impact +- No breaking changes β€” all existing functionality preserved serial. + +--- + +## [1.12.0] - 2026-02-26 β€” MeshCore Observer Fase 1 + +### Added +- βœ… **MeshCore Observer daemon** β€” New standalone read-only daemon (`meshcore_observer.py`) that reads archive JSON files produced by meshcore_gui and meshcore_bridge, aggregates them, and presents a unified NiceGUI monitoring dashboard on port 9093. +- βœ… **ArchiveWatcher** β€” Core component that polls `~/.meshcore-gui/archive/` for `*_messages.json` and `*_rxlog.json` files, tracks mtime changes, and returns only new entries since previous poll. Thread-safe, zero writes, graceful on corrupt JSON. +- βœ… **Observer dashboard panels** β€” Sources overview, aggregated messages feed (sorted by timestamp), aggregated RX log table, and statistics panel with uptime/counters/per-source breakdown. Full DOMCA theme (dark + light mode). +- βœ… **Source filter** β€” Dropdown to filter messages and RX log by archive source. +- βœ… **Channel filter** β€” Dropdown to filter messages by channel name. +- βœ… **ObserverConfig** β€” YAML-based configuration with `from_yaml()` classmethod, defaults work without config file. +- βœ… **observer_config.yaml** β€” Documented config template with all options. +- βœ… **install_observer.sh** β€” systemd installer (`/opt/meshcore-observer/`, `/etc/meshcore/observer_config.yaml`), with `--uninstall` option. +- βœ… **RxLogEntry raw packet fields** β€” 5 new fields on `RxLogEntry` dataclass: `raw_payload`, `packet_len`, `payload_len`, `route_type`, `packet_type_num` (all with defaults, backward compatible). +- βœ… **EventHandler.on_rx_log() metadata** β€” Raw payload hex and packet metadata now passed through to RxLogEntry and archived (preparation for Fase 2 LetsMesh uplink). + +### Changed +- πŸ”„ `meshcore_gui/core/models.py`: RxLogEntry +5 fields with defaults (backward compatible). +- πŸ”„ `meshcore_gui/ble/events.py`: on_rx_log() fills raw_payload and metadata (~10 lines added). +- πŸ”„ `meshcore_gui/services/message_archive.py`: add_rx_log() serializes the 5 new RxLogEntry fields. +- πŸ”„ `meshcore_gui/config.py`: Version bumped to `1.12.0`. + +### Impact +- **No breaking changes** β€” All new RxLogEntry fields have defaults; existing archives and code work identically. +- **New daemon** β€” meshcore_observer is fully standalone; no imports from meshcore_gui (reads only JSON files). + +--- + +### Added +- βœ… **Serial CLI flags** β€” `--baud=BAUD` and `--serial-cx-dly=SECONDS` for serial configuration at startup. + +### Changed +- πŸ”„ **Connection layer** β€” Switched from BLE to serial (`MeshCore.create_serial`) with serial reconnect handling. +- πŸ”„ `config.py`: Added `SERIAL_BAUDRATE`, `SERIAL_CX_DELAY`, `DEFAULT_TIMEOUT`, `MESHCORE_LIB_DEBUG`; removed BLE PIN settings; version bumped to `1.10.0`. +- πŸ”„ `meshcore_gui.py` / `meshcore_gui/__main__.py`: Updated usage, banners and defaults for serial ports. +- πŸ”„ Docs: Updated README and core docs for serial usage; BLE documents marked as legacy. + +### Impact +- No breaking changes β€” all existing functionality preserved serial. + +--- + +## [1.9.11] - 2026-02-19 β€” Message Dedup Hotfix + +### Fixed +- πŸ›  **Duplicate messages after (re)connect** β€” `load_recent_from_archive()` appended archived messages on every connect attempt without clearing existing entries; after N failed connects, each message appeared N times. Method is now idempotent: clears the in-memory list before loading. +- πŸ›  **Persistent duplicate messages** β€” Live BLE events for messages already loaded from archive were not suppressed because the `DualDeduplicator` was never seeded with archived content. Added `_seed_dedup_from_messages()` in `BLEWorker` after cache/archive load and after reconnect. +- πŸ›  **Last-line-of-defence dedup in SharedData** β€” `add_message()` now maintains a fingerprint set (`message_hash` or `channel:sender:text`) and silently skips messages whose fingerprint is already tracked. This guards against duplicates regardless of their source. +- πŸ›  **Messages panel empty on first click** β€” `_show_panel()` made the container visible but relied on the next 500 ms timer tick to populate it. Added an immediate `_messages.update()` call so content is rendered the moment the panel becomes visible. + +### Changed +- πŸ”„ `core/shared_data.py`: Added `_message_fingerprints` set and `_message_fingerprint()` static method; `add_message()` checks fingerprint before insert and evicts fingerprints when messages are rotated out; `load_recent_from_archive()` clears messages and fingerprints before loading (idempotent) +- πŸ”„ `ble/worker.py`: Added `_seed_dedup_from_messages()` helper; called after `_apply_cache()` and after reconnect `_load_data()` to seed `DualDeduplicator` with existing messages +- πŸ”„ `gui/dashboard.py`: `_show_panel()` now forces an immediate `_messages.update()` when the messages panel is shown, eliminating the stale-content flash +- πŸ”„ `config.py`: Version bumped to `1.9.11` + +### Impact +- Eliminates all duplicate message display scenarios: initial connect, failed retries, reconnect, and BLE event replay +- No breaking changes β€” all existing functionality preserved +- Fingerprint set is bounded to the same 100-message cap as the message list + +--- + +## [1.9.10] - 2026-02-19 β€” Map Tooltips & Separate Own-Position Marker + +### Added +- βœ… **Map marker tooltips** β€” All markers on the Leaflet map now show a tooltip on hover with the node name and type icon (πŸ“±, πŸ“‘, 🏠) from `TYPE_ICONS` +- βœ… **Separate own-position marker** β€” The device's own position is now tracked as a dedicated `_own_marker`, independent from contact markers. This prevents the own marker from being removed/recreated on every contact update cycle + +### Changed +- πŸ”„ `gui/panels/map_panel.py`: Renamed `_markers` to `_contacts_markers`; added `_own_marker` attribute; own position marker is only updated when `device_updated` flag is set (not every timer tick); contact markers are only rebuilt when `contacts_updated` is set; added `TYPE_ICONS` import for tooltip icons +- πŸ”„ `gui/dashboard.py`: Added `self._map.update(data)` call in the `device_updated` block so the own-position marker updates when device info changes (e.g. GPS position update) +- πŸ”„ `config.py`: Version bumped to `1.9.10` + +### Impact +- Map centering on own device now works correctly and updates only when position actually changes +- Contact markers are no longer needlessly destroyed and recreated on every UI timer tick β€” only on actual contact data changes +- Tooltips make it easy to identify nodes on the map without clicking +- No breaking changes β€” all existing map functionality preserved + +### Credits +- Based on [PR #16](https://github.com/pe1hvh/meshcore-gui/pull/16) by [@rich257](https://github.com/rich257) + +--- + +## [1.9.9] - 2026-02-18 β€” Variable Landing Page & Operator Callsign + +### Added +- βœ… **Configurable operator callsign** β€” New `OPERATOR_CALLSIGN` constant in `config.py` (default: `"PE1HVH"`). Used in the landing page SVG and the drawer footer copyright label. Change this single value to personalize the entire GUI for a different operator +- βœ… **External landing page SVG** β€” The DOMCA splash screen is now loaded from a standalone file (`static/landing_default.svg`) instead of being hardcoded in `dashboard.py`. New `LANDING_SVG_PATH` constant in `config.py` points to the SVG file. The placeholder `{callsign}` in the SVG is replaced at runtime with `OPERATOR_CALLSIGN` +- βœ… **Landing page customization** β€” To use a custom landing page: copy `landing_default.svg` (or create your own SVG), use `{callsign}` wherever the operator callsign should appear, and point `LANDING_SVG_PATH` to your file. The default SVG includes an instructive comment block explaining the placeholder mechanism + +### Changed +- πŸ”„ `config.py`: Added `OPERATOR_CALLSIGN` and `LANDING_SVG_PATH` constants in new **OPERATOR / LANDING PAGE** section; version bumped to `1.9.9` +- πŸ”„ `gui/dashboard.py`: Removed hardcoded `_DOMCA_SVG` string (~70 lines); added `_load_landing_svg()` helper that reads SVG from disk and replaces `{callsign}` placeholder; CSS variable `--pe1hvh` renamed to `--callsign`; drawer footer copyright label now uses `config.OPERATOR_CALLSIGN` + +### Added (files) +- βœ… `static/landing_default.svg` β€” The original DOMCA splash SVG extracted as a standalone file, with `{callsign}` placeholder and `--callsign` CSS variable. Serves as both the default landing page and a reference template for custom SVGs + +### Impact +- Out-of-the-box behavior is identical to v1.9.8 (same DOMCA branding, same PE1HVH callsign) +- Operators personalize by changing 1–2 lines in `config.py` β€” no code modifications needed +- Fallback: if the SVG file is missing, a minimal placeholder text is shown instead of a crash +- No breaking changes β€” all existing dashboard functionality (panels, menus, timer, theming) unchanged + +--- + +## [1.9.8] - 2026-02-17 β€” Bugfix: Route Page Sender ID, Type & Location Not Populated + +### Fixed +- πŸ›  **Sender ID, Type and Location empty in Route Page** β€” After the v4.1 refactoring to `RouteBuilder`/`RouteNode`, the sender contact lookup relied solely on `SharedData.get_contact_by_prefix()` (live lock-based) and `get_contact_by_name()`. When both failed (e.g. empty `sender_pubkey` from RX_LOG decode, or name mismatch), `route['sender']` remained `None` and the route table fell through to a hardcoded fallback with `type: '-'`, `location: '-'`. The contact data was available in the snapshot `data['contacts']` but was never searched +- πŸ›  **Route table fallback row ignored available contact data** β€” When `route['sender']` was `None`, the `_render_route_table` method used a static fallback row without attempting to find the contact in the data snapshot. Even when the contact was present in `data['contacts']` with valid type and location, these fields showed as `'-'` + +### Changed +- πŸ”„ `services/route_builder.py`: Added two additional fallback strategies in `build()` after the existing SharedData lookups: (3) bidirectional pubkey prefix match against `data['contacts']` snapshot, (4) case-insensitive `adv_name` match against `data['contacts']` snapshot. Added helper methods `_find_contact_by_pubkey()` and `_find_contact_by_adv_name()` for snapshot-based lookups +- πŸ”„ `gui/route_page.py`: Added defensive fallback in `_render_route_table()` sender section β€” when `route['sender']` is `None`, attempts to find the contact in the snapshot via `_find_sender_contact()` before falling back to the static `'-'` row. Added `_find_sender_contact()` helper method + +### Impact +- Sender ID (hash), Type and Location are now populated correctly in the route table when the contact is known +- Four-layer lookup chain ensures maximum resolution: (1) SharedData pubkey lookup, (2) SharedData name lookup, (3) snapshot pubkey lookup, (4) snapshot name lookup +- Defensive fallback in route_page guarantees data is shown even if RouteBuilder misses it +- No breaking changes β€” all existing route page behavior, styling and data flows unchanged + +--- + +## [1.9.7] - 2026-02-17 β€” Layout Fix: Archive Filter Toggle & Route Page Styling + +### Changed +- πŸ”„ `gui/archive_page.py`: Archive filter card now hidden by default; toggle visibility via a `filter_list` icon button placed right-aligned on the same row as the "πŸ“š Archive" title. Header restructured from single label to `ui.row()` with `justify-between` layout +- πŸ”„ `gui/route_page.py`: Route page now uses DOMCA theme (imported from `dashboard.py`) with dark mode as default, consistent with the main dashboard. Header restyled from `bg-blue-600` to Quasar-themed header with JetBrains Mono font. Content container changed from `w-full max-w-4xl mx-auto` to `domca-panel` class for consistent responsive sizing +- πŸ”„ `gui/dashboard.py`: Added `domca-header-text` CSS class with `@media (max-width: 599px)` rule to hide header text on narrow viewports; applied to version label and status label +- πŸ”„ `gui/route_page.py`: Header label also uses `domca-header-text` class for consistent responsive behaviour + +### Added +- βœ… **Archive filter toggle** β€” `filter_list` icon button in archive header row toggles the filter card visibility on click +- βœ… **Route page close button** β€” `X` (close) icon button added right-aligned in the route page header; calls `window.close()` to close the browser tab +- βœ… **Responsive header** β€” On viewports < 600px, header text labels are hidden; only icon buttons (menu, dark mode toggle, close) remain visible + +### Impact +- Archive page is cleaner by default β€” filters only shown when needed +- Route page visually consistent with the main dashboard (DOMCA theme, dark mode, responsive panel width) +- Headers degrade gracefully on mobile (< 600px): only icon buttons visible, no text overflow +- No functional changes β€” all event handlers, callbacks, data bindings, logic and imports are identical to the input + +--- + +## [1.9.6] - 2026-02-17 β€” Bugfix: Channel Discovery Reliability + +### Fixed +- πŸ›  **Channels not appearing (especially on mobile)** β€” Channel discovery aborted too early on slow BLE connections. The `_discover_channels()` probe used a single attempt per channel slot and stopped after just 2 consecutive empty responses. On mobile BLE stacks (WebBluetooth via NiceGUI) where GATT responses are slower, this caused discovery to abort before finding any channels, falling back to only `[0] Public` +- πŸ›  **Race condition: channel update flag lost between threads** β€” `get_snapshot()` and `clear_update_flags()` were two separate calls, each acquiring the lock independently. If the BLE worker set `channels_updated = True` between these two calls, the GUI consumed the flag via `get_snapshot()` but then `clear_update_flags()` reset it β€” causing the channel submenu and dropdown to never populate +- πŸ›  **Channels disappear on browser reconnect** β€” When a browser tab is closed and reopened, `render()` creates new (empty) NiceGUI containers for the drawer submenus, but did not reset `_last_channel_fingerprint`. The `_update_submenus()` method compared the new fingerprint against the stale one, found them equal, and skipped the rebuild β€” leaving the new containers permanently empty. Fixed by resetting both `_last_channel_fingerprint` and `_last_rooms_fingerprint` in `render()` + +### Changed +- πŸ”„ `core/shared_data.py`: New atomic method `get_snapshot_and_clear_flags()` that reads the snapshot and resets all update flags in a single lock acquisition. Internally refactored to `_build_snapshot_unlocked()` helper. Existing `get_snapshot()` and `clear_update_flags()` retained for backward compatibility +- πŸ”„ `ble/worker.py`: `_discover_channels()` β€” `max_attempts` increased from 1 to 2 per channel slot; inter-attempt `delay` increased from 0.5s to 1.0s; consecutive error threshold raised from 2 to 3; inter-channel pause increased from 0.15s to 0.3s for mobile BLE stack breathing room +- πŸ”„ `gui/dashboard.py`: `_update_ui()` now uses `get_snapshot_and_clear_flags()` instead of separate `get_snapshot()` + `clear_update_flags()`; `render()` now resets `_last_channel_fingerprint` and `_last_rooms_fingerprint` to `None` so that `_update_submenus()` rebuilds into the freshly created containers; channel-dependent updates (`update_filters`, `update_channel_options`, `_update_submenus`) now run unconditionally when channel data exists β€” safe because each method has internal idempotency checks +- πŸ”„ `gui/panels/messages_panel.py`: `update_channel_options()` now includes an equality check on options dict to skip redundant `.update()` calls to the NiceGUI client on every 500ms timer tick + +### Impact +- Channel discovery now survives transient BLE timeouts that are common on mobile connections +- Atomic snapshot eliminates the threading race condition that caused channels to silently never appear +- Browser close+reopen no longer loses channels β€” the single-instance timer race on the shared `DashboardPage` is fully mitigated +- No breaking changes β€” all existing API methods retained, all other functionality unchanged + +--- + +## [1.9.5] - 2026-02-16 β€” Layout Fix: RX Log Table Responsive Sizing + +### Fixed +- πŸ›  **RX Log table did not adapt to panel/card size** β€” The table used `max-h-48` (a maximum height cap) instead of a responsive fixed height, causing it to remain small regardless of available space. Changed to `h-40` which is overridden by the existing dashboard CSS to `calc(100vh - 20rem)` β€” the same responsive pattern used by the Messages panel +- πŸ›  **RX Log table did not fill card width** β€” Added `w-full` class to the table element so it stretches to the full width of the parent card +- πŸ›  **RX Log card did not fill panel height** β€” Added `flex-grow` class to the card container so it expands to fill the available panel space + +### Changed +- πŸ”„ `gui/panels/rxlog_panel.py`: Card classes `'w-full'` β†’ `'w-full flex-grow'` (line 45); table classes `'text-xs max-h-48 overflow-y-auto'` β†’ `'w-full text-xs h-40 overflow-y-auto'` (line 65) + +### Impact +- RX Log table now fills the panel consistently on both desktop and mobile viewports +- Layout is consistent with other panels (Messages, Contacts) that use the same `h-40` responsive height pattern +- No functional changes β€” all event handlers, callbacks, data bindings, logica and imports are identical to the input + +--- + +## [1.9.4] - 2026-02-16 β€” BLE Address Log Prefix & Entry Point Cleanup + +### Added +- βœ… **BLE address prefix in log filename** β€” Log file is now named `_meshcore_gui.log` (e.g. `AA_BB_CC_DD_EE_FF_meshcore_gui.log`) instead of the generic `meshcore_gui.log`. Makes it easy to identify which device produced which log file when running multiple instances + - New helper `_sanitize_ble_address()` strips `literal:` prefix and replaces colons with underscores + - New function `configure_log_file(ble_address)` updates `LOG_FILE` at runtime before the logger is initialised + - Rotated backups follow the same naming pattern automatically + +### Removed +- ❌ **`meshcore_gui/meshcore_gui.py`** β€” Redundant copy of `main()` that was never imported. All three entry points (`meshcore_gui.py` root, `__main__.py`, and `meshcore_gui/meshcore_gui.py`) contained near-identical copies of the same logic, causing changes to be missed (as demonstrated by this fix). `__main__.py` is now the single source of truth; root `meshcore_gui.py` is a thin wrapper that imports from it + +### Changed +- πŸ”„ `config.py`: Added `_sanitize_ble_address()` and `configure_log_file()`; version bumped to `1.9.4` +- πŸ”„ `__main__.py`: Added `config.configure_log_file(ble_address)` call before any debug output +- πŸ”„ `meshcore_gui.py` (root): Reduced to 4-line wrapper importing `main` from `__main__` + +### Impact +- Log files are now identifiable per BLE device +- Single source of truth for `main()` eliminates future sync issues between entry points +- Both startup methods (`python meshcore_gui.py` and `python -m meshcore_gui`) remain functional +- No breaking changes β€” defaults and all existing behaviour unchanged +--- + +## [1.9.3] - 2026-02-16 β€” Bugfix: Map Default Location & Payload Type Decoding + +### Fixed +- πŸ›  **Map centred on hardcoded Zwolle instead of device location** β€” All Leaflet maps used magic-number coordinates `(52.5, 6.0)` as initial centre and fallback. These are now replaced by a single configurable constant `DEFAULT_MAP_CENTER` in `config.py`. Once the device reports a valid `adv_lat`/`adv_lon`, maps re-centre on the actual device position (existing behaviour, unchanged) +- πŸ›  **Payload type shown as raw integer** β€” Payload type is now retrieved from the decoded payload and translated to human-readable text using MeshCoreDecoder functions, instead of displaying the raw numeric type value + +### Changed +- πŸ”„ `config.py`: Added `DEFAULT_MAP_CENTER` (default: `(52.5168, 6.0830)`) and `DEFAULT_MAP_ZOOM` (default: `9`) constants in new **MAP DEFAULTS** section. Version bumped to `1.9.2` +- πŸ”„ `gui/panels/map_panel.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; `ui.leaflet(center=...)` uses config constants instead of hardcoded values +- πŸ”„ `gui/route_page.py`: Imports `DEFAULT_MAP_CENTER` and `DEFAULT_MAP_ZOOM` from config; fallback coordinates (`or 52.5` / `or 6.0`) replaced by `DEFAULT_MAP_CENTER[0]` / `[1]`; zoom uses `DEFAULT_MAP_ZOOM` + +### Impact +- Map default location is now a single-point-of-change in `config.py` +- Payload type is displayed as readable text instead of a raw number +- No breaking changes β€” all existing map behaviour (re-centre on device position, contact markers) unchanged + +## [1.9.2] - 2026-02-15 β€” CLI Parameters & Cleanup + +### Added +- βœ… **`--port=PORT` CLI parameter** β€” Web server port is now configurable at startup (default: `8081`). Allows running multiple instances simultaneously on different ports +- βœ… **`--ble-pin=PIN` CLI parameter** β€” BLE pairing PIN is now configurable at startup (default: `123456`). Eliminates the need to edit `config.py` for devices with a non-default PIN, and works in systemd service files +- βœ… **Per-device log file** β€” Debug log file now includes the BLE address in its filename (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`), so multiple instances log to separate files + +### Fixed +- πŸ›  **BLE PIN not applied from CLI** β€” `ble/worker.py` imported `BLE_PIN` as a constant at module load time (`from config import BLE_PIN`), capturing the default value `"123456"` before CLI parsing could override `config.BLE_PIN`. Changed to runtime access via `config.BLE_PIN` so the `--ble-pin` parameter is correctly passed to the BLE agent + +### Removed +- ❌ **Redundant `meshcore_gui/meshcore_gui.py`** β€” This file was a near-identical copy of both `meshcore_gui.py` (top-level) and `meshcore_gui/__main__.py`, but was never imported or referenced. Removed to eliminate maintenance risk. The two remaining entry points cover all startup methods: `python meshcore_gui.py` and `python -m meshcore_gui` + +### Impact +- Multiple instances can run side-by-side with different ports, PINs and log files +- Service deployments no longer require editing `config.py` β€” all runtime settings via CLI +- No breaking changes β€” all defaults are unchanged + +--- + +## [1.9.1] - 2026-02-14 β€” Bugfix: Dual Reconnect Conflict + +### Fixed +- πŸ›  **Library reconnect interfered with application reconnect** β€” The meshcore library's internal `auto_reconnect` (visible in logs as `"Attempting reconnection 1/3"`) ran a fast 3-attempt reconnect cycle without bond cleanup. This prevented the application's own `reconnect_loop` (which does `remove_bond()` + backoff) from succeeding, because BlueZ retained a stale bond β†’ `"failed to discover service"` + +### Changed +- πŸ”„ `ble/worker.py`: Set `auto_reconnect=False` in both `MeshCore.create_ble()` call sites (`_connect()` and `_create_fresh_connection()`), so only the application's bond-aware `reconnect_loop` handles reconnection +- πŸ”„ `ble/worker.py`: Added `"failed to discover"` and `"service discovery"` to disconnect detection keywords for defensive coverage + +### Impact +- Eliminates the ~9 second wasted library reconnect cycle after every BLE disconnect +- Application's `reconnect_loop` (with bond cleanup) now runs immediately after disconnect detection +- No breaking changes β€” the application reconnect logic was already fully functional + +--- + +## [1.9.0] - 2026-02-14 β€” BLE Connection Stability + +### Added +- βœ… **Built-in BLE PIN agent** β€” New `ble/ble_agent.py` registers a D-Bus agent with BlueZ to handle PIN pairing requests automatically. Eliminates the need for external `bt-agent.service` and `bluez-tools` package + - Uses `dbus_fast` (already a dependency of `bleak`, no new packages) + - Supports `RequestPinCode`, `RequestPasskey`, `DisplayPasskey`, `RequestConfirmation`, `AuthorizeService` callbacks + - Configurable PIN via `BLE_PIN` in `config.py` (default: `123456`) +- βœ… **Automatic bond cleanup** β€” New `ble/ble_reconnect.py` provides `remove_bond()` function that removes stale BLE bonds via D-Bus, equivalent to `bluetoothctl remove
`. Called automatically on startup and before each reconnect attempt +- βœ… **Automatic reconnect after disconnect** β€” BLEWorker main loop now detects BLE disconnects (via connection error exceptions) and automatically triggers a reconnect sequence: bond removal β†’ linear backoff wait β†’ fresh connection β†’ re-wire handlers β†’ reload device data + - Configurable via `RECONNECT_MAX_RETRIES` (default: 5) and `RECONNECT_BASE_DELAY` (default: 5.0s) + - After all retries exhausted: waits 60s then starts a new retry cycle (infinite recovery) +- βœ… **Generic install script** β€” `install_ble_stable.sh` auto-detects user, project directory, venv path and entry point to generate systemd service and D-Bus policy. Supports `--uninstall` flag + +### Changed +- πŸ”„ **`ble/worker.py`** β€” `_async_main()` rewritten with three phases: (1) start PIN agent, (2) remove stale bond, (3) connect + main loop with disconnect detection. Reconnect logic re-wires all event handlers and reloads device data after successful reconnection +- πŸ”„ **`config.py`** β€” Added `BLE_PIN`, `RECONNECT_MAX_RETRIES`, `RECONNECT_BASE_DELAY` constants + +### Removed +- ❌ **`bt-agent.service` dependency** β€” No longer needed; PIN pairing is handled by the built-in agent +- ❌ **`bluez-tools` system package** β€” No longer needed +- ❌ **`~/.meshcore-ble-pin` file** β€” No longer needed +- ❌ **Manual `bluetoothctl remove` before startup** β€” Handled automatically +- ❌ **`ExecStartPre` in systemd service** β€” Bond cleanup is internal + +### Impact +- Zero external dependencies for BLE pairing on Linux +- Automatic recovery from the T1000e ~2 hour BLE disconnect issue +- No manual intervention needed after BLE connection loss +- Single systemd service (`meshcore-gui.service`) manages everything +- No breaking changes to existing functionality + +--- + +## [1.8.0] - 2026-02-14 β€” DRY Message Construction & Archive Layout Unification + +### Fixed +- πŸ›  **Case-sensitive prefix matching** β€” `get_contact_name_by_prefix()` and `get_contact_by_prefix()` in `shared_data.py` failed to match path hashes (uppercase, e.g. `'B8'`) against contact pubkeys (lowercase, e.g. `'b8a3f2...'`). Added `.lower()` to both sides of the comparison, consistent with `_resolve_path_names()` which already had it +- πŸ›  **Route page 404 from archive** β€” Archive page linked to `/route/{hash}` but route was registered as `/route/{msg_index:int}`, causing a JSON parse error for hex hash strings. Route parameter changed to `str` with 3-strategy lookup (index β†’ memory hash β†’ archive fallback) +- πŸ›  **Three entry points out of sync** β€” `meshcore_gui.py` (root), `meshcore_gui/meshcore_gui.py` (inner) and `meshcore_gui/__main__.py` had diverging route registrations. All three now use identical `/route/{msg_key}` with `str` parameter + +### Changed +- πŸ”„ **`core/models.py` β€” DRY factory methods and formatting** + - `Message.now_timestamp()`: static method replacing 7Γ— hardcoded `datetime.now().strftime('%H:%M:%S')` across `events.py` and `commands.py` + - `Message.incoming()`: classmethod factory for received messages (`direction='in'`, auto-timestamp) + - `Message.outgoing()`: classmethod factory for sent messages (`sender='Me'`, `direction='out'`, auto-timestamp) + - `Message.format_line(channel_names)`: single-line display formatting (`"12:34:56 ← [Public] [2hβœ“] PE1ABC: Hello mesh!"`), replacing duplicate inline formatting in `messages_panel.py` and `archive_page.py` +- πŸ”„ **`ble/events.py`** β€” 4Γ— `Message(...)` constructors replaced by `Message.incoming()`; `datetime` import removed +- πŸ”„ **`ble/commands.py`** β€” 3Γ— `Message(...)` constructors replaced by `Message.outgoing()`; `datetime` import removed +- πŸ”„ **`gui/panels/messages_panel.py`** β€” 15 lines inline formatting replaced by single `msg.format_line(channel_names)` call +- πŸ”„ **`gui/archive_page.py` β€” Layout unified with main page** + - Multi-row card layout replaced by single-line `msg.format_line()` in monospace container (same style as main page) + - DM added to channel filter dropdown (post-filter on `channel is None`) + - Message click opens `/route/{message_hash}` in new tab (was: no click handler on archive messages) + - Removed `_render_message_card()` (98 lines) and `_render_archive_route()` (75 lines) + - Removed `RouteBuilder` dependency and `TYPE_LABELS` import + - File reduced from 445 to 267 lines +- πŸ”„ **`gui/route_page.py`** β€” `render(msg_index: int)` β†’ `render(msg_key: str)` with 3-strategy message lookup: (1) numeric index from in-memory list, (2) hash match in memory, (3) `archive.get_message_by_hash()` fallback +- πŸ”„ **`services/message_archive.py`** β€” New method `get_message_by_hash(hash)` for single-message lookup by packet hash +- πŸ”„ **`__main__.py` + `meshcore_gui.py` (both)** β€” Route changed from `/route/{msg_index}` (int) to `/route/{msg_key}` (str) + +### Impact +- DRY: timestamp formatting 7β†’1 definition, message construction 7β†’2 factories, line formatting 2β†’1 method +- Archive page visually consistent with main messages panel (single-line, monospace) +- Archive messages now clickable to open route visualization (was: only in-memory messages) +- Case-insensitive prefix matching fixes path name resolution for contacts with uppercase path hashes +- No breaking changes to BLE protocol handling, dedup, bot, or data storage + +### Known Limitations +- DM filter in archive uses post-filtering (query without channel filter + filter on `channel is None`); becomes exact when `query_messages()` gets native DM support + +### Parked for later +- Multi-path tracking (enrich RxLogEntry with multiple path observations) +- Events correlation improvements (only if proven data loss after `.lower()` fix) + +--- + +## [1.7.0] - 2026-02-13 β€” Archive Channel Name Persistence + +### Added +- βœ… **Channel name stored in archive** β€” Messages now persist `channel_name` alongside the numeric `channel` index in `
_messages.json`, so archived messages retain their human-readable channel name even when the device is not connected + - `Message` dataclass: new field `channel_name: str` (default `""`, backward compatible) + - `SharedData.add_message()`: automatically resolves `channel_name` from the live channels list when not already set (new helper `_resolve_channel_name()`) + - `MessageArchive.add_message()`: writes `channel_name` to the JSON dict +- βœ… **Archive channel selector built from archived data** β€” Channel filter dropdown on `/archive` now populated via `SELECT DISTINCT channel_name` on the archive instead of the live BLE channels list + - New method `MessageArchive.get_distinct_channel_names()` returns sorted unique channel names from stored messages + - Selector shows only channels that actually have archived messages +- βœ… **Archive filter on channel name** β€” `MessageArchive.query_messages()` parameter changed from `channel: Optional[int]` to `channel_name: Optional[str]` (exact match on name string) + +### Changed +- πŸ”„ `core/models.py`: Added `channel_name` field to `Message` dataclass and `from_dict()` +- πŸ”„ `core/shared_data.py`: `add_message()` resolves channel name; added `_resolve_channel_name()` helper +- πŸ”„ `services/message_archive.py`: `channel_name` persisted in JSON; `query_messages()` filters by name; new `get_distinct_channel_names()` method +- πŸ”„ `gui/archive_page.py`: Channel selector built from `archive.get_distinct_channel_names()`; filter state changed from `_channel_filter` (int) to `_channel_name_filter` (str); message cards show `channel_name` directly from archive + +### Fixed +- πŸ›  **Main page empty after startup** β€” After a restart the messages panel showed no messages until new live BLE traffic arrived. `SharedData.load_recent_from_archive()` now loads up to 100 recent archived messages during the cache-first startup phase, so historical messages are immediately visible + - New method `SharedData.load_recent_from_archive(limit)` β€” reads from `MessageArchive.query_messages()` and populates the in-memory list without re-archiving + - `BLEWorker._apply_cache()` calls `load_recent_from_archive()` at the end of cache loading + +### Impact +- Archived messages now self-contained β€” channel name visible without live BLE connection +- Main page immediately shows historical messages after startup (no waiting for live BLE traffic) +- Backward compatible β€” old archive entries without `channel_name` fall back to `"Ch "` +- No breaking changes to existing functionality + +--- + +## [1.6.0] - 2026-02-13 β€” Dashboard Layout Consolidation + +### Changed +- πŸ”„ **Messages panel consolidated** β€” Filter checkboxes (DM + channels) and message input (text field, channel selector, Send button) are now integrated into the Messages panel, replacing the separate Filter and Input panels + - DM + channel checkboxes displayed centered in the Messages header row, between the "πŸ’¬ Messages" label and the "πŸ“š Archive" button + - Message input row (text field, channel selector, Send button) placed below the message list within the same card + - `messages_panel.py`: Constructor now accepts `put_command` callable; added `update_filters(data)`, `update_channel_options(channels)` methods and `channel_filters`, `last_channels` properties (all logic 1:1 from FilterPanel/InputPanel); `update()` signature unchanged +- πŸ”„ **Actions panel expanded** β€” BOT toggle checkbox moved from Filter panel to Actions panel, below the Refresh/Advert buttons + - `actions_panel.py`: Constructor now accepts `set_bot_enabled` callable; added `update(data)` method for BOT state sync; `_on_bot_toggle()` logic 1:1 from FilterPanel +- πŸ”„ **Dashboard layout simplified** β€” Centre column reduced from 4 panels (Map β†’ Input β†’ Filter β†’ Messages) to 2 panels (Map β†’ Messages) + - `dashboard.py`: FilterPanel and InputPanel no longer rendered; all dependencies rerouted to MessagesPanel and ActionsPanel; `_update_ui()` call-sites updated accordingly + +### Removed (from layout, files retained) +- ❌ **Filter panel** no longer rendered as separate panel β€” `filter_panel.py` retained in codebase but not instantiated in dashboard +- ❌ **Input panel** no longer rendered as separate panel β€” `input_panel.py` retained in codebase but not instantiated in dashboard + +### Impact +- Cleaner, more compact dashboard: 2 fewer panels in the centre column +- All functionality preserved β€” message filtering, send, BOT toggle, archive all work identically +- No breaking changes to BLE, services, core or other panels + +--- + + + +## [1.5.0] - 2026-02-11 β€” Room Server Support, Dynamic Channel Discovery & Contact Management + +### Added +- βœ… **Room Server panel** β€” Dedicated per-room-server message panel in the centre column below Messages. Each Room Server (type=3 contact) gets its own `ui.card()` with login/logout controls and message display + - Click a Room Server contact to open an add/login dialog with password field + - After login: messages are displayed in the room card; send messages directly from the room panel + - Password row + login button automatically replaced by Logout button after successful login + - Room Server author attribution via `signature` field (txt_type=2) β€” real message author is resolved from the 4-byte pubkey prefix, not the room server pubkey + - New panel: `gui/panels/room_server_panel.py` β€” per-room card management with login state tracking +- βœ… **Room Server password store** β€” Passwords stored outside the repository in `~/.meshcore-gui/room_passwords/
.json` + - New service: `services/room_password_store.py` β€” JSON-backed persistent password storage per BLE device, analogous to `PinStore` + - Room panels are restored from stored passwords on app restart +- βœ… **Dynamic channel discovery** β€” Channels are now auto-discovered from the device at startup via `get_channel()` BLE probing, replacing the hardcoded `CHANNELS_CONFIG` + - Single-attempt probe per channel slot with early stop after 2 consecutive empty slots + - Channel name and encryption key extracted in a single pass (combined discovery + key loading) + - Configurable channel caching via `CHANNEL_CACHE_ENABLED` (default: `False` β€” always fresh from device) + - `MAX_CHANNELS` setting (default: 8) controls how many slots are probed +- βœ… **Individual contact deletion** β€” πŸ—‘οΈ delete button per unpinned contact in the contacts list, with confirmation dialog + - New command: `remove_single_contact` in BLE command handler + - Pinned contacts are protected (no delete button shown) +- βœ… **"Also delete from history" option** β€” Checkbox in the Clean up confirmation dialog to also remove locally cached contact data + + +- βœ… **Room Server protocol research** β€” `RoomServer_Companion_App_Onderzoek.md` documents the full companion app message flow (login, push protocol, signature mechanism, auto_message_fetching) + +### Changed +- πŸ”„ `config.py`: Removed `CHANNELS_CONFIG` constant; added `MAX_CHANNELS` (default: 8) and `CHANNEL_CACHE_ENABLED` (default: `False`) +- πŸ”„ `ble/worker.py`: Replaced hardcoded channel loading with `_discover_channels()` method; added `_try_get_channel_info()` helper; `_apply_cache()` respects `CHANNEL_CACHE_ENABLED` setting; removed `_load_channel_keys()` (integrated into discovery pass) +- πŸ”„ `ble/commands.py`: Added `login_room`, `send_room_msg` and `remove_single_contact` command handlers +- πŸ”„ `gui/panels/contacts_panel.py`: Contact click now dispatches by type β€” type=3 (Room Server) opens room dialog, others open DM dialog; added `on_add_room` callback parameter; added πŸ—‘οΈ delete button per unpinned contact +- πŸ”„ `gui/panels/messages_panel.py`: Room Server messages filtered from general message view via `_is_room_message()` with prefix matching; `update()` accepts `room_pubkeys` parameter +- πŸ”„ `gui/dashboard.py`: Added `RoomServerPanel` in centre column; `_update_ui()` passes `room_pubkeys` to Messages panel; added `_on_add_room_server` callback +- πŸ”„ `gui/panels/filter_panel.py`: Channel filter checkboxes now built dynamically from discovered channels (no hardcoded references) +- πŸ”„ `services/bot.py`: Removed stale comment referencing hardcoded channels + +### Fixed +- πŸ›  **Room Server messages appeared as DM** β€” Messages from Room Servers (txt_type=2) were displayed in the general Messages panel as direct messages. They are now filtered out and shown exclusively in the Room Server panel +- πŸ›  **Historical room messages not shown after login** β€” Post-login fetch loop was polling `get_msg()` before room server had time to push messages over LoRa RF (10–75s per message). Removed redundant fetch loop; the library's `auto_message_fetching` handles `MESSAGES_WAITING` events correctly and event-driven +- πŸ›  **Author attribution incorrect for room messages** β€” Room server messages showed the room server name as sender instead of the actual message author. Now correctly resolved from the `signature` field (4-byte pubkey prefix) via contact lookup + +### Impact +- Room Servers are now first-class citizens in the GUI with dedicated panels +- Channel configuration no longer requires manual editing of `config.py` +- Contact list management is more granular with per-contact deletion +- No breaking changes to existing functionality (messages, DM, map, archive, bot, etc.) + +--- + +## [1.4.0] - 2026-02-09 β€” SDK Event Race Condition Fix + +### Fixed +- πŸ›  **BLE startup delay of ~2 minutes eliminated** β€” The meshcore Python SDK (`commands/base.py`) dispatched device response events before `wait_for_events()` registered its subscription. On busy networks with frequent `RX_LOG_DATA` events, this caused `send_device_query()` and `get_channel()` to fail repeatedly with `no_event_received`, wasting 110+ seconds in timeouts + +### Changed +- πŸ“„ `meshcore` SDK `commands/base.py`: Rewritten `send()` method to subscribe to expected events **before** transmitting the BLE command (subscribe-before-send pattern), matching the approach used by the companion apps (meshcore.js, iOS, Android). Submitted upstream as [meshcore_py PR #52](https://github.com/meshcore-dev/meshcore_py/pull/52) + +### Impact +- Startup time reduced from ~2+ minutes to ~10 seconds on busy networks +- All BLE commands (`send_device_query`, `get_channel`, `get_bat`, `send_appstart`, etc.) now succeed on first attempt instead of requiring multiple retries +- No changes to meshcore_gui code required β€” the fix is entirely in the meshcore SDK + +### Temporary Installation +Until the fix is merged upstream, install the patched meshcore SDK: +```bash +pip install --force-reinstall git+https://github.com/PE1HVH/meshcore_py.git@fix/event-race-condition +``` + +--- + + + +## [1.3.2] - 2026-02-09 β€” Bugfix: Bot Device Name Restoration After Restart + +### Fixed +- πŸ›  **Bot device name not properly restored after restart/crash** β€” After a restart or crash with bot mode previously active, the original device name was incorrectly stored as the bot name (e.g. `NL-OV-ZWL-STDSHGN-WKC Bot`) instead of the real device name (e.g. `PE1HVH T1000e`). The original device name is now correctly preserved and restored when bot mode is disabled + +### Changed +- πŸ”„ `commands.py`: `set_bot_name` handler now verifies that the stored original name is not already the bot name before saving +- πŸ”„ `shared_data.py`: `original_device_name` is only written when it differs from `BOT_DEVICE_NAME` to prevent overwriting with the bot name on restart + +--- + + + +## [1.3.1] - 2026-02-09 β€” Bugfix: Auto-add AttributeError + +### Fixed +- πŸ›  **Auto-add error on first toggle** β€” Setting auto-add for the first time raised `AttributeError: 'telemetry_mode_base'`. The `set_manual_add_contacts()` SDK call now handles missing `telemetry_mode_base` attribute gracefully + +### Changed +- πŸ”„ `commands.py`: `set_auto_add` handler wraps `set_manual_add_contacts()` call with attribute check and error handling for missing `telemetry_mode_base` + +--- + + + +## [1.3.0] - 2026-02-08 β€” Bot Device Name Management + +### Added +- βœ… **Bot device name switching** β€” When the BOT checkbox is enabled, the device name is automatically changed to a configurable bot name; when disabled, the original name is restored + - Original device name is saved before renaming so it can be restored on BOT disable + - Device name written to device via BLE `set_name()` SDK call + - Graceful handling of BLE failures during name change +- βœ… **`BOT_DEVICE_NAME` constant** in `config.py` β€” Configurable fixed device name used when bot mode is active (default: `;NL-OV-ZWL-STDSHGN-WKC Bot`) + +### Changed +- πŸ”„ `config.py`: Added `BOT_DEVICE_NAME` constant for bot mode device name +- πŸ”„ `bot.py`: Removed hardcoded `BOT_NAME` prefix ("Zwolle Bot") from bot reply messages β€” bot replies no longer include a name prefix +- πŸ”„ `filter_panel.py`: BOT checkbox toggle now triggers device name save/rename via command queue +- πŸ”„ `commands.py`: Added `set_bot_name` and `restore_name` command handlers for device name switching +- πŸ”„ `shared_data.py`: Added `original_device_name` field for storing the pre-bot device name + +### Removed +- ❌ `BOT_NAME` constant from `bot.py` β€” bot reply prefix removed; replies no longer prepend a bot display name + +--- + +## [1.2.0] - 2026-02-08 β€” Contact Maintenance Feature + +### Added +- βœ… **Pin/Unpin contacts** (Iteration A) β€” Toggle to pin individual contacts, protecting them from bulk deletion + - Persistent pin state stored in `~/.meshcore-gui/cache/
_pins.json` + - Pinned contacts visually marked with yellow background + - Pinned contacts sorted to top of contact list + - Pin state survives app restart + - New service: `services/pin_store.py` β€” JSON-backed persistent pin storage + +- βœ… **Bulk delete unpinned contacts** (Iteration B) β€” Remove all unpinned contacts from device in one action + - "🧹 Clean up" button in contacts panel with confirmation dialog + - Shows count of contacts to be removed vs. pinned contacts kept + - Progress status updates during removal + - Automatic device resync after completion + - New service: `services/contact_cleaner.py` β€” ContactCleanerService with purge statistics + +- βœ… **Auto-add contacts toggle** (Iteration C) β€” Control whether device automatically adds new contacts from mesh adverts + - "πŸ“₯ Auto-add" checkbox in contacts panel (next to Clean up button) + - Syncs with device via `set_manual_add_contacts()` SDK call + - Inverted logic handled internally (UI "Auto-add ON" = `set_manual_add_contacts(false)`) + - Optimistic update with automatic rollback on BLE failure + - State synchronized from device on each GUI update cycle + +### Changed +- πŸ”„ `contacts_panel.py`: Added pin checkbox per contact, purge button, auto-add toggle, DM dialog (all existing functionality preserved) +- πŸ”„ `commands.py`: Added `purge_unpinned` and `set_auto_add` command handlers +- πŸ”„ `shared_data.py`: Added `auto_add_enabled` field with thread-safe getter/setter +- πŸ”„ `protocols.py`: Added `set_auto_add_enabled` and `is_auto_add_enabled` to Writer and Reader protocols +- πŸ”„ `dashboard.py`: Passes `PinStore` and `set_auto_add_enabled` callback to ContactsPanel +- πŸ”„ **UI language**: All Dutch strings in `contacts_panel.py` and `commands.py` translated to English + +--- + +### Fixed +- πŸ›  **Route table names and IDs not displayed** β€” Route tables in both current messages (RoutePage) and archive messages (ArchivePage) now correctly show node names and public key IDs for sender, repeaters and receiver + +### Changed +- πŸ”„ **CHANGELOG.md**: Corrected version numbering to semantic versioning, fixed inaccurate references (archive button location, filter state persistence) +- πŸ”„ **README.md**: Added Message Archive feature, updated project structure, configuration table and architecture diagram +- πŸ”„ **MeshCore_GUI_Design.docx**: Added ArchivePage, MessageArchive, Models components; updated project structure, protocols, configuration and version history + +--- + +## [1.1.0] - 2026-02-07 β€” Archive Viewer Feature + + +### Added +- βœ… **Archive Viewer Page** (`/archive`) β€” Full-featured message archive browser + - Pagination (50 messages per page, configurable) + - Channel filter dropdown (All + configured channels) + - Time range filter (24h, 7d, 30d, 90d, All time) + - Text search (case-insensitive) + - Filter state stored in instance variables (reset on page reload) + - Message cards with same styling as main messages panel + - Clickable messages for route visualization (where available) + - **πŸ’¬ Reply functionality** β€” Expandable reply panel per message + - **πŸ—ΊοΈ Inline route table** β€” Expandable route display per archive message with sender, repeaters and receiver (names, IDs, node types) + - *(Note: Reply panels and inline route tables removed in v1.8.0, replaced by click-to-route navigation via message hash)* + + + + + +- βœ… **MessageArchive.query_messages()** method + - Filter by: time range, channel, text search, sender + - Pagination support (limit, offset) + - Returns tuple: (messages, total_count) + - Sorting: Newest first + +- βœ… **UI Integration** + - "πŸ“š Archive" button in Messages panel header (opens in new tab) + - Back to Dashboard button in archive page + + + +- βœ… **Reply Panel** + - Expandable reply per message (πŸ’¬ Reply button) + - Pre-filled with @sender mention + - Channel selector + - Send button with success notification + - Auto-close expansion after send + +### Changed +- πŸ”„ `SharedData.get_snapshot()`: Now includes `'archive'` field +- πŸ”„ `MessagesPanel`: Added archive button in header row +- πŸ”„ Both entry points (`__main__.py` and `meshcore_gui.py`): Register `/archive` route + + + +### Performance +- Query: ~10ms for 10k messages with filters +- Memory: ~10KB per page (50 messages) +- No impact on main UI (separate page) + +### Known Limitations +- ~~Route visualization only works for messages in recent buffer (last 100)~~ β€” Fixed in v1.8.0: archive messages now support click-to-route via `get_message_by_hash()` fallback +- Text search is linear scan (no indexing yet) +- Sender filter exists in API but not in UI yet + +--- + +## [1.0.3] - 2026-02-07 β€” Critical Bugfix: Archive Overwrite Prevention + + +### Fixed +- πŸ›  **CRITICAL**: Fixed bug where archive was overwritten instead of appended on restart +- πŸ›  Archive now preserves existing data when read errors occur +- πŸ›  Buffer is retained for retry if existing archive cannot be read + +### Changed +- πŸ”„ `_flush_messages()`: Early return on read error instead of overwriting +- πŸ”„ `_flush_rxlog()`: Early return on read error instead of overwriting +- πŸ”„ Better error messages for version mismatch and JSON decode errors + +### Details +**Problem:** If the existing archive file had a JSON parse error or version mismatch, +the flush operation would proceed with `existing_messages = []`, effectively +overwriting all historical data with only the new buffered messages. + +**Solution:** The flush methods now: +1. Try to read existing archive first +2. If read fails (JSON error, version mismatch, IO error), abort the flush +3. Keep buffer intact for next retry +4. Only clear buffer after successful write + +**Impact:** No data loss on restart or when archive files have issues. + +### Testing +- βœ… Added `test_append_on_restart_not_overwrite()` integration test +- βœ… Verifies data is appended across multiple sessions +- βœ… All existing tests still pass + +--- + +## [1.0.2] - 2026-02-07 β€” RxLog message_hash Enhancement + + +### Added +- βœ… `message_hash` field added to `RxLogEntry` model +- βœ… RxLog entries now include message_hash for correlation with messages +- βœ… Archive JSON includes message_hash in rxlog entries + +### Changed +- πŸ”„ `events.py`: Restructured `on_rx_log()` to extract message_hash before creating RxLogEntry +- πŸ”„ `message_archive.py`: Updated rxlog archiving to include message_hash field +- πŸ”„ Tests updated to verify message_hash persistence + +### Benefits +- **Correlation**: Link RX log entries to their corresponding messages +- **Analysis**: Track which packets resulted in messages +- **Debugging**: Better troubleshooting of packet processing + +--- + +## [1.0.1] - 2026-02-07 β€” Entry Point Fix + + +### Fixed +- βœ… `meshcore_gui.py` (root entry point) now passes ble_address to SharedData +- βœ… Archive works correctly regardless of how application is started + +### Changed +- πŸ”„ Both entry points (`meshcore_gui.py` and `meshcore_gui/__main__.py`) updated + +--- + +## [1.0.0] - 2026-02-07 β€” Message & Metadata Persistence + + +### Added +- βœ… MessageArchive class for persistent storage +- βœ… Configurable retention periods (MESSAGE_RETENTION_DAYS, RXLOG_RETENTION_DAYS, CONTACT_RETENTION_DAYS) +- βœ… Automatic daily cleanup of old data +- βœ… Batch writes for performance +- βœ… Thread-safe with separate locks +- βœ… Atomic file writes +- βœ… Contact retention in DeviceCache +- βœ… Archive statistics API +- βœ… Comprehensive tests (20+ unit, 8+ integration) +- βœ… Full documentation + +### Storage Locations +- `~/.meshcore-gui/archive/
_messages.json` +- `~/.meshcore-gui/archive/
_rxlog.json` + +### Requirements Completed +- R1: All incoming messages persistent βœ… +- R2: All incoming RxLog entries persistent βœ… +- R3: Configurable retention βœ… +- R4: Automatic cleanup βœ… +- R5: Backward compatibility βœ… +- R6: Contact retention βœ… +- R7: Archive stats API βœ… + +- Fix3: Leaflet asset injection is now per page render instead of process-global, and browser bootstrap now retries until the host element, Leaflet runtime, and MeshCore panel runtime are all available. This fixes blank map containers caused by missing or late-loaded JS/CSS assets. + +- Fix5: Removed per-snapshot map invalidate calls, stopped forcing a default dark theme during map bootstrap, and added client-side interaction/resize guards so zooming stays responsive and the theme no longer jumps back during status-loop updates. + + +## 2026-03-09 map hotfix v2 +- regular map snapshots no longer carry theme state +- explicit theme changes are now handled only via the dedicated theme channel +- initial map render now sends an ensure_map command plus an immediate theme sync +- added no-op ensure_map handling in the Leaflet runtime to avoid accidental fallback behaviour diff --git a/meshcore_gui/meshcore_gui/config.py b/meshcore_gui/meshcore_gui/config.py new file mode 100644 index 0000000..d7c3dc5 --- /dev/null +++ b/meshcore_gui/meshcore_gui/config.py @@ -0,0 +1,395 @@ +""" +Application configuration for MeshCore GUI. + +Contains only global runtime settings. +Bot configuration lives in :mod:`meshcore_gui.services.bot`. +UI display constants live in :mod:`meshcore_gui.gui.constants`. + +The ``DEBUG`` flag defaults to False and can be activated at startup +with the ``--debug-on`` command-line option. + +Debug output is written to both stdout and a rotating log file at +``~/.meshcore-gui/logs/meshcore_gui.log``. +""" + +import json +import logging +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Any, Dict, List + + +# ============================================================================== +# VERSION +# ============================================================================== + + +VERSION: str = "1.14.0" + + +# ============================================================================== +# OPERATOR / LANDING PAGE +# ============================================================================== + +# Operator callsign shown on the landing page SVG and drawer footer. +# Change this to your own callsign (e.g. "PE1HVH", "PE1HVH/MIT"). +OPERATOR_CALLSIGN: str = "PE1HVH" + +# Path to the landing page SVG file. +# The placeholder ``{callsign}`` inside the SVG is replaced at runtime +# with ``OPERATOR_CALLSIGN``. +# +# Default: the bundled DOMCA splash (static/landing_default.svg). +# To use a custom SVG, point this to your own file, e.g.: +# LANDING_SVG_PATH = DATA_DIR / "landing.svg" +LANDING_SVG_PATH: Path = Path(__file__).parent / "static" / "landing_default.svg" + + +# ============================================================================== +# MAP DEFAULTS +# ============================================================================== + +# Default map centre used as the initial view *before* the device reports +# its own GPS position. Once the device advertises a valid adv_lat/adv_lon +# pair, every map will re-centre on the device's actual location. +# +# Change these values to match the location of your device / station. +# Current default: Zwolle, The Netherlands (52.5168, 6.0830). +DEFAULT_MAP_CENTER: tuple[float, float] = (52.5168, 6.0830) + +# Default zoom level for all Leaflet maps (higher = more zoomed in). +DEFAULT_MAP_ZOOM: int = 9 + + + +# ============================================================================== +# DIRECTORY STRUCTURE +# ============================================================================== + +# Base data directory β€” all persistent data lives under this root. +# Existing services (cache, pins, archive) each define their own +# sub-directory; this constant centralises the root for new consumers. +DATA_DIR: Path = Path.home() / ".meshcore-gui" + +# Log directory for debug and error log files. +LOG_DIR: Path = DATA_DIR / "logs" + +# Log file path (rotating: max 5 MB per file, 3 backups = 20 MB total). +LOG_FILE: Path = LOG_DIR / "meshcore_gui.log" + + +def set_log_file_for_device(device_id: str) -> None: + """Set the log file name based on the device identifier. + + Transforms ``F0:9E:9E:75:A3:01`` into + ``~/.meshcore-gui/logs/F0_9E_9E_75_A3_01_meshcore_gui.log`` and + ``/dev/ttyUSB0`` into ``~/.meshcore-gui/logs/_dev_ttyUSB0_meshcore_gui.log``. + + Must be called **before** the first ``debug_print()`` call so the + lazy logger initialisation picks up the correct path. + """ + global LOG_FILE + safe_name = ( + device_id + .replace("literal:", "") + .replace(":", "_") + .replace("/", "_") + ) + LOG_FILE = LOG_DIR / f"{safe_name}_meshcore_gui.log" + +# Maximum size per log file in bytes (5 MB). +LOG_MAX_BYTES: int = 5 * 1024 * 1024 + +# Number of rotated backup files to keep. +LOG_BACKUP_COUNT: int = 3 + + +# ============================================================================== +# DEBUG +# ============================================================================== + +DEBUG: bool = False + +# Internal file logger β€” initialised lazily on first debug_print() call. +_file_logger: logging.Logger | None = None + + +def _init_file_logger() -> logging.Logger: + """Create and configure the rotating file logger (called once).""" + LOG_DIR.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger("meshcore_gui.debug") + logger.setLevel(logging.DEBUG) + logger.propagate = False + + handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + handler.setFormatter( + logging.Formatter("%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + ) + logger.addHandler(handler) + return logger + + +def _caller_module() -> str: + """Return a short module label for the calling code. + + Walks two frames up (debug_print -> caller) and extracts the + module ``__name__``. The common ``meshcore_gui.`` prefix is + stripped for brevity, e.g. ``ble.worker`` instead of + ``meshcore_gui.ble.worker``. + """ + frame = sys._getframe(2) # 0=_caller_module, 1=debug_print, 2=actual caller + module = frame.f_globals.get("__name__", "") + if module.startswith("meshcore_gui."): + module = module[len("meshcore_gui."):] + return module + + +def _init_meshcore_logger() -> None: + """Route meshcore library debug output to our rotating log file. + + The meshcore library uses ``logging.getLogger("meshcore")`` throughout, + but never attaches a handler. Without this function all library-level + debug output (raw send/receive, event dispatching, command flow) + is silently dropped because Python's root logger only forwards + WARNING and above. + + Call once at startup (or lazily from ``debug_print``) so that + ``MESHCORE_LIB_DEBUG=True`` actually produces visible output. + """ + LOG_DIR.mkdir(parents=True, exist_ok=True) + + mc_logger = logging.getLogger("meshcore") + # Guard against duplicate handlers on repeated calls + if any(isinstance(h, RotatingFileHandler) for h in mc_logger.handlers): + return + + handler = RotatingFileHandler( + LOG_FILE, + maxBytes=LOG_MAX_BYTES, + backupCount=LOG_BACKUP_COUNT, + encoding="utf-8", + ) + handler.setFormatter( + logging.Formatter( + "%(asctime)s LIB [%(name)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + mc_logger.addHandler(handler) + + # Also add a stdout handler so library output appears in the console + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter( + logging.Formatter( + "%(asctime)s LIB [%(name)s]: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + mc_logger.addHandler(stdout_handler) + + +def debug_print(msg: str) -> None: + """Print a debug message when ``DEBUG`` is enabled. + + Output goes to both stdout and the rotating log file. + The calling module name is automatically included so that + exception context is immediately clear, e.g.:: + + DEBUG [ble.worker]: send_appstart attempt 3 exception: TimeoutError + """ + global _file_logger + + if not DEBUG: + return + + module = _caller_module() + formatted = f"DEBUG [{module}]: {msg}" + + # stdout (existing behaviour, now with module tag) + print(formatted) + + # Rotating log file + if _file_logger is None: + _file_logger = _init_file_logger() + # Also wire up the meshcore library logger so MESHCORE_LIB_DEBUG + # output actually appears in the same log file + stdout. + _init_meshcore_logger() + _file_logger.debug(formatted) + + +def pp(obj: Any, indent: int = 2) -> str: + """Pretty-format a dict, list, or other object for debug output. + + Use inside f-strings:: + + debug_print(f"payload={pp(r.payload)}") + + Dicts/lists get indented JSON; everything else falls back to repr(). + """ + if isinstance(obj, (dict, list)): + try: + return json.dumps(obj, indent=indent, default=str, ensure_ascii=False) + except (TypeError, ValueError): + return repr(obj) + return repr(obj) + + +def debug_data(label: str, obj: Any) -> None: + """Print a labelled data structure with pretty indentation. + + Combines a header line with pretty-printed data below it:: + + debug_data("get_contacts result", r.payload) + + Output:: + + DEBUG [worker]: get_contacts result ↓ + { + "name": "PE1HVH", + "contacts": 629, + ... + } + """ + if not DEBUG: + return + formatted = pp(obj) + # Single-line values stay on the same line + if '\n' not in formatted: + debug_print(f"{label}: {formatted}") + else: + # Multi-line: indent each line for readability + indented = '\n'.join(f" {line}" for line in formatted.splitlines()) + debug_print(f"{label} ↓\n{indented}") + + +# ============================================================================== +# CHANNELS +# ============================================================================== + +# Maximum number of channel slots to probe on the device. +# MeshCore supports up to 8 channels (indices 0-7). +MAX_CHANNELS: int = 8 + +# Enable or disable caching of the channel list to disk. +# When False (default), channels are always fetched fresh from the +# device at startup, guaranteeing the GUI always reflects the actual +# device configuration. When True, channels are loaded from cache +# for instant GUI population and then refreshed from the device. +# Note: channel *keys* (for packet decryption) are always cached +# regardless of this setting. +CHANNEL_CACHE_ENABLED: bool = False + + +# ============================================================================== +# BOT DEVICE NAME +# ============================================================================== + +# Fixed device name applied when the BOT checkbox is enabled. +# The original device name is saved and restored when BOT is disabled. +BOT_DEVICE_NAME: str = "ZwolsBotje" + +# Default device name used as fallback when restoring from BOT mode +# and no original name was saved (e.g. after a restart). +DEVICE_NAME: str = "PE1HVH T1000e" + + +# ============================================================================== +# CACHE / REFRESH +# ============================================================================== + +# Default timeout (seconds) for meshcore command responses. +# Increase if you see frequent 'no_event_received' errors during startup. +DEFAULT_TIMEOUT: float = 10.0 + +# Enable debug logging inside the meshcore library itself. +# When True, raw send/receive data and event parsing are logged. +MESHCORE_LIB_DEBUG: bool = True + +# ============================================================================== +# TRANSPORT MODE (auto-detected from CLI argument) +# ============================================================================== + +# "serial" or "ble" β€” set at startup by main() based on the device argument. +TRANSPORT: str = "serial" + + +def is_ble_address(device_id: str) -> bool: + """Detect whether *device_id* looks like a BLE MAC address. + + Heuristic: + - Starts with ``literal:`` β†’ BLE + - Matches ``XX:XX:XX:XX:XX:XX`` (6 colon-separated hex pairs) β†’ BLE + - Everything else (``/dev/…``, ``COM…``) β†’ Serial + """ + if device_id.lower().startswith("literal:"): + return True + parts = device_id.split(":") + if len(parts) == 6 and all(len(p) == 2 for p in parts): + try: + for p in parts: + int(p, 16) + return True + except ValueError: + pass + return False +TRANSPORT: str = "serial" + +# Serial connection defaults. +SERIAL_BAUDRATE: int = 115200 +SERIAL_CX_DELAY: float = 0.1 + +# BLE connection defaults. +# BLE pairing PIN for the MeshCore device (T1000e default: 123456). +# Used by the built-in D-Bus agent to answer pairing requests +# automatically β€” eliminates the need for bt-agent.service. +BLE_PIN: str = "123456" + +# Maximum number of reconnect attempts after a disconnect. +RECONNECT_MAX_RETRIES: int = 5 + +# Base delay in seconds between reconnect attempts (multiplied by +# attempt number for linear backoff: 5s, 10s, 15s, 20s, 25s). +RECONNECT_BASE_DELAY: float = 5.0 + +# Interval in seconds between periodic contact refreshes from the device. +# Contacts are merged (new/changed contacts update the cache; contacts +# only present in cache are kept so offline nodes are preserved). +CONTACT_REFRESH_SECONDS: float = 300.0 # 5 minutes + +# ============================================================================== +# EXTERNAL LINKS (drawer menu) +# ============================================================================== + +EXT_LINKS = [ + ('MeshCore', 'https://meshcore.co.uk'), + ('Handleiding', 'https://www.pe1hvh.nl/pdf/MeshCore_Complete_Handleiding.pdf'), + ('Netwerk kaart', 'https://meshcore.co.uk/map'), + ('LocalMesh NL', 'https://www.localmesh.nl/'), +] +# ============================================================================== +# ARCHIVE / RETENTION +# ============================================================================== + +# Retention period for archived messages (in days). +# Messages older than this are automatically removed during cleanup. +MESSAGE_RETENTION_DAYS: int = 30 + +# Retention period for RX log entries (in days). +# RX log entries older than this are automatically removed during cleanup. +RXLOG_RETENTION_DAYS: int = 7 + +# Retention period for contacts (in days). +# Contacts not seen for longer than this are removed from cache. +CONTACT_RETENTION_DAYS: int = 90 + + +# BBS channel configuration is managed at runtime via BbsConfigStore. +# Settings are persisted to ~/.meshcore-gui/bbs/bbs_config.json +# and edited through the BBS Settings panel in the GUI. diff --git a/meshcore_gui/meshcore_gui/gui/dashboard.py b/meshcore_gui/meshcore_gui/gui/dashboard.py new file mode 100644 index 0000000..411e67c --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/dashboard.py @@ -0,0 +1,850 @@ +""" +Main dashboard page for MeshCore GUI. + +Thin orchestrator that owns the layout and the 500 ms update timer. +All visual content is delegated to individual panel classes in +:mod:`meshcore_gui.gui.panels`. +""" + +import logging +from urllib.parse import urlencode + +from nicegui import ui + +from meshcore_gui import config + +from meshcore_gui.core.protocols import SharedDataReader +from meshcore_gui.gui.panels import ( + ActionsPanel, + BbsPanel, + ContactsPanel, + DevicePanel, + MapPanel, + MessagesPanel, + RoomServerPanel, + RxLogPanel, +) +from meshcore_gui.gui.archive_page import ArchivePage +from meshcore_gui.services.bbs_config_store import BbsConfigStore +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService +from meshcore_gui.services.pin_store import PinStore +from meshcore_gui.services.room_password_store import RoomPasswordStore + + +# Suppress the harmless "Client has been deleted" warning that NiceGUI +# emits when a browser tab is refreshed while a ui.timer is active. +class _DeletedClientFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + return 'Client has been deleted' not in record.getMessage() + +logging.getLogger('nicegui').addFilter(_DeletedClientFilter()) + + +# ── DOMCA Theme ────────────────────────────────────────────────────── +# Fonts + CSS variables adapted from domca.nl style.css for NiceGUI/Quasar. +# Dark/light variable sets switch via Quasar's body--dark / body--light classes. + +_DOMCA_HEAD = ''' + + + + + + + + + +''' + +# ── Landing SVG loader ──────────────────────────────────────────────── +# Reads the SVG from config.LANDING_SVG_PATH and replaces {callsign} +# with config.OPERATOR_CALLSIGN. Falls back to a minimal placeholder +# when the file is missing. + + +def _load_landing_svg() -> str: + """Load the landing page SVG from disk. + + Returns: + SVG markup string with ``{callsign}`` replaced by the + configured operator callsign. + """ + path = config.LANDING_SVG_PATH + try: + raw = path.read_text(encoding="utf-8") + return raw.replace("{callsign}", config.OPERATOR_CALLSIGN) + except FileNotFoundError: + return ( + '' + 'Landing SVG not found: {path.name}' + '' + ) + + +# ── Standalone menu items (no submenus) ────────────────────────────── + +_STANDALONE_ITEMS = [ + ('\U0001f465', 'CONTACTS', 'contacts'), + ('\U0001f5fa\ufe0f', 'MAP', 'map'), + ('\U0001f4e1', 'DEVICE', 'device'), + ('\u26a1', 'ACTIONS', 'actions'), + ('\U0001f4ca', 'RX LOG', 'rxlog'), + ('\U0001f4cb', 'BBS', 'bbs'), +] + +_EXT_LINKS = config.EXT_LINKS + +# ── Shared button styles ───────────────────────────────────────────── + +_SUB_BTN_STYLE = ( + "font-family: 'JetBrains Mono', monospace; " + "letter-spacing: 1px; font-size: 0.72rem; " + "padding: 0.2rem 1.2rem 0.2rem 2.4rem" +) + +_MENU_BTN_STYLE = ( + "font-family: 'JetBrains Mono', monospace; " + "letter-spacing: 2px; font-size: 0.8rem; " + "padding: 0.35rem 1.2rem" +) + + +class DashboardPage: + """Main dashboard rendered at ``/``. + + Args: + shared: SharedDataReader for data access and command dispatch. + """ + + def __init__(self, shared: SharedDataReader, pin_store: PinStore, room_password_store: RoomPasswordStore) -> None: + self._shared = shared + self._pin_store = pin_store + self._room_password_store = room_password_store + + # BBS service and config store (singletons shared with bot routing) + self._bbs_config_store = BbsConfigStore() + self._bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler( + self._bbs_service, self._bbs_config_store + ) + + # Panels (created fresh on each render) + self._device: DevicePanel | None = None + self._contacts: ContactsPanel | None = None + self._map: MapPanel | None = None + self._messages: MessagesPanel | None = None + self._actions: ActionsPanel | None = None + self._rxlog: RxLogPanel | None = None + self._room_server: RoomServerPanel | None = None + self._bbs: BbsPanel | None = None + + # Header status label + self._status_label = None + + # Local first-render flag + self._initialized: bool = False + + # Panel switching state (layout) + self._panel_containers: dict = {} + self._active_panel: str = 'landing' + self._drawer = None + self._menu_buttons: dict = {} + + # Submenu containers (for dynamic channel/room items) + self._msg_sub_container = None + self._archive_sub_container = None + self._rooms_sub_container = None + self._last_channel_fingerprint = None + self._last_rooms_fingerprint = None + + # Archive page reference (for inline channel switching) + self._archive_page: ArchivePage | None = None + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete dashboard layout and start the timer.""" + self._initialized = False + + # Reset fingerprints: render() creates new (empty) NiceGUI + # containers, so _update_submenus must rebuild into them even + # when the channel/room data hasn't changed since last session. + self._last_channel_fingerprint = None + self._last_rooms_fingerprint = None + + # Create panel instances (UNCHANGED functional wiring) + put_cmd = self._shared.put_command + self._device = DevicePanel() + self._contacts = ContactsPanel(put_cmd, self._pin_store, self._shared.set_auto_add_enabled, self._on_add_room_server) + self._map = MapPanel() + self._messages = MessagesPanel(put_cmd) + self._actions = ActionsPanel(put_cmd, self._shared.set_bot_enabled) + self._rxlog = RxLogPanel() + self._room_server = RoomServerPanel(put_cmd, self._room_password_store) + self._bbs = BbsPanel(put_cmd, self._bbs_service, self._bbs_config_store) + + # Inject DOMCA theme (fonts + CSS variables) + ui.add_head_html(_DOMCA_HEAD) + + # Default to dark mode (DOMCA theme) + dark = ui.dark_mode(True) + dark.on_value_change(lambda e: self._map.set_ui_dark_mode(e.value)) + self._map.set_ui_dark_mode(dark.value) + + # ── Left Drawer (must be created before header for Quasar) ──── + self._drawer = ui.left_drawer(value=False, bordered=True).classes( + 'domca-drawer' + ).style('padding: 0') + + with self._drawer: + # DOMCA branding (clickable β†’ landing page) + with ui.column().style('padding: 0.2rem 1.2rem 0'): + ui.button( + 'DOMCA', + on_click=lambda: self._navigate_panel('landing'), + ).props('flat no-caps').style( + "font-family: 'Exo 2', sans-serif; font-size: 1.4rem; " + "font-weight: 800; color: var(--title); letter-spacing: 4px; " + "margin-bottom: 0.3rem; padding: 0" + ) + + self._menu_buttons = {} + + # ── πŸ’¬ MESSAGES (expandable with channel submenu) ────── + with ui.expansion( + '\U0001f4ac MESSAGES', icon=None, value=False, + ).props('dense header-class="q-pa-none"').classes('w-full'): + self._msg_sub_container = ui.column().classes('w-full gap-0') + with self._msg_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('messages', channel=None) + ) + self._make_sub_btn( + 'DM', lambda: self._navigate_panel('messages', channel='DM') + ) + # Dynamic channel items populated by _update_submenus + + # ── 🏠 ROOMS (expandable with room submenu) ─────────── + with ui.expansion( + '\U0001f3e0 ROOMS', icon=None, value=False, + ).props('dense header-class="q-pa-none"').classes('w-full'): + self._rooms_sub_container = ui.column().classes('w-full gap-0') + with self._rooms_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('rooms') + ) + # Pre-populate from persisted rooms + for entry in self._room_password_store.get_rooms(): + short = entry.name or entry.pubkey[:12] + self._make_sub_btn( + f'\U0001f3e0 {short}', + lambda: self._navigate_panel('rooms'), + ) + + # ── πŸ“š ARCHIVE (expandable with channel submenu) ────── + with ui.expansion( + '\U0001f4da ARCHIVE', icon=None, value=False, + ).props('dense header-class="q-pa-none"').classes('w-full'): + self._archive_sub_container = ui.column().classes('w-full gap-0') + with self._archive_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('archive', channel=None) + ) + self._make_sub_btn( + 'DM', lambda: self._navigate_panel('archive', channel='DM') + ) + # Dynamic channel items populated by _update_submenus + + ui.separator().classes('my-1') + + # ── Standalone menu items (MAP, DEVICE, ACTIONS, RX LOG) + for icon, label, panel_id in _STANDALONE_ITEMS: + btn = ui.button( + f'{icon} {label}', + on_click=lambda pid=panel_id: self._navigate_panel(pid), + ).props('flat no-caps align=left').classes( + 'w-full justify-start domca-menu-btn' + ).style(_MENU_BTN_STYLE) + self._menu_buttons[panel_id] = btn + + ui.separator().classes('my-2') + + # External links (same as domca.nl navigation) + with ui.column().style('padding: 0 1.2rem'): + for label, url in _EXT_LINKS: + ui.link(label, url, new_tab=True).classes( + 'domca-ext-link' + ).style( + "font-family: 'JetBrains Mono', monospace; " + "letter-spacing: 2px; font-size: 0.72rem; " + "text-decoration: none; opacity: 0.6; " + "display: block; padding: 0.35rem 0" + ) + + # Footer in drawer + ui.space() + ui.label(f'\u00a9 2026 {config.OPERATOR_CALLSIGN}').classes('domca-footer').style('padding: 0 1.2rem 1rem') + + # ── Header ──────────────────────────────────────────────── + with ui.header().classes('items-center px-4 py-2 shadow-md'): + menu_btn = ui.button( + icon='menu', + on_click=lambda: self._drawer.toggle(), + ).props('flat round dense color=white') + + # Swap icon: menu ↔ close + self._drawer.on_value_change( + lambda e: menu_btn.props(f'icon={"close" if e.value else "menu"}') + ) + + ui.label(f'\U0001f517 MeshCore v{config.VERSION}').classes( + 'text-lg font-bold ml-2 domca-header-text' + ).style("font-family: 'JetBrains Mono', monospace") + + # Transport mode badge + _is_ble = config.TRANSPORT == "ble" + _badge_icon = 'πŸ”΅' if _is_ble else '🟒' + _badge_label = 'BLE' if _is_ble else 'Serial' + ui.label(f'{_badge_icon} {_badge_label}').classes( + 'text-xs ml-2 domca-header-text' + ).style( + "font-family: 'JetBrains Mono', monospace; " + "opacity: 0.65; letter-spacing: 1px" + ) + + ui.space() + + _initial_status = self._shared.get_snapshot().get('status', 'Starting...') + self._status_label = ui.label(_initial_status).classes( + 'text-sm opacity-70 domca-header-text' + ) + + ui.button( + icon='brightness_6', + on_click=lambda: dark.toggle(), + ).props('flat round dense color=white').tooltip('Toggle dark / light') + + # ── Main Content Area ───────────────────────────────────── + self._panel_containers = {} + + # Landing page (SVG splash from file β€” visible by default) + landing = ui.column().classes('domca-landing w-full') + with landing: + ui.html(_load_landing_svg()) + self._panel_containers['landing'] = landing + + # Panel containers (hidden by default, shown on menu click) + panel_defs = [ + ('messages', self._messages), + ('contacts', self._contacts), + ('map', self._map), + ('device', self._device), + ('actions', self._actions), + ('rxlog', self._rxlog), + ('rooms', self._room_server), + ('bbs', self._bbs), + ] + + for panel_id, panel_obj in panel_defs: + container = ui.column().classes('domca-panel') + container.set_visibility(False) + with container: + panel_obj.render() + self._panel_containers[panel_id] = container + + # Archive panel (inline β€” replaces separate /archive page) + archive_container = ui.column().classes('domca-panel') + archive_container.set_visibility(False) + with archive_container: + self._archive_page = ArchivePage(self._shared) + self._archive_page.render() + self._panel_containers['archive'] = archive_container + + self._active_panel = 'landing' + + # Start update timer + self._apply_url_state() + ui.timer(0.5, self._update_ui) + + # ------------------------------------------------------------------ + # Submenu button helper (layout only) + # ------------------------------------------------------------------ + + @staticmethod + def _make_sub_btn(label: str, on_click) -> ui.button: + """Create a submenu button in the drawer.""" + return ui.button( + label, + on_click=on_click, + ).props('flat no-caps align=left').classes( + 'w-full justify-start domca-sub-btn' + ).style(_SUB_BTN_STYLE) + + # ------------------------------------------------------------------ + # Dynamic submenu updates (layout β€” called from _update_ui) + # ------------------------------------------------------------------ + + def _update_submenus(self, data: dict) -> None: + """Rebuild channel/room submenu items when data changes. + + Only the dynamic items are rebuilt; the container is cleared and + ALL items (static + dynamic) are re-rendered. + """ + # ── Channel submenus (Messages + Archive) ── + channels = data.get('channels', []) + ch_fingerprint = tuple((ch['idx'], ch['name']) for ch in channels) + + if ch_fingerprint != self._last_channel_fingerprint and channels: + self._last_channel_fingerprint = ch_fingerprint + + # Rebuild Messages submenu + if self._msg_sub_container: + self._msg_sub_container.clear() + with self._msg_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('messages', channel=None) + ) + self._make_sub_btn( + 'DM', lambda: self._navigate_panel('messages', channel='DM') + ) + for ch in channels: + idx = ch['idx'] + name = ch['name'] + self._make_sub_btn( + f"[{idx}] {name}", + lambda i=idx: self._navigate_panel('messages', channel=i), + ) + + # Rebuild Archive submenu + if self._archive_sub_container: + self._archive_sub_container.clear() + with self._archive_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('archive', channel=None) + ) + self._make_sub_btn( + 'DM', lambda: self._navigate_panel('archive', channel='DM') + ) + for ch in channels: + idx = ch['idx'] + name = ch['name'] + self._make_sub_btn( + f"[{idx}] {name}", + lambda n=name: self._navigate_panel('archive', channel=n), + ) + + # ── Room submenus ── + rooms = self._room_password_store.get_rooms() + rooms_fingerprint = tuple((r.pubkey, r.name) for r in rooms) + + if rooms_fingerprint != self._last_rooms_fingerprint: + self._last_rooms_fingerprint = rooms_fingerprint + + if self._rooms_sub_container: + self._rooms_sub_container.clear() + with self._rooms_sub_container: + self._make_sub_btn( + 'ALL', lambda: self._navigate_panel('rooms') + ) + for entry in rooms: + short = entry.name or entry.pubkey[:12] + self._make_sub_btn( + f'\U0001f3e0 {short}', + lambda: self._navigate_panel('rooms'), + ) + + # ------------------------------------------------------------------ + # Panel switching (layout helper β€” no functional logic) + # ------------------------------------------------------------------ + + def _apply_url_state(self) -> None: + """Apply panel selection from URL query params on first render.""" + try: + params = ui.context.client.request.query_params + except Exception: + return + + panel = params.get('panel') or 'landing' + channel = params.get('channel') + + if panel not in self._panel_containers: + panel = 'landing' + channel = None + + if panel == 'messages': + if channel is None or channel.lower() == 'all': + channel = None + elif channel.upper() == 'DM': + channel = 'DM' + else: + channel = int(channel) if channel.isdigit() else None + elif panel == 'archive': + if channel is None or channel.lower() == 'all': + channel = None + elif channel.upper() == 'DM': + channel = 'DM' + else: + channel = None + + self._show_panel(panel, channel) + + def _build_panel_url(self, panel_id: str, channel=None) -> str: + params = {'panel': panel_id} + if channel is not None: + params['channel'] = str(channel) + return '/?' + urlencode(params) + + def _navigate_panel(self, panel_id: str, channel=None) -> None: + """Navigate with panel id in the URL so browser back restores state.""" + ui.navigate.to(self._build_panel_url(panel_id, channel)) + + def _show_panel(self, panel_id: str, channel=None) -> None: + """Show the selected panel, hide all others, close the drawer. + + Args: + panel_id: Panel to show (e.g. 'messages', 'archive', 'rooms'). + channel: Optional channel filter. + For messages: None=all, 'DM'=DM only, int=channel idx. + For archive: None=all, 'DM'=DM only, str=channel name. + """ + for pid, container in self._panel_containers.items(): + container.set_visibility(pid == panel_id) + self._active_panel = panel_id + + # Apply channel filter to messages panel + if panel_id == 'messages' and self._messages: + self._messages.set_active_channel(channel) + + # Apply channel filter to archive panel + if panel_id == 'archive' and self._archive_page: + self._archive_page.set_channel_filter(channel) + + self._refresh_active_panel_now(force_map_center=(panel_id == 'map')) + + # Update active menu highlight (standalone buttons only) + for pid, btn in self._menu_buttons.items(): + if pid == panel_id: + btn.classes('domca-menu-active', remove='') + else: + btn.classes(remove='domca-menu-active') + + # Close drawer after selection + if self._drawer: + self._drawer.hide() + + def _refresh_active_panel_now(self, force_map_center: bool = False) -> None: + """Refresh only the currently visible panel. + + This is used directly after a panel switch so the user does not + need to wait for the next 500 ms dashboard tick. + """ + data = self._shared.get_snapshot() + + if data.get('channels'): + self._messages.update_filters(data) + self._messages.update_channel_options(data['channels']) + self._update_submenus(data) + + if self._active_panel == 'device': + self._device.update(data) + elif self._active_panel == 'map': + if force_map_center: + data['force_center'] = True + self._map.update(data) + elif self._active_panel == 'actions': + self._actions.update(data) + elif self._active_panel == 'contacts': + self._contacts.update(data) + elif self._active_panel == 'messages': + self._messages.update( + data, + self._messages.channel_filters, + self._messages.last_channels, + room_pubkeys=( + self._room_server.get_room_pubkeys() + if self._room_server else None + ), + ) + elif self._active_panel == 'rooms': + self._room_server.update(data) + elif self._active_panel == 'rxlog': + self._rxlog.update(data) + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) + + # ------------------------------------------------------------------ + # Room Server callback (from ContactsPanel) + # ------------------------------------------------------------------ + + def _on_add_room_server(self, pubkey: str, name: str, password: str) -> None: + """Handle adding a Room Server from the contacts panel. + + Delegates to the RoomServerPanel which persists the entry, + creates the UI card and sends the login command. + """ + if self._room_server: + self._room_server.add_room(pubkey, name, password) + + # ------------------------------------------------------------------ + # Timer-driven UI update + # ------------------------------------------------------------------ + + def _update_ui(self) -> None: + try: + if not self._status_label: + return + + # Atomic snapshot + flag clear: eliminates race condition + # where worker sets channels_updated between separate + # get_snapshot() and clear_update_flags() calls. + data = self._shared.get_snapshot_and_clear_flags() + is_first = not self._initialized + + # Mark initialised immediately β€” even if a panel update + # crashes below, we must NOT retry the full first-render + # path every 500 ms (that causes the infinite rebuild). + if is_first: + self._initialized = True + + # Always update status + self._status_label.text = data['status'] + + # Channel-dependent drawer/submenu state may stay global. + # The helpers below already contain equality checks, so this + # remains cheap while keeping navigation consistent. + if data['channels']: + self._messages.update_filters(data) + self._messages.update_channel_options(data['channels']) + self._update_submenus(data) + + if self._active_panel == 'device': + if data['device_updated'] or is_first: + self._device.update(data) + + elif self._active_panel == 'map': + # Keep sending snapshots while the map panel is active. + # The browser runtime coalesces pending payloads, so only + # the newest snapshot is applied. + self._map.update(data) + + elif self._active_panel == 'actions': + if data['channels_updated'] or is_first: + self._actions.update(data) + + elif self._active_panel == 'contacts': + if data['contacts_updated'] or is_first: + self._contacts.update(data) + + elif self._active_panel == 'messages': + self._messages.update( + data, + self._messages.channel_filters, + self._messages.last_channels, + room_pubkeys=( + self._room_server.get_room_pubkeys() + if self._room_server else None + ), + ) + + elif self._active_panel == 'rooms': + self._room_server.update(data) + + elif self._active_panel == 'rxlog': + if data['rxlog_updated'] or is_first: + self._rxlog.update(data) + + elif self._active_panel == 'bbs': + if self._bbs: + self._bbs.update(data) + + # Signal worker that GUI is ready for data + if is_first and data['channels'] and data['contacts']: + self._shared.mark_gui_initialized() + + except Exception as e: + err = str(e).lower() + if "deleted" not in err and "client" not in err: + import traceback + print(f"GUI update error: {e}") + traceback.print_exc() diff --git a/meshcore_gui/meshcore_gui/gui/panels/__init__.py b/meshcore_gui/meshcore_gui/gui/panels/__init__.py new file mode 100644 index 0000000..f9245f4 --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/panels/__init__.py @@ -0,0 +1,18 @@ +""" +Individual dashboard panels β€” each panel is a single-responsibility class. + +Re-exports all panels for convenient importing:: + + from meshcore_gui.gui.panels import DevicePanel, ContactsPanel, ... +""" + +from meshcore_gui.gui.panels.device_panel import DevicePanel # noqa: F401 +from meshcore_gui.gui.panels.contacts_panel import ContactsPanel # noqa: F401 +from meshcore_gui.gui.panels.map_panel import MapPanel # noqa: F401 +from meshcore_gui.gui.panels.input_panel import InputPanel # noqa: F401 +from meshcore_gui.gui.panels.filter_panel import FilterPanel # noqa: F401 +from meshcore_gui.gui.panels.messages_panel import MessagesPanel # noqa: F401 +from meshcore_gui.gui.panels.actions_panel import ActionsPanel # noqa: F401 +from meshcore_gui.gui.panels.rxlog_panel import RxLogPanel # noqa: F401 +from meshcore_gui.gui.panels.room_server_panel import RoomServerPanel # noqa: F401 +from meshcore_gui.gui.panels.bbs_panel import BbsPanel # noqa: F401 diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py new file mode 100644 index 0000000..0282a59 --- /dev/null +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -0,0 +1,535 @@ +"""BBS panel -- board-based Bulletin Board System viewer and configuration.""" + +import re +from typing import Callable, Dict, List, Optional + +from nicegui import ui + +from meshcore_gui.config import debug_print +from meshcore_gui.services.bbs_config_store import ( + BbsBoard, + BbsConfigStore, + DEFAULT_CATEGORIES, + DEFAULT_RETENTION_HOURS, +) +from meshcore_gui.services.bbs_service import BbsMessage, BbsService + + +def _slug(name: str) -> str: + """Convert a board name to a safe id slug. + + Args: + name: Human-readable board name. + + Returns: + Lowercase alphanumeric + underscore string. + """ + return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" + + +class BbsPanel: + """BBS panel: board selector, filters, message list, post form and settings. + + The settings section lets users create, configure and delete boards. + Each board can span one or more device channels (from SharedData). + Configuration is persisted via BbsConfigStore. + + Args: + put_command: Callable to enqueue a command dict for the worker. + bbs_service: Shared BbsService instance. + config_store: Shared BbsConfigStore instance. + """ + + def __init__( + self, + put_command: Callable[[Dict], None], + bbs_service: BbsService, + config_store: BbsConfigStore, + ) -> None: + self._put_command = put_command + self._service = bbs_service + self._config_store = config_store + + # Active view state + self._active_board: Optional[BbsBoard] = None + self._active_region: Optional[str] = None + self._active_category: Optional[str] = None + + # UI refs -- message view + self._board_btn_row = None + self._region_row = None + self._region_select = None + self._category_select = None + self._text_input = None + self._post_region_row = None + self._post_region_select = None + self._post_category_select = None + self._msg_list_container = None + + # UI refs -- settings + self._boards_settings_container = None + self._new_board_name_input = None + self._new_board_channel_checks: Dict[int, object] = {} + + # Cached device channels (updated by update()) + self._device_channels: List[Dict] = [] + self._last_ch_fingerprint: tuple = () + + # ------------------------------------------------------------------ + # Render + # ------------------------------------------------------------------ + + def render(self) -> None: + """Build the complete BBS panel layout.""" + # ---- Message view card -------------------------------------- + with ui.card().classes('w-full'): + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + + self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + + ui.separator() + + with ui.row().classes('w-full items-center gap-4 flex-wrap'): + ui.label('Filter:').classes('text-sm text-gray-600') + + self._region_row = ui.row().classes('items-center gap-2') + with self._region_row: + ui.label('Region:').classes('text-xs text-gray-600') + self._region_select = ui.select( + options=[], value=None, + on_change=lambda e: self._on_region_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + with ui.row().classes('items-center gap-2'): + ui.label('Category:').classes('text-xs text-gray-600') + self._category_select = ui.select( + options=[], value=None, + on_change=lambda e: self._on_category_filter(e.value), + ).classes('text-xs').style('min-width: 120px') + + ui.button( + 'Refresh', on_click=self._refresh_messages, + ).props('flat no-caps').classes('text-xs') + + ui.separator() + + self._msg_list_container = ui.column().classes( + 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + ) + + ui.separator() + + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('Post:').classes('text-sm text-gray-600') + + self._post_region_row = ui.row().classes('items-center gap-1') + with self._post_region_row: + self._post_region_select = ui.select( + options=[], label='Region', + ).classes('text-xs').style('min-width: 110px') + + self._post_category_select = ui.select( + options=[], label='Category', + ).classes('text-xs').style('min-width: 110px') + + self._text_input = ui.input( + placeholder='Message text...', + ).classes('flex-grow text-sm') + + ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') + + # ---- Settings card ------------------------------------------ + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.label( + 'Create boards and assign device channels. ' + 'One board can cover multiple channels.' + ).classes('text-xs text-gray-500') + ui.separator() + + # New board form + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('New board:').classes('text-sm text-gray-600') + self._new_board_name_input = ui.input( + placeholder='Board name...', + ).classes('text-xs').style('min-width: 160px') + ui.button( + 'Create', on_click=self._on_create_board, + ).props('no-caps').classes('text-xs') + + ui.separator() + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + ui.label('No boards configured yet.').classes( + 'text-xs text-gray-400 italic' + ) + + # Initial render + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # Board selector (message view) + # ------------------------------------------------------------------ + + def _rebuild_board_buttons(self) -> None: + """Rebuild board selector buttons from current config.""" + if not self._board_btn_row: + return + self._board_btn_row.clear() + boards = self._config_store.get_boards() + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + if not boards: + ui.label('No boards configured.').classes( + 'text-xs text-gray-400 italic' + ) + return + for board in boards: + ui.button( + board.name, + on_click=lambda b=board: self._select_board(b), + ).props('flat no-caps').classes('text-xs') + + # Auto-select first board if none active or active was deleted + ids = [b.id for b in boards] + if boards and (self._active_board is None or self._active_board.id not in ids): + self._select_board(boards[0]) + + def _select_board(self, board: BbsBoard) -> None: + """Activate a board and rebuild filter selects. + + Args: + board: Board to activate. + """ + self._active_board = board + self._active_region = None + self._active_category = None + + has_regions = bool(board.regions) + if self._region_row: + self._region_row.set_visibility(has_regions) + if self._post_region_row: + self._post_region_row.set_visibility(has_regions) + + region_opts = ['(all)'] + board.regions + if self._region_select: + self._region_select.options = region_opts + self._region_select.value = '(all)' + if self._post_region_select: + self._post_region_select.options = board.regions + self._post_region_select.value = board.regions[0] if board.regions else None + + cat_opts = ['(all)'] + board.categories + if self._category_select: + self._category_select.options = cat_opts + self._category_select.value = '(all)' + if self._post_category_select: + self._post_category_select.options = board.categories + self._post_category_select.value = board.categories[0] if board.categories else None + + self._refresh_messages() + + # ------------------------------------------------------------------ + # Filters + # ------------------------------------------------------------------ + + def _on_region_filter(self, value: Optional[str]) -> None: + self._active_region = None if (not value or value == '(all)') else value + self._refresh_messages() + + def _on_category_filter(self, value: Optional[str]) -> None: + self._active_category = None if (not value or value == '(all)') else value + self._refresh_messages() + + # ------------------------------------------------------------------ + # Message list + # ------------------------------------------------------------------ + + def _refresh_messages(self) -> None: + if not self._msg_list_container: + return + self._msg_list_container.clear() + with self._msg_list_container: + if self._active_board is None: + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') + return + if not self._active_board.channels: + ui.label('No channels assigned to this board.').classes( + 'text-xs text-gray-400 italic' + ) + return + messages = self._service.get_all_messages( + channels=self._active_board.channels, + region=self._active_region, + category=self._active_category, + ) + if not messages: + ui.label('No messages.').classes('text-xs text-gray-400 italic') + return + for msg in messages: + self._render_message_row(msg) + + def _render_message_row(self, msg: BbsMessage) -> None: + ts = msg.timestamp[:16].replace('T', ' ') + region_label = f' [{msg.region}]' if msg.region else '' + header = f'{ts} {msg.sender} [{msg.category}]{region_label}' + with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500') + ui.label(msg.text).classes('text-sm') + + # ------------------------------------------------------------------ + # Post + # ------------------------------------------------------------------ + + def _on_post(self) -> None: + if self._active_board is None: + ui.notify('Select a board first.', type='warning') + return + if not self._active_board.channels: + ui.notify('No channels assigned to this board.', type='warning') + return + + text = (self._text_input.value or '').strip() if self._text_input else '' + if not text: + ui.notify('Message text cannot be empty.', type='warning') + return + + category = ( + self._post_category_select.value if self._post_category_select + else (self._active_board.categories[0] if self._active_board.categories else '') + ) + if not category: + ui.notify('Please select a category.', type='warning') + return + + region = '' + if self._active_board.regions and self._post_region_select: + region = self._post_region_select.value or '' + + # Post on first assigned channel (primary channel for outgoing) + target_channel = self._active_board.channels[0] + + msg = BbsMessage( + channel=target_channel, + region=region, category=category, + sender='Me', sender_key='', text=text, + ) + self._service.post_message(msg) + + region_part = f'{region} ' if region else '' + mesh_text = f'!bbs post {region_part}{category} {text}' + self._put_command({ + 'action': 'send_message', + 'channel': target_channel, + 'text': mesh_text, + }) + debug_print( + f'BBS panel: posted to board={self._active_board.id} ' + f'ch={target_channel} {mesh_text[:60]}' + ) + + if self._text_input: + self._text_input.value = '' + self._refresh_messages() + ui.notify('Message posted.', type='positive') + + # ------------------------------------------------------------------ + # Settings -- board list + # ------------------------------------------------------------------ + + def _rebuild_boards_settings(self) -> None: + """Rebuild the settings section for all configured boards.""" + if not self._boards_settings_container: + return + self._boards_settings_container.clear() + boards = self._config_store.get_boards() + with self._boards_settings_container: + if not boards: + ui.label('No boards configured yet.').classes( + 'text-xs text-gray-400 italic' + ) + return + for board in boards: + self._render_board_settings_row(board) + + def _render_board_settings_row(self, board: BbsBoard) -> None: + """Render one settings expansion for a single board. + + Args: + board: Board to render. + """ + with ui.expansion( + board.name, value=False, + ).classes('w-full').props('dense'): + with ui.column().classes('w-full gap-2 p-2'): + + # Name + name_input = ui.input( + label='Board name', value=board.name, + ).classes('w-full text-xs') + + # Channel assignment + ui.label('Channels (select which device channels belong to this board):').classes( + 'text-xs text-gray-600' + ) + ch_checks: Dict[int, object] = {} + with ui.row().classes('flex-wrap gap-2'): + if not self._device_channels: + ui.label('No device channels known yet.').classes( + 'text-xs text-gray-400 italic' + ) + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {ch_name}', + value=idx in board.channels, + ).classes('text-xs') + ch_checks[idx] = cb + + # Categories + cats_input = ui.input( + label='Categories (comma-separated)', + value=', '.join(board.categories), + ).classes('w-full text-xs') + + # Regions + regions_input = ui.input( + label='Regions (comma-separated, leave empty for none)', + value=', '.join(board.regions), + ).classes('w-full text-xs') + + # Retention + retention_input = ui.input( + label='Retention (hours)', + value=str(board.retention_hours), + ).classes('text-xs').style('max-width: 160px') + + # Whitelist + wl_input = ui.input( + label='Allowed keys (comma-separated hex, empty = all)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + with ui.row().classes('gap-2'): + def _save( + bid=board.id, + ni=name_input, + cc=ch_checks, + ci=cats_input, + ri=regions_input, + ret=retention_input, + wli=wl_input, + ) -> None: + new_name = (ni.value or '').strip() or bid + selected_channels = [ + idx for idx, cb in cc.items() if cb.value + ] + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + regions = [ + r.strip() + for r in (ri.value or '').split(',') if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wli.value or '').split(',') if k.strip() + ] + updated = BbsBoard( + id=bid, + name=new_name, + channels=selected_channels, + categories=categories, + regions=regions, + retention_hours=retention_hours, + allowed_keys=allowed_keys, + ) + self._config_store.set_board(updated) + debug_print( + f'BBS settings: saved board {bid} ' + f'channels={selected_channels}' + ) + ui.notify(f'Board "{new_name}" saved.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + def _delete(bid=board.id, bname=board.name) -> None: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: deleted board {bid}') + ui.notify(f'Board "{bname}" deleted.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + ui.button( + 'Delete', on_click=_delete, + ).props('no-caps flat color=negative').classes('text-xs') + + # ------------------------------------------------------------------ + # Settings -- create new board + # ------------------------------------------------------------------ + + def _on_create_board(self) -> None: + """Handle the Create button for a new board.""" + name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' + if not name: + ui.notify('Enter a board name first.', type='warning') + return + + board_id = _slug(name) + # Make id unique if needed + base_id = board_id + counter = 2 + while self._config_store.board_id_exists(board_id): + board_id = f'{base_id}_{counter}' + counter += 1 + + board = BbsBoard( + id=board_id, + name=name, + channels=[], + categories=list(DEFAULT_CATEGORIES), + regions=[], + retention_hours=DEFAULT_RETENTION_HOURS, + allowed_keys=[], + ) + self._config_store.set_board(board) + debug_print(f'BBS settings: created board {board_id}') + if self._new_board_name_input: + self._new_board_name_input.value = '' + ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # External update hook + # ------------------------------------------------------------------ + + def update(self, data: Dict) -> None: + """Called by the dashboard timer with the SharedData snapshot. + + Rebuilds the settings channel checkboxes when the device channel + list changes. + + Args: + data: SharedData snapshot dict. + """ + device_channels = data.get('channels', []) + fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels + ) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_boards_settings() diff --git a/meshcore_gui/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/meshcore_gui/services/bbs_config_store.py new file mode 100644 index 0000000..c05727b --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bbs_config_store.py @@ -0,0 +1,302 @@ +""" +BBS board configuration store for MeshCore GUI. + +Persists BBS board configuration to +``~/.meshcore-gui/bbs/bbs_config.json``. + +A **board** groups one or more MeshCore channel indices into a single +bulletin board. Messages posted on any of the board's channels are +visible in the board view. This supports two usage patterns: + +- One board per channel (classic per-channel BBS) +- One board spanning multiple channels (shared bulletin board) + +Config version history +~~~~~~~~~~~~~~~~~~~~~~ +v1 β€” per-channel config (list of channels with enabled flag). +v2 β€” board-based config (list of boards, each with a channels list). + Automatic migration from v1 on first load. + +Thread safety +~~~~~~~~~~~~~ +All public methods acquire an internal ``threading.Lock``. +""" + +import json +import threading +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +# --------------------------------------------------------------------------- +# Storage +# --------------------------------------------------------------------------- + +BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" +BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" + +CONFIG_VERSION: int = 2 + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] +DEFAULT_REGIONS: List[str] = [] +DEFAULT_RETENTION_HOURS: int = 48 + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsBoard: + """A BBS board grouping one or more MeshCore channels. + + Attributes: + id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``). + name: Human-readable board name. + channels: List of MeshCore channel indices assigned to this board. + categories: Valid category tags for this board. + regions: Optional region tags; empty = no region filtering. + retention_hours: Message retention period in hours. + allowed_keys: Sender public key whitelist (empty = all allowed). + """ + + id: str + name: str + channels: List[int] = field(default_factory=list) + categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES)) + regions: List[str] = field(default_factory=list) + retention_hours: int = DEFAULT_RETENTION_HOURS + allowed_keys: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict: + """Serialise to a JSON-compatible dict.""" + return { + "id": self.id, + "name": self.name, + "channels": list(self.channels), + "categories": list(self.categories), + "regions": list(self.regions), + "retention_hours": self.retention_hours, + "allowed_keys": list(self.allowed_keys), + } + + @staticmethod + def from_dict(d: Dict) -> "BbsBoard": + """Deserialise from a config dict.""" + return BbsBoard( + id=d.get("id", ""), + name=d.get("name", ""), + channels=list(d.get("channels", [])), + categories=list(d.get("categories", DEFAULT_CATEGORIES)), + regions=list(d.get("regions", [])), + retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(d.get("allowed_keys", [])), + ) + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + +class BbsConfigStore: + """Persistent store for BBS board configuration. + + Args: + config_path: Path to the JSON config file. + Defaults to ``~/.meshcore-gui/bbs/bbs_config.json``. + """ + + def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: + self._path = config_path + self._lock = threading.Lock() + self._boards: List[BbsBoard] = [] + self._load() + + # ------------------------------------------------------------------ + # Load / save + # ------------------------------------------------------------------ + + def _load(self) -> None: + """Load config from disk; migrate v1 β†’ v2 if needed.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + + if not self._path.exists(): + self._save_unlocked() + debug_print("BBS config: created new config file (v2)") + return + + try: + raw = self._path.read_text(encoding="utf-8") + data = json.loads(raw) + version = data.get("version", 1) + + if version == CONFIG_VERSION: + self._boards = [ + BbsBoard.from_dict(b) for b in data.get("boards", []) + ] + debug_print(f"BBS config: loaded {len(self._boards)} boards") + + elif version == 1: + # Migrate: each v1 channel β†’ one board + self._boards = self._migrate_v1(data.get("channels", [])) + self._save_unlocked() + debug_print( + f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" + ) + else: + debug_print( + f"BBS config: unknown version {version}, using empty config" + ) + + except (json.JSONDecodeError, OSError) as exc: + debug_print(f"BBS config: load error ({exc}), using empty config") + + @staticmethod + def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]: + """Convert v1 per-channel entries to v2 boards. + + Only enabled channels are migrated. + + Args: + v1_channels: List of v1 channel config dicts. + + Returns: + List of ``BbsBoard`` instances. + """ + boards = [] + for ch in v1_channels: + if not ch.get("enabled", False): + continue + idx = ch.get("channel", 0) + board_id = f"ch{idx}" + boards.append(BbsBoard( + id=board_id, + name=ch.get("name", f"Channel {idx}"), + channels=[idx], + categories=list(ch.get("categories", DEFAULT_CATEGORIES)), + regions=list(ch.get("regions", [])), + retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(ch.get("allowed_keys", [])), + )) + return boards + + def _save_unlocked(self) -> None: + """Write config to disk. MUST be called with self._lock held.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + data = { + "version": CONFIG_VERSION, + "boards": [b.to_dict() for b in self._boards], + } + tmp = self._path.with_suffix(".tmp") + tmp.write_text( + json.dumps(data, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + tmp.replace(self._path) + + def save(self) -> None: + """Flush current configuration to disk.""" + with self._lock: + self._save_unlocked() + + # ------------------------------------------------------------------ + # Board queries + # ------------------------------------------------------------------ + + def get_boards(self) -> List[BbsBoard]: + """Return a copy of all configured boards. + + Returns: + List of ``BbsBoard`` instances. + """ + with self._lock: + return list(self._boards) + + def get_board(self, board_id: str) -> Optional[BbsBoard]: + """Return a board by its id, or ``None``. + + Args: + board_id: Board identifier string. + + Returns: + ``BbsBoard`` instance or ``None``. + """ + with self._lock: + for b in self._boards: + if b.id == board_id: + return BbsBoard.from_dict(b.to_dict()) + return None + + def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: + """Return the first board that includes *channel_idx*, or ``None``. + + Used by ``BbsCommandHandler`` to route incoming mesh commands. + + Args: + channel_idx: MeshCore channel index. + + Returns: + ``BbsBoard`` instance or ``None``. + """ + with self._lock: + for b in self._boards: + if channel_idx in b.channels: + return BbsBoard.from_dict(b.to_dict()) + return None + + # ------------------------------------------------------------------ + # Board management + # ------------------------------------------------------------------ + + def set_board(self, board: BbsBoard) -> None: + """Insert or replace a board (matched by ``board.id``). + + Args: + board: ``BbsBoard`` to persist. + """ + with self._lock: + for i, b in enumerate(self._boards): + if b.id == board.id: + self._boards[i] = BbsBoard.from_dict(board.to_dict()) + self._save_unlocked() + debug_print(f"BBS config: updated board '{board.id}'") + return + self._boards.append(BbsBoard.from_dict(board.to_dict())) + self._save_unlocked() + debug_print(f"BBS config: added board '{board.id}'") + + def delete_board(self, board_id: str) -> bool: + """Remove a board by id. + + Args: + board_id: Board identifier to remove. + + Returns: + ``True`` if removed, ``False`` if not found. + """ + with self._lock: + before = len(self._boards) + self._boards = [b for b in self._boards if b.id != board_id] + if len(self._boards) < before: + self._save_unlocked() + debug_print(f"BBS config: deleted board '{board_id}'") + return True + return False + + def board_id_exists(self, board_id: str) -> bool: + """Check whether a board id is already in use. + + Args: + board_id: Board identifier to check. + + Returns: + ``True`` if a board with this id exists. + """ + with self._lock: + return any(b.id == board_id for b in self._boards) diff --git a/meshcore_gui/meshcore_gui/services/bbs_service.py b/meshcore_gui/meshcore_gui/services/bbs_service.py new file mode 100644 index 0000000..52b7968 --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bbs_service.py @@ -0,0 +1,468 @@ +""" +Offline Bulletin Board System (BBS) service for MeshCore GUI. + +Stores BBS messages in a local SQLite database. Messages are keyed by +their originating MeshCore channel index. A **board** (see +:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or +more channel indices to a single bulletin board, so queries are always +issued as ``WHERE channel IN (...)``. + +Architecture +~~~~~~~~~~~~ +- ``BbsService`` -- persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. + +Thread safety +~~~~~~~~~~~~~ +SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by +multiple application instances (e.g. 800 MHz + 433 MHz on one Pi). + +Storage +~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) +""" + +import sqlite3 +import threading +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Dict, List, Optional + +from meshcore_gui.config import debug_print + +BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" +BBS_DB_PATH = BBS_DIR / "bbs_messages.db" + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsMessage: + """A single BBS message. + + Attributes: + id: Database row id (``None`` before insert). + channel: MeshCore channel index the message arrived on. + region: Region tag (empty string when board has no regions). + category: Category tag. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Message body. + timestamp: UTC ISO-8601 timestamp string. + """ + + channel: int + region: str + category: str + sender: str + sender_key: str + text: str + timestamp: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + id: Optional[int] = None + + +# --------------------------------------------------------------------------- +# Service +# --------------------------------------------------------------------------- + +class BbsService: + """SQLite-backed BBS storage service. + + Args: + db_path: Path to the SQLite database file. + """ + + def __init__(self, db_path: Path = BBS_DB_PATH) -> None: + self._db_path = db_path + self._lock = threading.Lock() + self._init_db() + + def _init_db(self) -> None: + """Create the database directory and schema if not present.""" + BBS_DIR.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=3000") + conn.execute(""" + CREATE TABLE IF NOT EXISTS bbs_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel INTEGER NOT NULL, + region TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL, + sender TEXT NOT NULL, + sender_key TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL, + timestamp TEXT NOT NULL + ) + """) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_channel ON bbs_messages(channel)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_timestamp ON bbs_messages(timestamp)" + ) + conn.commit() + debug_print(f"BBS: database ready at {self._db_path}") + + def _connect(self) -> sqlite3.Connection: + return sqlite3.connect(str(self._db_path), check_same_thread=False) + + # ------------------------------------------------------------------ + # Write + # ------------------------------------------------------------------ + + def post_message(self, msg: BbsMessage) -> int: + """Insert a BBS message and return its row id. + + Args: + msg: ``BbsMessage`` dataclass to persist. + + Returns: + Assigned ``rowid`` (also set on ``msg.id``). + """ + with self._lock: + with self._connect() as conn: + cur = conn.execute( + """INSERT INTO bbs_messages + (channel, region, category, sender, sender_key, text, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (msg.channel, msg.region, msg.category, + msg.sender, msg.sender_key, msg.text, msg.timestamp), + ) + conn.commit() + msg.id = cur.lastrowid + debug_print( + f"BBS: posted id={msg.id} ch={msg.channel} " + f"cat={msg.category} sender={msg.sender}" + ) + return msg.id + + # ------------------------------------------------------------------ + # Read (channels is a list to support multi-channel boards) + # ------------------------------------------------------------------ + + def get_messages( + self, + channels: List[int], + region: Optional[str] = None, + category: Optional[str] = None, + limit: int = 5, + ) -> List[BbsMessage]: + """Return the *limit* most recent messages for a set of channels. + + Args: + channels: MeshCore channel indices to query (board's channel list). + region: Optional region filter. + category: Optional category filter. + limit: Maximum number of messages to return. + + Returns: + List of ``BbsMessage`` objects, newest first. + """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) + query = ( + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" + ) + params: list = list(channels) + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + return [self._row_to_msg(r) for r in rows] + + def get_all_messages( + self, + channels: List[int], + region: Optional[str] = None, + category: Optional[str] = None, + ) -> List[BbsMessage]: + """Return all messages for a set of channels (oldest first). + + Args: + channels: MeshCore channel indices to query. + region: Optional region filter. + category: Optional category filter. + + Returns: + List of ``BbsMessage`` objects, oldest first. + """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) + query = ( + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" + ) + params: list = list(channels) + if region: + query += " AND region = ?" + params.append(region) + if category: + query += " AND category = ?" + params.append(category) + query += " ORDER BY timestamp ASC" + + with self._lock: + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + return [self._row_to_msg(r) for r in rows] + + @staticmethod + def _row_to_msg(row: tuple) -> BbsMessage: + return BbsMessage( + id=row[0], channel=row[1], region=row[2], category=row[3], + sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7], + ) + + # ------------------------------------------------------------------ + # Retention + # ------------------------------------------------------------------ + + def purge_expired(self, channels: List[int], retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a set of channels. + + Args: + channels: MeshCore channel indices to purge. + retention_hours: Messages older than this are deleted. + + Returns: + Number of rows deleted. + """ + if not channels: + return 0 + cutoff = ( + datetime.now(timezone.utc) - timedelta(hours=retention_hours) + ).isoformat() + placeholders = ",".join("?" * len(channels)) + with self._lock: + with self._connect() as conn: + cur = conn.execute( + f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?", + list(channels) + [cutoff], + ) + conn.commit() + deleted = cur.rowcount + if deleted: + debug_print( + f"BBS: purged {deleted} expired messages from ch={channels}" + ) + return deleted + + def purge_all_expired(self, boards) -> None: + """Run retention cleanup for all boards. + + Args: + boards: Iterable of ``BbsBoard`` instances. + """ + for board in boards: + self.purge_expired(board.channels, board.retention_hours) + + +# --------------------------------------------------------------------------- +# Command handler +# --------------------------------------------------------------------------- + +class BbsCommandHandler: + """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + + Looks up the board for the incoming channel via ``BbsConfigStore`` + so that a single board spanning multiple channels handles commands + from all of them. + + Args: + service: Shared ``BbsService`` instance. + config_store: ``BbsConfigStore`` instance for live board config. + """ + + READ_LIMIT: int = 5 + + def __init__(self, service: BbsService, config_store) -> None: + self._service = service + self._config_store = config_store + + # ------------------------------------------------------------------ + # Public entry point + # ------------------------------------------------------------------ + + def handle( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Parse an incoming message and return a reply string (or ``None``). + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string, or ``None`` if no reply should be sent. + """ + text = (text or "").strip() + if not text.lower().startswith("!bbs"): + return None + + board = self._config_store.get_board_for_channel(channel_idx) + if board is None: + return None + + # Whitelist check + if board.allowed_keys and sender_key not in board.allowed_keys: + debug_print( + f"BBS: silently dropping msg from {sender} " + f"(key not in whitelist for board '{board.id}')" + ) + return None + + parts = text.split(None, 1) + args = parts[1].strip() if len(parts) > 1 else "" + return self._dispatch(board, channel_idx, sender, sender_key, args) + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + + def _dispatch(self, board, channel_idx, sender, sender_key, args): + sub = args.split(None, 1)[0].lower() if args else "" + rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" + if sub == "post": + return self._handle_post(board, channel_idx, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown command '{sub}'. {self._handle_help(board)}" + + # ------------------------------------------------------------------ + # post + # ------------------------------------------------------------------ + + def _handle_post(self, board, channel_idx, sender, sender_key, args): + regions = board.regions + categories = board.categories + tokens = args.split(None, 2) if args else [] + + if regions: + if len(tokens) < 3: + return ( + f"Usage: !bbs post [region] [category] [text] | " + f"Regions: {', '.join(regions)} | " + f"Categories: {', '.join(categories)}" + ) + region, category, text = tokens[0], tokens[1], tokens[2] + valid_r = [r.upper() for r in regions] + if region.upper() not in valid_r: + return f"Invalid region '{region}'. Valid: {', '.join(regions)}" + region = regions[valid_r.index(region.upper())] + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" + category = categories[valid_c.index(category.upper())] + else: + if len(tokens) < 2: + return ( + f"Usage: !bbs post [category] [text] | " + f"Categories: {', '.join(categories)}" + ) + region = "" + category, text = tokens[0], tokens[1] + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: + return f"Invalid category '{category}'. Valid: {', '.join(categories)}" + category = categories[valid_c.index(category.upper())] + + msg = BbsMessage( + channel=channel_idx, + region=region, category=category, + sender=sender, sender_key=sender_key, text=text, + ) + self._service.post_message(msg) + region_label = f" [{region}]" if region else "" + return f"Posted [{category}]{region_label}: {text[:60]}" + + # ------------------------------------------------------------------ + # read + # ------------------------------------------------------------------ + + def _handle_read(self, board, args): + regions = board.regions + categories = board.categories + tokens = args.split() if args else [] + region = None + category = None + + if regions: + valid_r = [r.upper() for r in regions] + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_r: + region = regions[valid_r.index(tokens[0].upper())] + if len(tokens) >= 2: + if tokens[1].upper() in valid_c: + category = categories[valid_c.index(tokens[1].upper())] + else: + return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" + else: + return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" + else: + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_c: + category = categories[valid_c.index(tokens[0].upper())] + else: + return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" + + messages = self._service.get_messages( + board.channels, region=region, category=category, limit=self.READ_LIMIT, + ) + if not messages: + return "BBS: no messages found." + lines = [] + for m in messages: + ts = m.timestamp[:16].replace("T", " ") + region_label = f"[{m.region}] " if m.region else "" + lines.append(f"{ts} {m.sender} [{m.category}] {region_label}{m.text}") + return "\n".join(lines) + + # ------------------------------------------------------------------ + # help + # ------------------------------------------------------------------ + + def _handle_help(self, board) -> str: + cats = ", ".join(board.categories) + if board.regions: + regs = ", ".join(board.regions) + return ( + f"BBS [{board.name}] | " + f"!bbs post [region] [cat] [text] | " + f"!bbs read [region] [cat] | " + f"Regions: {regs} | Categories: {cats}" + ) + return ( + f"BBS [{board.name}] | " + f"!bbs post [cat] [text] | " + f"!bbs read [cat] | " + f"Categories: {cats}" + ) diff --git a/meshcore_gui/meshcore_gui/services/bot.py b/meshcore_gui/meshcore_gui/services/bot.py new file mode 100644 index 0000000..9279a72 --- /dev/null +++ b/meshcore_gui/meshcore_gui/services/bot.py @@ -0,0 +1,221 @@ +""" +Keyword-triggered auto-reply bot for MeshCore GUI. + +Extracted from SerialWorker to satisfy the Single Responsibility Principle. +The bot listens on a configured channel and replies to messages that +contain recognised keywords. + +Open/Closed +~~~~~~~~~~~ +New keywords are added via ``BotConfig.keywords`` (data) without +modifying the ``MeshBot`` class (code). Custom matching strategies +can be implemented by subclassing and overriding ``_match_keyword``. + +BBS integration +~~~~~~~~~~~~~~~ +``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a +:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one +is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is +``None`` (default), BBS routing is simply skipped. +""" + +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler + +from meshcore_gui.config import debug_print + + +# ============================================================================== +# Bot defaults (previously in config.py) +# ============================================================================== + +# Channel indices the bot listens on (must match device channels). +BOT_CHANNELS: frozenset = frozenset({1, 4}) # #test, #bot + +# Display name prepended to every bot reply. +BOT_NAME: str = "ZwolsBotje" + +# Minimum seconds between two bot replies (prevents reply-storms). +BOT_COOLDOWN_SECONDS: float = 5.0 + +# Keyword β†’ reply template mapping. +# Available variables: {bot}, {sender}, {snr}, {path} +# The bot checks whether the incoming message text *contains* the keyword +# (case-insensitive). First match wins. +BOT_KEYWORDS: Dict[str, str] = { + 'test': '@[{sender}], rcvd | SNR {snr} | {path}', + 'ping': 'Pong!', + 'help': 'test, ping, help', +} + + +@dataclass +class BotConfig: + """Configuration for :class:`MeshBot`. + + Attributes: + channels: Channel indices to listen on. + name: Display name prepended to replies. + cooldown_seconds: Minimum seconds between replies. + keywords: Keyword β†’ reply template mapping. + """ + + channels: frozenset = field(default_factory=lambda: frozenset(BOT_CHANNELS)) + name: str = BOT_NAME + cooldown_seconds: float = BOT_COOLDOWN_SECONDS + keywords: Dict[str, str] = field(default_factory=lambda: dict(BOT_KEYWORDS)) + + +class MeshBot: + """Keyword-triggered auto-reply bot. + + The bot checks incoming messages against a set of keyword β†’ template + pairs. When a keyword is found (case-insensitive substring match, + first match wins), the template is expanded and queued as a channel + message via *command_sink*. + + Args: + config: Bot configuration. + command_sink: Callable that enqueues a command dict for the + worker (typically ``shared.put_command``). + enabled_check: Callable that returns ``True`` when the bot is + enabled (typically ``shared.is_bot_enabled``). + """ + + def __init__( + self, + config: BotConfig, + command_sink: Callable[[Dict], None], + enabled_check: Callable[[], bool], + bbs_handler: Optional["BbsCommandHandler"] = None, + ) -> None: + self._config = config + self._sink = command_sink + self._enabled = enabled_check + self._last_reply: float = 0.0 + self._bbs_handler = bbs_handler + + def check_and_reply( + self, + sender: str, + text: str, + channel_idx: Optional[int], + snr: Optional[float], + path_len: int, + path_hashes: Optional[List[str]] = None, + ) -> None: + """Evaluate an incoming message and queue a reply if appropriate. + + Guards (in order): + 1. Bot is enabled (checkbox in GUI). + 2. Message is on the configured channel. + 3. Sender is not the bot itself. + 4. Sender name does not end with ``'Bot'`` (prevent loops). + 5. Cooldown period has elapsed. + 6. Message text contains a recognised keyword. + """ + # Guard 1: enabled? + if not self._enabled(): + return + + # Guard 2: correct channel? + if channel_idx not in self._config.channels: + return + + # Guard 3: own messages? + if sender == "Me" or (text and text.startswith(self._config.name)): + return + + # Guard 4: other bots? + if sender and sender.rstrip().lower().endswith("bot"): + debug_print(f"BOT: skipping message from other bot '{sender}'") + return + + # Guard 5: cooldown? + now = time.time() + if now - self._last_reply < self._config.cooldown_seconds: + debug_print("BOT: cooldown active, skipping") + return + + # BBS routing: delegate !bbs commands to BbsCommandHandler + if self._bbs_handler is not None: + text_stripped = (text or "").strip() + if text_stripped.lower().startswith("!bbs"): + bbs_reply = self._bbs_handler.handle( + channel_idx=channel_idx, + sender=sender, + sender_key="", # sender_key not available at this call-site + text=text_stripped, + ) + if bbs_reply is not None: + self._last_reply = now + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": bbs_reply, + "_bot": True, + }) + debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}") + return # Do not fall through to keyword matching + + # Guard 6: keyword match + template = self._match_keyword(text) + if template is None: + return + + # Build reply + path_str = self._format_path(path_len, path_hashes) + snr_str = f"{snr:.1f}" if snr is not None else "?" + reply = template.format( + bot=self._config.name, + sender=sender or "?", + snr=snr_str, + path=path_str, + ) + + self._last_reply = now + + self._sink({ + "action": "send_message", + "channel": channel_idx, + "text": reply, + "_bot": True, + }) + debug_print(f"BOT: queued reply to '{sender}': {reply}") + + # ------------------------------------------------------------------ + # Extension point (OCP) + # ------------------------------------------------------------------ + + def _match_keyword(self, text: str) -> Optional[str]: + """Return the reply template for the first matching keyword. + + Override this method for custom matching strategies (regex, + exact match, priority ordering, etc.). + + Returns: + Template string, or ``None`` if no keyword matched. + """ + text_lower = (text or "").lower() + for keyword, template in self._config.keywords.items(): + if keyword in text_lower: + return template + return None + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _format_path( + path_len: int, + path_hashes: Optional[List[str]], + ) -> str: + """Format path info as ``path(N); ``path(0)``.""" + if not path_len: + return "path(0)" + return f"path({path_len})" From 21e266ceb5f72617370092e76cf5c068619508a6 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 08:43:50 +0100 Subject: [PATCH 04/14] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- CHANGELOG.md | 34 +- meshcore_gui/gui/panels/bbs_panel.py | 496 ++++++++++++---------- meshcore_gui/services/bbs_config_store.py | 334 +++++++++------ meshcore_gui/services/bbs_service.py | 290 ++++++------- 4 files changed, 610 insertions(+), 544 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d542cb..6df97ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,29 +33,29 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) ### Added -- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsConfigStore`: beheert `~/.meshcore-gui/bbs/bbs_config.json`. Thread-safe, atomische schrijfoperaties. Wordt aangemaakt bij eerste start. Methoden: `get_channels()`, `get_enabled_channels()`, `get_channel()`, `set_channel()`, `enable_channel()`, `disable_channel()`, `update_channel_field()`. -- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. - - `BbsMessage` dataclass: channel, region, category, sender, sender_key, text, timestamp. - - `BbsService`: `post_message()`, `get_messages()`, `get_all_messages()`, `purge_expired()`, `purge_all_expired()`. Thread-safe via `threading.Lock`. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen (bijv. 800 MHz + 433 MHz gelijktijdig). Database op `~/.meshcore-gui/bbs/bbs_messages.db`. - - `BbsCommandHandler`: parst `!bbs post`, `!bbs read`, `!bbs help` mesh commando's. Leest channel-config live uit `BbsConfigStore`. Whitelist-controle (stille drop bij onbekende sender key). Per-channel regio/categorie-validatie met foutmelding. +- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`. +- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`. - πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. - - Channel-selector (alleen enabled BBS channels). - - Regio-filter (alleen zichtbaar als channel regio's heeft). - - Categorie-filter. - - Scrollbare berichtenlijst met timestamp, afzender, categorie en optioneel regio-label. - - Post-formulier: regio-select (conditioneel), categorie-select, tekstinvoer, Send-knop. - - Send verstuurt ook `!bbs post …` op het mesh-kanaal zodat andere nodes het ontvangen. - - **Settings-sectie**: per device channel een inklapbaar blok met enable-toggle, categorie-invoer, regio-invoer, retentie-invoer en whitelist-invoer. Opslaan via `BbsConfigStore`; channel-selector wordt direct bijgewerkt. + - Board-selector (knoppen per geconfigureerd board). + - Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft). + - Scrollbare berichtenlijst over alle channels van het actieve board. + - Post-formulier: post op het eerste channel van het board. + - **Settings-sectie**: boards aanmaken (naam β†’ Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieΓ«n, regio's, retentie, whitelist, Save en Delete. ### Changed -- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler` parameter. Inkomende `!bbs` berichten worden doorgesluisd naar `BbsCommandHandler` vΓ³Γ³r keyword-matching; antwoorden worden teruggestuurd op hetzelfde kanaal. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd (config leeft nu in `bbs_config.json`). Versie naar `1.14.0`. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties toegevoegd; `BbsPanel` geregistreerd als standalone panel `'bbs'`; menu-item `πŸ“‹ BBS` toegevoegd aan de drawer. +- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`. +- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd; versie `1.14.0`. +- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `πŸ“‹ BBS` drawer-item. - πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. +### Storage +``` +~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) +~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +``` + ### Not changed -- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services. -- Bestaande bot keyword-logica, room server flow, archive page, contacts, map, device, actions, rxlog panels. +- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. --- diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index c642ad8..0282a59 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -1,11 +1,13 @@ -"""BBS panel -- offline Bulletin Board System viewer, post form and settings.""" +"""BBS panel -- board-based Bulletin Board System viewer and configuration.""" +import re from typing import Callable, Dict, List, Optional from nicegui import ui from meshcore_gui.config import debug_print from meshcore_gui.services.bbs_config_store import ( + BbsBoard, BbsConfigStore, DEFAULT_CATEGORIES, DEFAULT_RETENTION_HOURS, @@ -13,16 +15,24 @@ from meshcore_gui.services.bbs_config_store import ( from meshcore_gui.services.bbs_service import BbsMessage, BbsService +def _slug(name: str) -> str: + """Convert a board name to a safe id slug. + + Args: + name: Human-readable board name. + + Returns: + Lowercase alphanumeric + underscore string. + """ + return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" + + class BbsPanel: - """BBS panel: channel selector, filters, message list, post form and settings. + """BBS panel: board selector, filters, message list, post form and settings. - The settings section lists all active device channels (from SharedData) - and lets the user enable/disable BBS per channel and configure - categories, regions and retention. Configuration is persisted via - BbsConfigStore to ~/.meshcore-gui/bbs/bbs_config.json. - - All data access goes through BbsService and BbsConfigStore. - No direct SQLite access in this class (SOLID: SRP / DIP). + The settings section lets users create, configure and delete boards. + Each board can span one or more device channels (from SharedData). + Configuration is persisted via BbsConfigStore. Args: put_command: Callable to enqueue a command dict for the worker. @@ -41,12 +51,12 @@ class BbsPanel: self._config_store = config_store # Active view state - self._active_channel_idx: Optional[int] = None + self._active_board: Optional[BbsBoard] = None self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI element references -- message view - self._msg_list_container = None + # UI refs -- message view + self._board_btn_row = None self._region_row = None self._region_select = None self._category_select = None @@ -54,11 +64,16 @@ class BbsPanel: self._post_region_row = None self._post_region_select = None self._post_category_select = None - self._channel_btn_row = None + self._msg_list_container = None - # UI element references -- settings - self._settings_container = None - self._last_device_channels: List[Dict] = [] + # UI refs -- settings + self._boards_settings_container = None + self._new_board_name_input = None + self._new_board_channel_checks: Dict[int, object] = {} + + # Cached device channels (updated by update()) + self._device_channels: List[Dict] = [] + self._last_ch_fingerprint: tuple = () # ------------------------------------------------------------------ # Render @@ -66,18 +81,16 @@ class BbsPanel: def render(self) -> None: """Build the complete BBS panel layout.""" + # ---- Message view card -------------------------------------- with ui.card().classes('w-full'): ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') - # ---- Channel selector ----------------------------------- - self._channel_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - # Populated by _rebuild_channel_buttons() + self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') ui.separator() - # ---- Filter row ---------------------------------------- with ui.row().classes('w-full items-center gap-4 flex-wrap'): ui.label('Filter:').classes('text-sm text-gray-600') @@ -85,33 +98,29 @@ class BbsPanel: with self._region_row: ui.label('Region:').classes('text-xs text-gray-600') self._region_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_region_filter(e.value), ).classes('text-xs').style('min-width: 120px') with ui.row().classes('items-center gap-2'): ui.label('Category:').classes('text-xs text-gray-600') self._category_select = ui.select( - options=[], - value=None, + options=[], value=None, on_change=lambda e: self._on_category_filter(e.value), ).classes('text-xs').style('min-width: 120px') ui.button( - 'Refresh', on_click=self._refresh_messages + 'Refresh', on_click=self._refresh_messages, ).props('flat no-caps').classes('text-xs') ui.separator() - # ---- Message list -------------------------------------- self._msg_list_container = ui.column().classes( 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' ) ui.separator() - # ---- Post form ----------------------------------------- with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') @@ -131,91 +140,100 @@ class BbsPanel: ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ----------------------------------------- + # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') ui.label( - 'Enable BBS on a channel to allow !bbs commands and store messages.' + 'Create boards and assign device channels. ' + 'One board can cover multiple channels.' ).classes('text-xs text-gray-500') ui.separator() - self._settings_container = ui.column().classes('w-full gap-2') - with self._settings_container: - ui.label('Waiting for device channels...').classes( + + # New board form + with ui.row().classes('w-full items-center gap-2 flex-wrap'): + ui.label('New board:').classes('text-sm text-gray-600') + self._new_board_name_input = ui.input( + placeholder='Board name...', + ).classes('text-xs').style('min-width: 160px') + ui.button( + 'Create', on_click=self._on_create_board, + ).props('no-caps').classes('text-xs') + + ui.separator() + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) + # Initial render + self._rebuild_board_buttons() + self._rebuild_boards_settings() + # ------------------------------------------------------------------ - # Channel selector (message view) + # Board selector (message view) # ------------------------------------------------------------------ - def _rebuild_channel_buttons(self, enabled_channels: List[Dict]) -> None: - """Rebuild the channel selector buttons for enabled BBS channels. - - Args: - enabled_channels: List of enabled channel config dicts. - """ - if not self._channel_btn_row: + def _rebuild_board_buttons(self) -> None: + """Rebuild board selector buttons from current config.""" + if not self._board_btn_row: return - self._channel_btn_row.clear() - with self._channel_btn_row: - ui.label('Channel:').classes('text-sm text-gray-600') - if not enabled_channels: - ui.label('No BBS channels configured.').classes( + self._board_btn_row.clear() + boards = self._config_store.get_boards() + with self._board_btn_row: + ui.label('Board:').classes('text-sm text-gray-600') + if not boards: + ui.label('No boards configured.').classes( 'text-xs text-gray-400 italic' ) return - for cfg in enabled_channels: - idx = cfg['channel'] - name = cfg['name'] + for board in boards: ui.button( - name, - on_click=lambda i=idx: self._select_channel(i), + board.name, + on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first channel when none is active yet - if self._active_channel_idx is None and enabled_channels: - self._select_channel(enabled_channels[0]['channel']) + # Auto-select first board if none active or active was deleted + ids = [b.id for b in boards] + if boards and (self._active_board is None or self._active_board.id not in ids): + self._select_board(boards[0]) - def _select_channel(self, channel_idx: int) -> None: - """Switch the active channel and rebuild filter options. + def _select_board(self, board: BbsBoard) -> None: + """Activate a board and rebuild filter selects. Args: - channel_idx: MeshCore channel index to activate. + board: Board to activate. """ - self._active_channel_idx = channel_idx + self._active_board = board self._active_region = None self._active_category = None - cfg = self._config_store.get_channel(channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) - - has_regions = bool(regions) + has_regions = bool(board.regions) if self._region_row: self._region_row.set_visibility(has_regions) if self._post_region_row: self._post_region_row.set_visibility(has_regions) - region_opts = ['(all)'] + regions + region_opts = ['(all)'] + board.regions if self._region_select: self._region_select.options = region_opts self._region_select.value = '(all)' if self._post_region_select: - self._post_region_select.options = regions - self._post_region_select.value = regions[0] if regions else None + self._post_region_select.options = board.regions + self._post_region_select.value = board.regions[0] if board.regions else None - cat_opts = ['(all)'] + categories + cat_opts = ['(all)'] + board.categories if self._category_select: self._category_select.options = cat_opts self._category_select.value = '(all)' if self._post_category_select: - self._post_category_select.options = categories - self._post_category_select.value = categories[0] if categories else None + self._post_category_select.options = board.categories + self._post_category_select.value = board.categories[0] if board.categories else None self._refresh_messages() # ------------------------------------------------------------------ - # Filter callbacks + # Filters # ------------------------------------------------------------------ def _on_region_filter(self, value: Optional[str]) -> None: @@ -227,20 +245,24 @@ class BbsPanel: self._refresh_messages() # ------------------------------------------------------------------ - # Message list refresh + # Message list # ------------------------------------------------------------------ def _refresh_messages(self) -> None: - """Query the BBS service and rebuild the message list UI.""" if not self._msg_list_container: return self._msg_list_container.clear() with self._msg_list_container: - if self._active_channel_idx is None: - ui.label('Select a channel above.').classes('text-xs text-gray-400 italic') + if self._active_board is None: + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') + return + if not self._active_board.channels: + ui.label('No channels assigned to this board.').classes( + 'text-xs text-gray-400 italic' + ) return messages = self._service.get_all_messages( - channel=self._active_channel_idx, + channels=self._active_board.channels, region=self._active_region, category=self._active_category, ) @@ -251,11 +273,6 @@ class BbsPanel: self._render_message_row(msg) def _render_message_row(self, msg: BbsMessage) -> None: - """Render a single message row. - - Args: - msg: BbsMessage to display. - """ ts = msg.timestamp[:16].replace('T', ' ') region_label = f' [{msg.region}]' if msg.region else '' header = f'{ts} {msg.sender} [{msg.category}]{region_label}' @@ -268,14 +285,12 @@ class BbsPanel: # ------------------------------------------------------------------ def _on_post(self) -> None: - """Handle the Send button: validate inputs and post a BBS message.""" - if self._active_channel_idx is None: - ui.notify('Select a channel first.', type='warning') + if self._active_board is None: + ui.notify('Select a board first.', type='warning') + return + if not self._active_board.channels: + ui.notify('No channels assigned to this board.', type='warning') return - - cfg = self._config_store.get_channel(self._active_channel_idx) or {} - regions: List[str] = cfg.get('regions', []) - categories: List[str] = cfg.get('categories', []) text = (self._text_input.value or '').strip() if self._text_input else '' if not text: @@ -283,36 +298,38 @@ class BbsPanel: return category = ( - self._post_category_select.value - if self._post_category_select else (categories[0] if categories else '') + self._post_category_select.value if self._post_category_select + else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: ui.notify('Please select a category.', type='warning') return region = '' - if regions and self._post_region_select: + if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' + # Post on first assigned channel (primary channel for outgoing) + target_channel = self._active_board.channels[0] + msg = BbsMessage( - channel=self._active_channel_idx, - region=region, - category=category, - sender='Me', - sender_key='', - text=text, + channel=target_channel, + region=region, category=category, + sender='Me', sender_key='', text=text, ) self._service.post_message(msg) - # Broadcast on the mesh channel region_part = f'{region} ' if region else '' mesh_text = f'!bbs post {region_part}{category} {text}' self._put_command({ 'action': 'send_message', - 'channel': self._active_channel_idx, + 'channel': target_channel, 'text': mesh_text, }) - debug_print(f'BBS panel: posted to ch={self._active_channel_idx} {mesh_text[:60]}') + debug_print( + f'BBS panel: posted to board={self._active_board.id} ' + f'ch={target_channel} {mesh_text[:60]}' + ) if self._text_input: self._text_input.value = '' @@ -320,170 +337,199 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings panel + # Settings -- board list # ------------------------------------------------------------------ - def _rebuild_settings(self, device_channels: List[Dict]) -> None: - """Rebuild the settings rows for all device channels. - - Called from update() when the device channel list changes. - - Args: - device_channels: Channel list from SharedData snapshot. - """ - if not self._settings_container: + def _rebuild_boards_settings(self) -> None: + """Rebuild the settings section for all configured boards.""" + if not self._boards_settings_container: return - self._settings_container.clear() - with self._settings_container: - if not device_channels: - ui.label('No channels received from device yet.').classes( + self._boards_settings_container.clear() + boards = self._config_store.get_boards() + with self._boards_settings_container: + if not boards: + ui.label('No boards configured yet.').classes( 'text-xs text-gray-400 italic' ) return - for ch in device_channels: - self._render_settings_row(ch) + for board in boards: + self._render_board_settings_row(board) - def _render_settings_row(self, device_ch: Dict) -> None: - """Render one settings row for a single device channel. + def _render_board_settings_row(self, board: BbsBoard) -> None: + """Render one settings expansion for a single board. Args: - device_ch: Channel dict from SharedData (keys: idx, name). + board: Board to render. """ - ch_idx = device_ch.get('idx', device_ch.get('index', 0)) - ch_name = device_ch.get('name', f'Ch {ch_idx}') - bbs_cfg = self._config_store.get_channel(ch_idx) - is_enabled = bbs_cfg.get('enabled', False) if bbs_cfg else False - with ui.expansion( - f'[{ch_idx}] {ch_name}', - value=False, + board.name, value=False, ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): - # Enable / disable toggle - enable_cb = ui.checkbox( - 'Enable BBS on this channel', - value=is_enabled, - ) - - # Categories input - cats_val = ', '.join(bbs_cfg.get('categories', DEFAULT_CATEGORIES)) if bbs_cfg else ', '.join(DEFAULT_CATEGORIES) - cats_input = ui.input( - label='Categories (comma-separated)', - value=cats_val, + # Name + name_input = ui.input( + label='Board name', value=board.name, ).classes('w-full text-xs') - # Regions input - regions_val = ', '.join(bbs_cfg.get('regions', [])) if bbs_cfg else '' + # Channel assignment + ui.label('Channels (select which device channels belong to this board):').classes( + 'text-xs text-gray-600' + ) + ch_checks: Dict[int, object] = {} + with ui.row().classes('flex-wrap gap-2'): + if not self._device_channels: + ui.label('No device channels known yet.').classes( + 'text-xs text-gray-400 italic' + ) + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {ch_name}', + value=idx in board.channels, + ).classes('text-xs') + ch_checks[idx] = cb + + # Categories + cats_input = ui.input( + label='Categories (comma-separated)', + value=', '.join(board.categories), + ).classes('w-full text-xs') + + # Regions regions_input = ui.input( label='Regions (comma-separated, leave empty for none)', - value=regions_val, + value=', '.join(board.regions), ).classes('w-full text-xs') # Retention - ret_val = str(bbs_cfg.get('retention_hours', DEFAULT_RETENTION_HOURS)) if bbs_cfg else str(DEFAULT_RETENTION_HOURS) retention_input = ui.input( label='Retention (hours)', - value=ret_val, - ).classes('w-full text-xs').style('max-width: 160px') + value=str(board.retention_hours), + ).classes('text-xs').style('max-width: 160px') # Whitelist - wl_val = ', '.join(bbs_cfg.get('allowed_keys', [])) if bbs_cfg else '' - whitelist_input = ui.input( - label='Allowed keys (comma-separated hex, leave empty for all)', - value=wl_val, + wl_input = ui.input( + label='Allowed keys (comma-separated hex, empty = all)', + value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Save button - def _save( - idx=ch_idx, - name=ch_name, - cb=enable_cb, - cats=cats_input, - regs=regions_input, - ret=retention_input, - wl=whitelist_input, - ) -> None: - categories = [ - c.strip().upper() - for c in (cats.value or '').split(',') - if c.strip() - ] - regions = [ - r.strip() - for r in (regs.value or '').split(',') - if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wl.value or '').split(',') - if k.strip() - ] - cfg_entry = { - 'channel': idx, - 'name': name, - 'enabled': cb.value, - 'categories': categories if categories else list(DEFAULT_CATEGORIES), - 'regions': regions, - 'retention_hours': retention_hours, - 'allowed_keys': allowed_keys, - } - self._config_store.set_channel(cfg_entry) - debug_print( - f'BBS settings: saved ch={idx} enabled={cb.value} ' - f'cats={categories} regions={regions}' - ) - ui.notify(f'BBS settings saved for [{idx}] {name}', type='positive') - # Refresh channel buttons and message view - self._refresh_after_settings_save() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - - def _refresh_after_settings_save(self) -> None: - """Rebuild the channel selector buttons after a settings save.""" - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) - # Reset active channel if it was disabled - if self._active_channel_idx is not None: - cfg = self._config_store.get_channel(self._active_channel_idx) - if not cfg or not cfg.get('enabled', False): - self._active_channel_idx = None - if self._msg_list_container: - self._msg_list_container.clear() - with self._msg_list_container: - ui.label('Select a channel above.').classes( - 'text-xs text-gray-400 italic' + with ui.row().classes('gap-2'): + def _save( + bid=board.id, + ni=name_input, + cc=ch_checks, + ci=cats_input, + ri=regions_input, + ret=retention_input, + wli=wl_input, + ) -> None: + new_name = (ni.value or '').strip() or bid + selected_channels = [ + idx for idx, cb in cc.items() if cb.value + ] + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + regions = [ + r.strip() + for r in (ri.value or '').split(',') if r.strip() + ] + try: + retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) + except ValueError: + retention_hours = DEFAULT_RETENTION_HOURS + allowed_keys = [ + k.strip() + for k in (wli.value or '').split(',') if k.strip() + ] + updated = BbsBoard( + id=bid, + name=new_name, + channels=selected_channels, + categories=categories, + regions=regions, + retention_hours=retention_hours, + allowed_keys=allowed_keys, ) + self._config_store.set_board(updated) + debug_print( + f'BBS settings: saved board {bid} ' + f'channels={selected_channels}' + ) + ui.notify(f'Board "{new_name}" saved.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + def _delete(bid=board.id, bname=board.name) -> None: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: deleted board {bid}') + ui.notify(f'Board "{bname}" deleted.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') + ui.button( + 'Delete', on_click=_delete, + ).props('no-caps flat color=negative').classes('text-xs') # ------------------------------------------------------------------ - # External update hook (called from dashboard timer) + # Settings -- create new board + # ------------------------------------------------------------------ + + def _on_create_board(self) -> None: + """Handle the Create button for a new board.""" + name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' + if not name: + ui.notify('Enter a board name first.', type='warning') + return + + board_id = _slug(name) + # Make id unique if needed + base_id = board_id + counter = 2 + while self._config_store.board_id_exists(board_id): + board_id = f'{base_id}_{counter}' + counter += 1 + + board = BbsBoard( + id=board_id, + name=name, + channels=[], + categories=list(DEFAULT_CATEGORIES), + regions=[], + retention_hours=DEFAULT_RETENTION_HOURS, + allowed_keys=[], + ) + self._config_store.set_board(board) + debug_print(f'BBS settings: created board {board_id}') + if self._new_board_name_input: + self._new_board_name_input.value = '' + ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + # ------------------------------------------------------------------ + # External update hook # ------------------------------------------------------------------ def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings panel when the device channel list changes. + Rebuilds the settings channel checkboxes when the device channel + list changes. Args: data: SharedData snapshot dict. """ device_channels = data.get('channels', []) - - # Rebuild settings only when the channel list changes - ch_fingerprint = tuple( + fingerprint = tuple( (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels ) - last_fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in self._last_device_channels - ) - if ch_fingerprint != last_fingerprint: - self._last_device_channels = device_channels - self._rebuild_settings(device_channels) - # Also rebuild channel buttons (config may have changed) - enabled = self._config_store.get_enabled_channels() - self._rebuild_channel_buttons(enabled) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_boards_settings() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index 1e43842..c05727b 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -1,36 +1,46 @@ """ -BBS channel configuration store for MeshCore GUI. +BBS board configuration store for MeshCore GUI. -Persists BBS channel configuration to -``~/.meshcore-gui/bbs/bbs_config.json`` so that settings survive -restarts and are managed outside of ``config.py``. +Persists BBS board configuration to +``~/.meshcore-gui/bbs/bbs_config.json``. -On first use the file is created with an empty channel list. -The GUI populates it when the user enables BBS on a device channel. +A **board** groups one or more MeshCore channel indices into a single +bulletin board. Messages posted on any of the board's channels are +visible in the board view. This supports two usage patterns: + +- One board per channel (classic per-channel BBS) +- One board spanning multiple channels (shared bulletin board) + +Config version history +~~~~~~~~~~~~~~~~~~~~~~ +v1 β€” per-channel config (list of channels with enabled flag). +v2 β€” board-based config (list of boards, each with a channels list). + Automatic migration from v1 on first load. Thread safety ~~~~~~~~~~~~~ -All methods acquire an internal ``threading.Lock``. +All public methods acquire an internal ``threading.Lock``. """ import json import threading +from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional from meshcore_gui.config import debug_print # --------------------------------------------------------------------------- -# Storage location +# Storage # --------------------------------------------------------------------------- BBS_DIR: Path = Path.home() / ".meshcore-gui" / "bbs" BBS_CONFIG_PATH: Path = BBS_DIR / "bbs_config.json" -CONFIG_VERSION: int = 1 +CONFIG_VERSION: int = 2 # --------------------------------------------------------------------------- -# Default values applied when a channel is first enabled +# Defaults # --------------------------------------------------------------------------- DEFAULT_CATEGORIES: List[str] = ["STATUS", "ALGEMEEN"] @@ -38,8 +48,64 @@ DEFAULT_REGIONS: List[str] = [] DEFAULT_RETENTION_HOURS: int = 48 +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class BbsBoard: + """A BBS board grouping one or more MeshCore channels. + + Attributes: + id: Unique identifier (slug, e.g. ``'noodnet_zwolle'``). + name: Human-readable board name. + channels: List of MeshCore channel indices assigned to this board. + categories: Valid category tags for this board. + regions: Optional region tags; empty = no region filtering. + retention_hours: Message retention period in hours. + allowed_keys: Sender public key whitelist (empty = all allowed). + """ + + id: str + name: str + channels: List[int] = field(default_factory=list) + categories: List[str] = field(default_factory=lambda: list(DEFAULT_CATEGORIES)) + regions: List[str] = field(default_factory=list) + retention_hours: int = DEFAULT_RETENTION_HOURS + allowed_keys: List[str] = field(default_factory=list) + + def to_dict(self) -> Dict: + """Serialise to a JSON-compatible dict.""" + return { + "id": self.id, + "name": self.name, + "channels": list(self.channels), + "categories": list(self.categories), + "regions": list(self.regions), + "retention_hours": self.retention_hours, + "allowed_keys": list(self.allowed_keys), + } + + @staticmethod + def from_dict(d: Dict) -> "BbsBoard": + """Deserialise from a config dict.""" + return BbsBoard( + id=d.get("id", ""), + name=d.get("name", ""), + channels=list(d.get("channels", [])), + categories=list(d.get("categories", DEFAULT_CATEGORIES)), + regions=list(d.get("regions", [])), + retention_hours=int(d.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(d.get("allowed_keys", [])), + ) + + +# --------------------------------------------------------------------------- +# Store +# --------------------------------------------------------------------------- + class BbsConfigStore: - """Persistent store for BBS channel configuration. + """Persistent store for BBS board configuration. Args: config_path: Path to the JSON config file. @@ -49,7 +115,7 @@ class BbsConfigStore: def __init__(self, config_path: Path = BBS_CONFIG_PATH) -> None: self._path = config_path self._lock = threading.Lock() - self._data: Dict = {"version": CONFIG_VERSION, "channels": []} + self._boards: List[BbsBoard] = [] self._load() # ------------------------------------------------------------------ @@ -57,36 +123,79 @@ class BbsConfigStore: # ------------------------------------------------------------------ def _load(self) -> None: - """Load config from disk; create defaults if file is absent.""" + """Load config from disk; migrate v1 β†’ v2 if needed.""" BBS_DIR.mkdir(parents=True, exist_ok=True) if not self._path.exists(): self._save_unlocked() - debug_print("BBS config: created new config file") + debug_print("BBS config: created new config file (v2)") return try: raw = self._path.read_text(encoding="utf-8") data = json.loads(raw) - if data.get("version") == CONFIG_VERSION: - self._data = data + version = data.get("version", 1) + + if version == CONFIG_VERSION: + self._boards = [ + BbsBoard.from_dict(b) for b in data.get("boards", []) + ] + debug_print(f"BBS config: loaded {len(self._boards)} boards") + + elif version == 1: + # Migrate: each v1 channel β†’ one board + self._boards = self._migrate_v1(data.get("channels", [])) + self._save_unlocked() debug_print( - f"BBS config: loaded {len(self._data.get('channels', []))} channels" + f"BBS config: migrated v1 β†’ v2 ({len(self._boards)} boards)" ) else: debug_print( - f"BBS config: version mismatch " - f"(got {data.get('version')}, expected {CONFIG_VERSION}) β€” using defaults" + f"BBS config: unknown version {version}, using empty config" ) + except (json.JSONDecodeError, OSError) as exc: - debug_print(f"BBS config: load error ({exc}) β€” using defaults") + debug_print(f"BBS config: load error ({exc}), using empty config") + + @staticmethod + def _migrate_v1(v1_channels: List[Dict]) -> List["BbsBoard"]: + """Convert v1 per-channel entries to v2 boards. + + Only enabled channels are migrated. + + Args: + v1_channels: List of v1 channel config dicts. + + Returns: + List of ``BbsBoard`` instances. + """ + boards = [] + for ch in v1_channels: + if not ch.get("enabled", False): + continue + idx = ch.get("channel", 0) + board_id = f"ch{idx}" + boards.append(BbsBoard( + id=board_id, + name=ch.get("name", f"Channel {idx}"), + channels=[idx], + categories=list(ch.get("categories", DEFAULT_CATEGORIES)), + regions=list(ch.get("regions", [])), + retention_hours=int(ch.get("retention_hours", DEFAULT_RETENTION_HOURS)), + allowed_keys=list(ch.get("allowed_keys", [])), + )) + return boards def _save_unlocked(self) -> None: """Write config to disk. MUST be called with self._lock held.""" BBS_DIR.mkdir(parents=True, exist_ok=True) + data = { + "version": CONFIG_VERSION, + "boards": [b.to_dict() for b in self._boards], + } tmp = self._path.with_suffix(".tmp") tmp.write_text( - json.dumps(self._data, indent=2, ensure_ascii=False), + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8", ) tmp.replace(self._path) @@ -97,136 +206,97 @@ class BbsConfigStore: self._save_unlocked() # ------------------------------------------------------------------ - # Channel management + # Board queries # ------------------------------------------------------------------ - def get_channels(self) -> List[Dict]: - """Return a copy of all configured channels (enabled and disabled). + def get_boards(self) -> List[BbsBoard]: + """Return a copy of all configured boards. Returns: - List of channel config dicts. + List of ``BbsBoard`` instances. """ with self._lock: - return [ch.copy() for ch in self._data.get("channels", [])] + return list(self._boards) - def get_enabled_channels(self) -> List[Dict]: - """Return only channels with ``enabled: true``. - - Returns: - List of enabled channel config dicts. - """ - with self._lock: - return [ - ch.copy() - for ch in self._data.get("channels", []) - if ch.get("enabled", False) - ] - - def get_channel(self, channel_idx: int) -> Optional[Dict]: - """Return config for a single channel index, or ``None``. + def get_board(self, board_id: str) -> Optional[BbsBoard]: + """Return a board by its id, or ``None``. Args: - channel_idx: MeshCore channel index. + board_id: Board identifier string. Returns: - Channel config dict copy, or ``None`` if not found. + ``BbsBoard`` instance or ``None``. """ with self._lock: - for ch in self._data.get("channels", []): - if ch.get("channel") == channel_idx: - return ch.copy() + for b in self._boards: + if b.id == board_id: + return BbsBoard.from_dict(b.to_dict()) return None - def set_channel(self, channel_cfg: Dict) -> None: - """Insert or update a channel configuration entry. + def get_board_for_channel(self, channel_idx: int) -> Optional[BbsBoard]: + """Return the first board that includes *channel_idx*, or ``None``. - The channel is identified by the ``channel`` key in *channel_cfg*. - If an entry with the same index exists it is replaced; otherwise - a new entry is appended. - - Args: - channel_cfg: Channel config dict (must contain ``'channel'``). - """ - idx = channel_cfg["channel"] - with self._lock: - channels = self._data.setdefault("channels", []) - for i, ch in enumerate(channels): - if ch.get("channel") == idx: - channels[i] = channel_cfg.copy() - self._save_unlocked() - debug_print(f"BBS config: updated ch={idx}") - return - channels.append(channel_cfg.copy()) - self._save_unlocked() - debug_print(f"BBS config: added ch={idx}") - - def enable_channel( - self, - channel_idx: int, - name: str, - *, - categories: Optional[List[str]] = None, - regions: Optional[List[str]] = None, - retention_hours: int = DEFAULT_RETENTION_HOURS, - allowed_keys: Optional[List[str]] = None, - ) -> None: - """Enable BBS on a device channel, creating a default config if needed. - - If the channel already exists its ``enabled`` flag is set to - ``True`` and other fields are left as-is. Pass explicit keyword - arguments to override any field on a new channel. - - Args: - channel_idx: MeshCore channel index. - name: Human-readable channel name. - categories: Category list (defaults to ``DEFAULT_CATEGORIES``). - regions: Region list (defaults to empty β€” no regions). - retention_hours: Retention in hours (default 48). - allowed_keys: Sender key whitelist (default empty = all allowed). - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = True - self.set_channel(existing) - else: - self.set_channel({ - "channel": channel_idx, - "name": name, - "enabled": True, - "categories": categories if categories is not None else list(DEFAULT_CATEGORIES), - "regions": regions if regions is not None else list(DEFAULT_REGIONS), - "retention_hours": retention_hours, - "allowed_keys": allowed_keys if allowed_keys is not None else [], - }) - - def disable_channel(self, channel_idx: int) -> None: - """Set ``enabled: false`` for a channel without removing its config. + Used by ``BbsCommandHandler`` to route incoming mesh commands. Args: channel_idx: MeshCore channel index. - """ - existing = self.get_channel(channel_idx) - if existing: - existing["enabled"] = False - self.set_channel(existing) - debug_print(f"BBS config: disabled ch={channel_idx}") - - def update_channel_field( - self, channel_idx: int, field: str, value - ) -> bool: - """Update a single field on an existing channel entry. - - Args: - channel_idx: MeshCore channel index. - field: Field name to update. - value: New value. Returns: - ``True`` if the channel was found and updated, ``False`` otherwise. + ``BbsBoard`` instance or ``None``. """ - existing = self.get_channel(channel_idx) - if not existing: - return False - existing[field] = value - self.set_channel(existing) - return True + with self._lock: + for b in self._boards: + if channel_idx in b.channels: + return BbsBoard.from_dict(b.to_dict()) + return None + + # ------------------------------------------------------------------ + # Board management + # ------------------------------------------------------------------ + + def set_board(self, board: BbsBoard) -> None: + """Insert or replace a board (matched by ``board.id``). + + Args: + board: ``BbsBoard`` to persist. + """ + with self._lock: + for i, b in enumerate(self._boards): + if b.id == board.id: + self._boards[i] = BbsBoard.from_dict(board.to_dict()) + self._save_unlocked() + debug_print(f"BBS config: updated board '{board.id}'") + return + self._boards.append(BbsBoard.from_dict(board.to_dict())) + self._save_unlocked() + debug_print(f"BBS config: added board '{board.id}'") + + def delete_board(self, board_id: str) -> bool: + """Remove a board by id. + + Args: + board_id: Board identifier to remove. + + Returns: + ``True`` if removed, ``False`` if not found. + """ + with self._lock: + before = len(self._boards) + self._boards = [b for b in self._boards if b.id != board_id] + if len(self._boards) < before: + self._save_unlocked() + debug_print(f"BBS config: deleted board '{board_id}'") + return True + return False + + def board_id_exists(self, board_id: str) -> bool: + """Check whether a board id is already in use. + + Args: + board_id: Board identifier to check. + + Returns: + ``True`` if a board with this id exists. + """ + with self._lock: + return any(b.id == board_id for b in self._boards) diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 2286c08..52b7968 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -1,27 +1,27 @@ """ Offline Bulletin Board System (BBS) service for MeshCore GUI. -Stores BBS messages in a local SQLite database, one table per channel. -Channel configuration is managed by -:class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` and -persisted to ``~/.meshcore-gui/bbs/bbs_config.json``. +Stores BBS messages in a local SQLite database. Messages are keyed by +their originating MeshCore channel index. A **board** (see +:class:`~meshcore_gui.services.bbs_config_store.BbsBoard`) maps one or +more channel indices to a single bulletin board, so queries are always +issued as ``WHERE channel IN (...)``. Architecture ~~~~~~~~~~~~ -- ``BbsService`` β€” persistence layer (SQLite, retention, queries). -- ``BbsCommandHandler`` β€” parses incoming ``!bbs`` text commands and - delegates to ``BbsService``. Returns reply text. +- ``BbsService`` -- persistence layer (SQLite, retention, queries). +- ``BbsCommandHandler`` -- parses incoming ``!bbs`` text commands and + delegates to ``BbsService``. Returns reply text. Thread safety ~~~~~~~~~~~~~ -SQLite connections are created in the calling thread. The service uses -``check_same_thread=False`` combined with an internal ``threading.Lock`` -so it is safe to call from both the GUI thread and the worker thread. +SQLite WAL-mode + busy_timeout=3 s: safe for concurrent access by +multiple application instances (e.g. 800 MHz + 433 MHz on one Pi). -Storage location -~~~~~~~~~~~~~~~~ -``~/.meshcore-gui/bbs/bbs_messages.db`` (SQLite, stdlib). -``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore). +Storage +~~~~~~~ +``~/.meshcore-gui/bbs/bbs_messages.db`` +``~/.meshcore-gui/bbs/bbs_config.json`` (via BbsConfigStore) """ import sqlite3 @@ -33,10 +33,6 @@ from typing import Dict, List, Optional from meshcore_gui.config import debug_print -# --------------------------------------------------------------------------- -# Storage -# --------------------------------------------------------------------------- - BBS_DIR = Path.home() / ".meshcore-gui" / "bbs" BBS_DB_PATH = BBS_DIR / "bbs_messages.db" @@ -51,9 +47,9 @@ class BbsMessage: Attributes: id: Database row id (``None`` before insert). - channel: MeshCore channel index. - region: Region tag (empty string when channel has no regions). - category: Category tag (e.g. ``'MEDISCH'``). + channel: MeshCore channel index the message arrived on. + region: Region tag (empty string when board has no regions). + category: Category tag. sender: Display name of the sender. sender_key: Public key of the sender (hex string). text: Message body. @@ -81,7 +77,6 @@ class BbsService: Args: db_path: Path to the SQLite database file. - Defaults to ``~/.meshcore-gui/bbs/bbs_messages.db``. """ def __init__(self, db_path: Path = BBS_DB_PATH) -> None: @@ -89,10 +84,6 @@ class BbsService: self._lock = threading.Lock() self._init_db() - # ------------------------------------------------------------------ - # Initialisation - # ------------------------------------------------------------------ - def _init_db(self) -> None: """Create the database directory and schema if not present.""" BBS_DIR.mkdir(parents=True, exist_ok=True) @@ -121,10 +112,7 @@ class BbsService: debug_print(f"BBS: database ready at {self._db_path}") def _connect(self) -> sqlite3.Connection: - """Return a new SQLite connection (check_same_thread=False).""" - return sqlite3.connect( - str(self._db_path), check_same_thread=False - ) + return sqlite3.connect(str(self._db_path), check_same_thread=False) # ------------------------------------------------------------------ # Write @@ -142,163 +130,150 @@ class BbsService: with self._lock: with self._connect() as conn: cur = conn.execute( - """ - INSERT INTO bbs_messages - (channel, region, category, sender, sender_key, text, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - msg.channel, - msg.region, - msg.category, - msg.sender, - msg.sender_key, - msg.text, - msg.timestamp, - ), + """INSERT INTO bbs_messages + (channel, region, category, sender, sender_key, text, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (msg.channel, msg.region, msg.category, + msg.sender, msg.sender_key, msg.text, msg.timestamp), ) conn.commit() msg.id = cur.lastrowid debug_print( - f"BBS: posted msg id={msg.id} ch={msg.channel} " + f"BBS: posted id={msg.id} ch={msg.channel} " f"cat={msg.category} sender={msg.sender}" ) return msg.id # ------------------------------------------------------------------ - # Read + # Read (channels is a list to support multi-channel boards) # ------------------------------------------------------------------ def get_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, limit: int = 5, ) -> List[BbsMessage]: - """Return the *limit* most recent messages for a channel. + """Return the *limit* most recent messages for a set of channels. Args: - channel: MeshCore channel index. - region: Optional region filter (exact match; ``None`` = all). - category: Optional category filter (exact match; ``None`` = all). + channels: MeshCore channel indices to query (board's channel list). + region: Optional region filter. + category: Optional category filter. limit: Maximum number of messages to return. Returns: List of ``BbsMessage`` objects, newest first. """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) query = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + params: list = list(channels) if region: query += " AND region = ?" params.append(region) if category: query += " AND category = ?" params.append(category) - query += " ORDER BY timestamp DESC LIMIT ?" params.append(limit) with self._lock: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - - return [self._row_to_msg(row) for row in rows] + return [self._row_to_msg(r) for r in rows] def get_all_messages( self, - channel: int, + channels: List[int], region: Optional[str] = None, category: Optional[str] = None, ) -> List[BbsMessage]: - """Return all messages for a channel (oldest first) for the GUI panel. + """Return all messages for a set of channels (oldest first). Args: - channel: MeshCore channel index. + channels: MeshCore channel indices to query. region: Optional region filter. category: Optional category filter. Returns: List of ``BbsMessage`` objects, oldest first. """ + if not channels: + return [] + placeholders = ",".join("?" * len(channels)) query = ( - "SELECT id, channel, region, category, sender, sender_key, text, timestamp " - "FROM bbs_messages WHERE channel = ?" + f"SELECT id, channel, region, category, sender, sender_key, text, timestamp " + f"FROM bbs_messages WHERE channel IN ({placeholders})" ) - params: list = [channel] - + params: list = list(channels) if region: query += " AND region = ?" params.append(region) if category: query += " AND category = ?" params.append(category) - query += " ORDER BY timestamp ASC" with self._lock: with self._connect() as conn: rows = conn.execute(query, params).fetchall() - - return [self._row_to_msg(row) for row in rows] + return [self._row_to_msg(r) for r in rows] @staticmethod def _row_to_msg(row: tuple) -> BbsMessage: return BbsMessage( - id=row[0], - channel=row[1], - region=row[2], - category=row[3], - sender=row[4], - sender_key=row[5], - text=row[6], - timestamp=row[7], + id=row[0], channel=row[1], region=row[2], category=row[3], + sender=row[4], sender_key=row[5], text=row[6], timestamp=row[7], ) # ------------------------------------------------------------------ # Retention # ------------------------------------------------------------------ - def purge_expired(self, channel: int, retention_hours: int) -> int: - """Delete messages older than *retention_hours* for a channel. + def purge_expired(self, channels: List[int], retention_hours: int) -> int: + """Delete messages older than *retention_hours* for a set of channels. Args: - channel: MeshCore channel index. + channels: MeshCore channel indices to purge. retention_hours: Messages older than this are deleted. Returns: Number of rows deleted. """ + if not channels: + return 0 cutoff = ( datetime.now(timezone.utc) - timedelta(hours=retention_hours) ).isoformat() - + placeholders = ",".join("?" * len(channels)) with self._lock: with self._connect() as conn: cur = conn.execute( - "DELETE FROM bbs_messages WHERE channel = ? AND timestamp < ?", - (channel, cutoff), + f"DELETE FROM bbs_messages WHERE channel IN ({placeholders}) AND timestamp < ?", + list(channels) + [cutoff], ) conn.commit() deleted = cur.rowcount if deleted: debug_print( - f"BBS: purged {deleted} expired messages from ch={channel}" + f"BBS: purged {deleted} expired messages from ch={channels}" ) return deleted - def purge_all_expired(self, channels_config: List[Dict]) -> None: - """Run retention cleanup for all configured channels. + def purge_all_expired(self, boards) -> None: + """Run retention cleanup for all boards. Args: - channels_config: List of channel config dicts. + boards: Iterable of ``BbsBoard`` instances. """ - for cfg in channels_config: - self.purge_expired(cfg["channel"], cfg["retention_hours"]) + for board in boards: + self.purge_expired(board.channels, board.retention_hours) # --------------------------------------------------------------------------- @@ -308,14 +283,13 @@ class BbsService: class BbsCommandHandler: """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. - Channel configuration is read live from the supplied - :class:`~meshcore_gui.services.bbs_config_store.BbsConfigStore` - so that changes made in the GUI take effect immediately without - restarting the application. + Looks up the board for the incoming channel via ``BbsConfigStore`` + so that a single board spanning multiple channels handles commands + from all of them. Args: service: Shared ``BbsService`` instance. - config_store: ``BbsConfigStore`` instance for live channel config. + config_store: ``BbsConfigStore`` instance for live board config. """ READ_LIMIT: int = 5 @@ -324,13 +298,6 @@ class BbsCommandHandler: self._service = service self._config_store = config_store - def _get_cfg(self, channel_idx: int) -> Optional[Dict]: - """Return enabled channel config, or ``None``.""" - cfg = self._config_store.get_channel(channel_idx) - if cfg and cfg.get("enabled", False): - return cfg - return None - # ------------------------------------------------------------------ # Public entry point # ------------------------------------------------------------------ @@ -357,46 +324,44 @@ class BbsCommandHandler: if not text.lower().startswith("!bbs"): return None - cfg = self._get_cfg(channel_idx) - if cfg is None: + board = self._config_store.get_board_for_channel(channel_idx) + if board is None: return None # Whitelist check - allowed = cfg.get("allowed_keys", []) - if allowed and sender_key not in allowed: + if board.allowed_keys and sender_key not in board.allowed_keys: debug_print( f"BBS: silently dropping msg from {sender} " - f"(key not in whitelist for ch={channel_idx})" + f"(key not in whitelist for board '{board.id}')" ) return None parts = text.split(None, 1) args = parts[1].strip() if len(parts) > 1 else "" - return self._dispatch(cfg, sender, sender_key, args) + return self._dispatch(board, channel_idx, sender, sender_key, args) # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ - def _dispatch(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: + def _dispatch(self, board, channel_idx, sender, sender_key, args): sub = args.split(None, 1)[0].lower() if args else "" rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" - if sub == "post": - return self._handle_post(cfg, sender, sender_key, rest) + return self._handle_post(board, channel_idx, sender, sender_key, rest) if sub == "read": - return self._handle_read(cfg, rest) + return self._handle_read(board, rest) if sub == "help" or not sub: - return self._handle_help(cfg) - return f"Unknown command '{sub}'. {self._handle_help(cfg)}" + return self._handle_help(board) + return f"Unknown command '{sub}'. {self._handle_help(board)}" # ------------------------------------------------------------------ - # Sub-command: post + # post # ------------------------------------------------------------------ - def _handle_post(self, cfg: Dict, sender: str, sender_key: str, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + def _handle_post(self, board, channel_idx, sender, sender_key, args): + regions = board.regions + categories = board.categories tokens = args.split(None, 2) if args else [] if regions: @@ -407,16 +372,14 @@ class BbsCommandHandler: f"Categories: {', '.join(categories)}" ) region, category, text = tokens[0], tokens[1], tokens[2] - region_upper = region.upper() - valid_regions = [r.upper() for r in regions] - if region_upper not in valid_regions: + valid_r = [r.upper() for r in regions] + if region.upper() not in valid_r: return f"Invalid region '{region}'. Valid: {', '.join(regions)}" - region = regions[valid_regions.index(region_upper)] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + region = regions[valid_r.index(region.upper())] + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] else: if len(tokens) < 2: return ( @@ -425,67 +388,57 @@ class BbsCommandHandler: ) region = "" category, text = tokens[0], tokens[1] - category_upper = category.upper() - valid_cats = [c.upper() for c in categories] - if category_upper not in valid_cats: + valid_c = [c.upper() for c in categories] + if category.upper() not in valid_c: return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_cats.index(category_upper)] + category = categories[valid_c.index(category.upper())] msg = BbsMessage( - channel=cfg["channel"], - region=region, - category=category, - sender=sender, - sender_key=sender_key, - text=text, + channel=channel_idx, + region=region, category=category, + sender=sender, sender_key=sender_key, text=text, ) self._service.post_message(msg) region_label = f" [{region}]" if region else "" return f"Posted [{category}]{region_label}: {text[:60]}" # ------------------------------------------------------------------ - # Sub-command: read + # read # ------------------------------------------------------------------ - def _handle_read(self, cfg: Dict, args: str) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] + def _handle_read(self, board, args): + regions = board.regions + categories = board.categories tokens = args.split() if args else [] - - region: Optional[str] = None - category: Optional[str] = None + region = None + category = None if regions: - valid_regions_upper = [r.upper() for r in regions] - valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_regions_upper: - region = regions[valid_regions_upper.index(tok0)] + valid_r = [r.upper() for r in regions] + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_r: + region = regions[valid_r.index(tokens[0].upper())] if len(tokens) >= 2: - tok1 = tokens[1].upper() - if tok1 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok1)] + if tokens[1].upper() in valid_c: + category = categories[valid_c.index(tokens[1].upper())] else: return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" else: return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" else: - valid_cats_upper = [c.upper() for c in categories] - if len(tokens) >= 1: - tok0 = tokens[0].upper() - if tok0 in valid_cats_upper: - category = categories[valid_cats_upper.index(tok0)] + valid_c = [c.upper() for c in categories] + if tokens: + if tokens[0].upper() in valid_c: + category = categories[valid_c.index(tokens[0].upper())] else: return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" messages = self._service.get_messages( - cfg["channel"], region=region, category=category, limit=self.READ_LIMIT, + board.channels, region=region, category=category, limit=self.READ_LIMIT, ) - if not messages: return "BBS: no messages found." - lines = [] for m in messages: ts = m.timestamp[:16].replace("T", " ") @@ -494,25 +447,22 @@ class BbsCommandHandler: return "\n".join(lines) # ------------------------------------------------------------------ - # Sub-command: help + # help # ------------------------------------------------------------------ - def _handle_help(self, cfg: Dict) -> str: - regions: List[str] = cfg.get("regions", []) - categories: List[str] = cfg["categories"] - name = cfg.get("name", f"ch{cfg['channel']}") - if regions: + def _handle_help(self, board) -> str: + cats = ", ".join(board.categories) + if board.regions: + regs = ", ".join(board.regions) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [region] [cat] [text] | " f"!bbs read [region] [cat] | " - f"Regions: {', '.join(regions)} | " - f"Categories: {', '.join(categories)}" + f"Regions: {regs} | Categories: {cats}" ) return ( - f"BBS [{name}] | " + f"BBS [{board.name}] | " f"!bbs post [cat] [text] | " f"!bbs read [cat] | " - f"Categories: {', '.join(categories)}" + f"Categories: {cats}" ) - From 395db80c97d0ba2abe2c4201f36db96032430667 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 16:38:33 +0100 Subject: [PATCH 05/14] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/gui/panels/bbs_panel.py | 385 +++++++++--------- .../meshcore_gui/gui/panels/bbs_panel.py | 385 +++++++++--------- 2 files changed, 402 insertions(+), 368 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 0282a59..554f02b 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -30,9 +30,10 @@ def _slug(name: str) -> str: class BbsPanel: """BBS panel: board selector, filters, message list, post form and settings. - The settings section lets users create, configure and delete boards. - Each board can span one or more device channels (from SharedData). - Configuration is persisted via BbsConfigStore. + The settings section automatically derives one board per device channel. + Boards are enabled/disabled per channel; no manual board creation needed. + Advanced options (regions, allowed keys, channel combining) are hidden + in a collapsible section for administrator use. Args: put_command: Callable to enqueue a command dict for the worker. @@ -68,8 +69,6 @@ class BbsPanel: # UI refs -- settings self._boards_settings_container = None - self._new_board_name_input = None - self._new_board_channel_checks: Dict[int, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -143,26 +142,11 @@ class BbsPanel: # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.label( - 'Create boards and assign device channels. ' - 'One board can cover multiple channels.' - ).classes('text-xs text-gray-500') ui.separator() - # New board form - with ui.row().classes('w-full items-center gap-2 flex-wrap'): - ui.label('New board:').classes('text-sm text-gray-600') - self._new_board_name_input = ui.input( - placeholder='Board name...', - ).classes('text-xs').style('min-width: 160px') - ui.button( - 'Create', on_click=self._on_create_board, - ).props('no-caps').classes('text-xs') - - ui.separator() self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('No boards configured yet.').classes( + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) @@ -183,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No boards configured.').classes( + ui.label('Geen actieve boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -254,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Select a board above.').classes('text-xs text-gray-400 italic') + ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('No channels assigned to this board.').classes( + ui.label('Geen kanalen gekoppeld aan dit board.').classes( 'text-xs text-gray-400 italic' ) return @@ -267,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('No messages.').classes('text-xs text-gray-400 italic') + ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -286,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Select a board first.', type='warning') + ui.notify('Selecteer eerst een board.', type='warning') return if not self._active_board.channels: - ui.notify('No channels assigned to this board.', type='warning') + ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') return text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify('Message text cannot be empty.', type='warning') + ui.notify('Berichttekst mag niet leeg zijn.', type='warning') return category = ( @@ -302,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Please select a category.', type='warning') + ui.notify('Selecteer een categorie.', type='warning') return region = '' @@ -334,183 +318,216 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Message posted.', type='positive') + ui.notify('Bericht geplaatst.', type='positive') # ------------------------------------------------------------------ - # Settings -- board list + # Settings -- channel list (standard view) # ------------------------------------------------------------------ def _rebuild_boards_settings(self) -> None: - """Rebuild the settings section for all configured boards.""" + """Rebuild settings: one row per device channel + collapsed advanced section.""" if not self._boards_settings_container: return self._boards_settings_container.clear() - boards = self._config_store.get_boards() with self._boards_settings_container: - if not boards: - ui.label('No boards configured yet.').classes( + if not self._device_channels: + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) return - for board in boards: - self._render_board_settings_row(board) - def _render_board_settings_row(self, board: BbsBoard) -> None: - """Render one settings expansion for a single board. + # Standard view: one row per channel + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + # Advanced section (collapsed) + with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): + ui.label("Regio's en sleutellijst per kanaal").classes( + 'text-xs text-gray-500 pb-1' + ) + advanced_any = False + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + board = self._config_store.get_board(f'ch{idx}') + if board is not None: + self._render_channel_advanced_row(ch, board) + advanced_any = True + if not advanced_any: + ui.label( + 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + ).classes('text-xs text-gray-400 italic') + + def _render_channel_settings_row(self, ch: Dict) -> None: + """Render the standard settings row for a single device channel. + + Shows enable toggle, categories, retention and a Save button. Args: - board: Board to render. + ch: Device channel dict with 'idx'/'index' and 'name' keys. """ - with ui.expansion( - board.name, value=False, - ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + board_id = f'ch{idx}' + board = self._config_store.get_board(board_id) - # Name - name_input = ui.input( - label='Board name', value=board.name, - ).classes('w-full text-xs') + is_active = board is not None + cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) + retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) - # Channel assignment - ui.label('Channels (select which device channels belong to this board):').classes( - 'text-xs text-gray-600' + with ui.card().classes('w-full p-2'): + # Header row: channel name + active toggle + with ui.row().classes('w-full items-center justify-between'): + ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') + active_toggle = ui.toggle( + {True: '● Actief', False: 'β—‹ Uit'}, + value=is_active, + ).classes('text-xs') + + # Categories + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + # Retention + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input(value=retention_value).classes('text-xs').style( + 'max-width: 80px' ) - ch_checks: Dict[int, object] = {} + ui.label('uur').classes('text-xs text-gray-600') + + def _save( + bid=board_id, + bname=ch_name, + bidx=idx, + tog=active_toggle, + ci=cats_input, + ri=retention_input, + ) -> None: + if tog.value: + existing = self._config_store.get_board(bid) + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + try: + ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) + except ValueError: + ret_hours = DEFAULT_RETENTION_HOURS + # Preserve extra combined channels and advanced fields if board existed + extra_channels = ( + [c for c in existing.channels if c != bidx] + if existing else [] + ) + updated = BbsBoard( + id=bid, + name=bname, + channels=[bidx] + extra_channels, + categories=categories, + regions=existing.regions if existing else [], + retention_hours=ret_hours, + allowed_keys=existing.allowed_keys if existing else [], + ) + self._config_store.set_board(updated) + debug_print(f'BBS settings: kanaal {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + else: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') + ui.notify(f'{bname} uitgeschakeld.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + + # ------------------------------------------------------------------ + # Settings -- advanced section (collapsed) + # ------------------------------------------------------------------ + + def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: + """Render the advanced settings block for a single active channel. + + Shows regions, allowed keys and optional channel combining. + + Args: + ch: Device channel dict. + board: Existing BbsBoard for this channel. + """ + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + board_id = f'ch{idx}' + + with ui.column().classes('w-full gap-1 py-2'): + ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') + + regions_input = ui.input( + label="Regio's (komma-gescheiden)", + value=', '.join(board.regions), + ).classes('w-full text-xs') + + wl_input = ui.input( + label='Toegestane sleutels (leeg = iedereen op het kanaal)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + # Combine with other channels + other_channels = [ + c for c in self._device_channels + if c.get('idx', c.get('index', 0)) != idx + ] + ch_checks: Dict[int, object] = {} + if other_channels: + ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): - if not self._device_channels: - ui.label('No device channels known yet.').classes( - 'text-xs text-gray-400 italic' - ) - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') + for other_ch in other_channels: + other_idx = other_ch.get('idx', other_ch.get('index', 0)) + other_name = other_ch.get('name', f'Ch {other_idx}') cb = ui.checkbox( - f'[{idx}] {ch_name}', - value=idx in board.channels, + f'[{other_idx}] {other_name}', + value=other_idx in board.channels, ).classes('text-xs') - ch_checks[idx] = cb + ch_checks[other_idx] = cb - # Categories - cats_input = ui.input( - label='Categories (comma-separated)', - value=', '.join(board.categories), - ).classes('w-full text-xs') + def _save_adv( + bid=board_id, + bidx=idx, + bname=ch_name, + ri=regions_input, + wli=wl_input, + cc=ch_checks, + ) -> None: + existing = self._config_store.get_board(bid) + if existing is None: + ui.notify('Zet het kanaal eerst op Actief.', type='warning') + return + regions = [ + r.strip() for r in (ri.value or '').split(',') if r.strip() + ] + allowed_keys = [ + k.strip() for k in (wli.value or '').split(',') if k.strip() + ] + combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value] + updated = BbsBoard( + id=bid, + name=bname, + channels=combined, + categories=existing.categories, + regions=regions, + retention_hours=existing.retention_hours, + allowed_keys=allowed_keys, + ) + self._config_store.set_board(updated) + debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() - # Regions - regions_input = ui.input( - label='Regions (comma-separated, leave empty for none)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - # Retention - retention_input = ui.input( - label='Retention (hours)', - value=str(board.retention_hours), - ).classes('text-xs').style('max-width: 160px') - - # Whitelist - wl_input = ui.input( - label='Allowed keys (comma-separated hex, empty = all)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - with ui.row().classes('gap-2'): - def _save( - bid=board.id, - ni=name_input, - cc=ch_checks, - ci=cats_input, - ri=regions_input, - ret=retention_input, - wli=wl_input, - ) -> None: - new_name = (ni.value or '').strip() or bid - selected_channels = [ - idx for idx, cb in cc.items() if cb.value - ] - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - regions = [ - r.strip() - for r in (ri.value or '').split(',') if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wli.value or '').split(',') if k.strip() - ] - updated = BbsBoard( - id=bid, - name=new_name, - channels=selected_channels, - categories=categories, - regions=regions, - retention_hours=retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print( - f'BBS settings: saved board {bid} ' - f'channels={selected_channels}' - ) - ui.notify(f'Board "{new_name}" saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - def _delete(bid=board.id, bname=board.name) -> None: - self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None - debug_print(f'BBS settings: deleted board {bid}') - ui.notify(f'Board "{bname}" deleted.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - ui.button( - 'Delete', on_click=_delete, - ).props('no-caps flat color=negative').classes('text-xs') - - # ------------------------------------------------------------------ - # Settings -- create new board - # ------------------------------------------------------------------ - - def _on_create_board(self) -> None: - """Handle the Create button for a new board.""" - name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' - if not name: - ui.notify('Enter a board name first.', type='warning') - return - - board_id = _slug(name) - # Make id unique if needed - base_id = board_id - counter = 2 - while self._config_store.board_id_exists(board_id): - board_id = f'{base_id}_{counter}' - counter += 1 - - board = BbsBoard( - id=board_id, - name=name, - channels=[], - categories=list(DEFAULT_CATEGORIES), - regions=[], - retention_hours=DEFAULT_RETENTION_HOURS, - allowed_keys=[], - ) - self._config_store.set_board(board) - debug_print(f'BBS settings: created board {board_id}') - if self._new_board_name_input: - self._new_board_name_input.value = '' - ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.separator() # ------------------------------------------------------------------ # External update hook @@ -519,8 +536,8 @@ class BbsPanel: def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings channel checkboxes when the device channel - list changes. + Rebuilds the settings channel list when the device channel list + changes. Args: data: SharedData snapshot dict. diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 0282a59..554f02b 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -30,9 +30,10 @@ def _slug(name: str) -> str: class BbsPanel: """BBS panel: board selector, filters, message list, post form and settings. - The settings section lets users create, configure and delete boards. - Each board can span one or more device channels (from SharedData). - Configuration is persisted via BbsConfigStore. + The settings section automatically derives one board per device channel. + Boards are enabled/disabled per channel; no manual board creation needed. + Advanced options (regions, allowed keys, channel combining) are hidden + in a collapsible section for administrator use. Args: put_command: Callable to enqueue a command dict for the worker. @@ -68,8 +69,6 @@ class BbsPanel: # UI refs -- settings self._boards_settings_container = None - self._new_board_name_input = None - self._new_board_channel_checks: Dict[int, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -143,26 +142,11 @@ class BbsPanel: # ---- Settings card ------------------------------------------ with ui.card().classes('w-full'): ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.label( - 'Create boards and assign device channels. ' - 'One board can cover multiple channels.' - ).classes('text-xs text-gray-500') ui.separator() - # New board form - with ui.row().classes('w-full items-center gap-2 flex-wrap'): - ui.label('New board:').classes('text-sm text-gray-600') - self._new_board_name_input = ui.input( - placeholder='Board name...', - ).classes('text-xs').style('min-width: 160px') - ui.button( - 'Create', on_click=self._on_create_board, - ).props('no-caps').classes('text-xs') - - ui.separator() self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('No boards configured yet.').classes( + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) @@ -183,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No boards configured.').classes( + ui.label('Geen actieve boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -254,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Select a board above.').classes('text-xs text-gray-400 italic') + ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('No channels assigned to this board.').classes( + ui.label('Geen kanalen gekoppeld aan dit board.').classes( 'text-xs text-gray-400 italic' ) return @@ -267,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('No messages.').classes('text-xs text-gray-400 italic') + ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -286,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Select a board first.', type='warning') + ui.notify('Selecteer eerst een board.', type='warning') return if not self._active_board.channels: - ui.notify('No channels assigned to this board.', type='warning') + ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') return text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify('Message text cannot be empty.', type='warning') + ui.notify('Berichttekst mag niet leeg zijn.', type='warning') return category = ( @@ -302,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Please select a category.', type='warning') + ui.notify('Selecteer een categorie.', type='warning') return region = '' @@ -334,183 +318,216 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Message posted.', type='positive') + ui.notify('Bericht geplaatst.', type='positive') # ------------------------------------------------------------------ - # Settings -- board list + # Settings -- channel list (standard view) # ------------------------------------------------------------------ def _rebuild_boards_settings(self) -> None: - """Rebuild the settings section for all configured boards.""" + """Rebuild settings: one row per device channel + collapsed advanced section.""" if not self._boards_settings_container: return self._boards_settings_container.clear() - boards = self._config_store.get_boards() with self._boards_settings_container: - if not boards: - ui.label('No boards configured yet.').classes( + if not self._device_channels: + ui.label('Verbind het apparaat om kanalen te zien.').classes( 'text-xs text-gray-400 italic' ) return - for board in boards: - self._render_board_settings_row(board) - def _render_board_settings_row(self, board: BbsBoard) -> None: - """Render one settings expansion for a single board. + # Standard view: one row per channel + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + # Advanced section (collapsed) + with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): + ui.label("Regio's en sleutellijst per kanaal").classes( + 'text-xs text-gray-500 pb-1' + ) + advanced_any = False + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + board = self._config_store.get_board(f'ch{idx}') + if board is not None: + self._render_channel_advanced_row(ch, board) + advanced_any = True + if not advanced_any: + ui.label( + 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + ).classes('text-xs text-gray-400 italic') + + def _render_channel_settings_row(self, ch: Dict) -> None: + """Render the standard settings row for a single device channel. + + Shows enable toggle, categories, retention and a Save button. Args: - board: Board to render. + ch: Device channel dict with 'idx'/'index' and 'name' keys. """ - with ui.expansion( - board.name, value=False, - ).classes('w-full').props('dense'): - with ui.column().classes('w-full gap-2 p-2'): + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + board_id = f'ch{idx}' + board = self._config_store.get_board(board_id) - # Name - name_input = ui.input( - label='Board name', value=board.name, - ).classes('w-full text-xs') + is_active = board is not None + cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) + retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) - # Channel assignment - ui.label('Channels (select which device channels belong to this board):').classes( - 'text-xs text-gray-600' + with ui.card().classes('w-full p-2'): + # Header row: channel name + active toggle + with ui.row().classes('w-full items-center justify-between'): + ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') + active_toggle = ui.toggle( + {True: '● Actief', False: 'β—‹ Uit'}, + value=is_active, + ).classes('text-xs') + + # Categories + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + # Retention + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input(value=retention_value).classes('text-xs').style( + 'max-width: 80px' ) - ch_checks: Dict[int, object] = {} + ui.label('uur').classes('text-xs text-gray-600') + + def _save( + bid=board_id, + bname=ch_name, + bidx=idx, + tog=active_toggle, + ci=cats_input, + ri=retention_input, + ) -> None: + if tog.value: + existing = self._config_store.get_board(bid) + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + try: + ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) + except ValueError: + ret_hours = DEFAULT_RETENTION_HOURS + # Preserve extra combined channels and advanced fields if board existed + extra_channels = ( + [c for c in existing.channels if c != bidx] + if existing else [] + ) + updated = BbsBoard( + id=bid, + name=bname, + channels=[bidx] + extra_channels, + categories=categories, + regions=existing.regions if existing else [], + retention_hours=ret_hours, + allowed_keys=existing.allowed_keys if existing else [], + ) + self._config_store.set_board(updated) + debug_print(f'BBS settings: kanaal {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + else: + self._config_store.delete_board(bid) + if self._active_board and self._active_board.id == bid: + self._active_board = None + debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') + ui.notify(f'{bname} uitgeschakeld.', type='warning') + self._rebuild_board_buttons() + self._rebuild_boards_settings() + + ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + + # ------------------------------------------------------------------ + # Settings -- advanced section (collapsed) + # ------------------------------------------------------------------ + + def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: + """Render the advanced settings block for a single active channel. + + Shows regions, allowed keys and optional channel combining. + + Args: + ch: Device channel dict. + board: Existing BbsBoard for this channel. + """ + idx = ch.get('idx', ch.get('index', 0)) + ch_name = ch.get('name', f'Ch {idx}') + board_id = f'ch{idx}' + + with ui.column().classes('w-full gap-1 py-2'): + ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') + + regions_input = ui.input( + label="Regio's (komma-gescheiden)", + value=', '.join(board.regions), + ).classes('w-full text-xs') + + wl_input = ui.input( + label='Toegestane sleutels (leeg = iedereen op het kanaal)', + value=', '.join(board.allowed_keys), + ).classes('w-full text-xs') + + # Combine with other channels + other_channels = [ + c for c in self._device_channels + if c.get('idx', c.get('index', 0)) != idx + ] + ch_checks: Dict[int, object] = {} + if other_channels: + ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): - if not self._device_channels: - ui.label('No device channels known yet.').classes( - 'text-xs text-gray-400 italic' - ) - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') + for other_ch in other_channels: + other_idx = other_ch.get('idx', other_ch.get('index', 0)) + other_name = other_ch.get('name', f'Ch {other_idx}') cb = ui.checkbox( - f'[{idx}] {ch_name}', - value=idx in board.channels, + f'[{other_idx}] {other_name}', + value=other_idx in board.channels, ).classes('text-xs') - ch_checks[idx] = cb + ch_checks[other_idx] = cb - # Categories - cats_input = ui.input( - label='Categories (comma-separated)', - value=', '.join(board.categories), - ).classes('w-full text-xs') + def _save_adv( + bid=board_id, + bidx=idx, + bname=ch_name, + ri=regions_input, + wli=wl_input, + cc=ch_checks, + ) -> None: + existing = self._config_store.get_board(bid) + if existing is None: + ui.notify('Zet het kanaal eerst op Actief.', type='warning') + return + regions = [ + r.strip() for r in (ri.value or '').split(',') if r.strip() + ] + allowed_keys = [ + k.strip() for k in (wli.value or '').split(',') if k.strip() + ] + combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value] + updated = BbsBoard( + id=bid, + name=bname, + channels=combined, + categories=existing.categories, + regions=regions, + retention_hours=existing.retention_hours, + allowed_keys=allowed_keys, + ) + self._config_store.set_board(updated) + debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') + ui.notify(f'{bname} opgeslagen.', type='positive') + self._rebuild_board_buttons() + self._rebuild_boards_settings() - # Regions - regions_input = ui.input( - label='Regions (comma-separated, leave empty for none)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - # Retention - retention_input = ui.input( - label='Retention (hours)', - value=str(board.retention_hours), - ).classes('text-xs').style('max-width: 160px') - - # Whitelist - wl_input = ui.input( - label='Allowed keys (comma-separated hex, empty = all)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - with ui.row().classes('gap-2'): - def _save( - bid=board.id, - ni=name_input, - cc=ch_checks, - ci=cats_input, - ri=regions_input, - ret=retention_input, - wli=wl_input, - ) -> None: - new_name = (ni.value or '').strip() or bid - selected_channels = [ - idx for idx, cb in cc.items() if cb.value - ] - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - regions = [ - r.strip() - for r in (ri.value or '').split(',') if r.strip() - ] - try: - retention_hours = int(ret.value or DEFAULT_RETENTION_HOURS) - except ValueError: - retention_hours = DEFAULT_RETENTION_HOURS - allowed_keys = [ - k.strip() - for k in (wli.value or '').split(',') if k.strip() - ] - updated = BbsBoard( - id=bid, - name=new_name, - channels=selected_channels, - categories=categories, - regions=regions, - retention_hours=retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print( - f'BBS settings: saved board {bid} ' - f'channels={selected_channels}' - ) - ui.notify(f'Board "{new_name}" saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - def _delete(bid=board.id, bname=board.name) -> None: - self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None - debug_print(f'BBS settings: deleted board {bid}') - ui.notify(f'Board "{bname}" deleted.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs') - ui.button( - 'Delete', on_click=_delete, - ).props('no-caps flat color=negative').classes('text-xs') - - # ------------------------------------------------------------------ - # Settings -- create new board - # ------------------------------------------------------------------ - - def _on_create_board(self) -> None: - """Handle the Create button for a new board.""" - name = (self._new_board_name_input.value or '').strip() if self._new_board_name_input else '' - if not name: - ui.notify('Enter a board name first.', type='warning') - return - - board_id = _slug(name) - # Make id unique if needed - base_id = board_id - counter = 2 - while self._config_store.board_id_exists(board_id): - board_id = f'{base_id}_{counter}' - counter += 1 - - board = BbsBoard( - id=board_id, - name=name, - channels=[], - categories=list(DEFAULT_CATEGORIES), - regions=[], - retention_hours=DEFAULT_RETENTION_HOURS, - allowed_keys=[], - ) - self._config_store.set_board(board) - debug_print(f'BBS settings: created board {board_id}') - if self._new_board_name_input: - self._new_board_name_input.value = '' - ui.notify(f'Board "{name}" created. Assign channels in the settings below.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.separator() # ------------------------------------------------------------------ # External update hook @@ -519,8 +536,8 @@ class BbsPanel: def update(self, data: Dict) -> None: """Called by the dashboard timer with the SharedData snapshot. - Rebuilds the settings channel checkboxes when the device channel - list changes. + Rebuilds the settings channel list when the device channel list + changes. Args: data: SharedData snapshot dict. From 7d61b7ddd26b601080b5b8f046dfd15bf53e3219 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 16:47:34 +0100 Subject: [PATCH 06/14] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/gui/panels/bbs_panel.py | 60 +++++++++---------- .../meshcore_gui/gui/panels/bbs_panel.py | 60 +++++++++---------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 554f02b..41adb22 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -146,7 +146,7 @@ class BbsPanel: self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) @@ -167,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('Geen actieve boards.').classes( + ui.label('No active boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -238,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('Geen kanalen gekoppeld aan dit board.').classes( + ui.label('No channels assigned to this board.').classes( 'text-xs text-gray-400 italic' ) return @@ -251,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') + ui.label('No messages.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -270,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Selecteer eerst een board.', type='warning') + ui.notify('Select a board first.', type='warning') return if not self._active_board.channels: - ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') + ui.notify('No channels assigned to this board.', type='warning') return text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify('Berichttekst mag niet leeg zijn.', type='warning') + ui.notify('Message text cannot be empty.', type='warning') return category = ( @@ -286,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Selecteer een categorie.', type='warning') + ui.notify('Please select a category.', type='warning') return region = '' @@ -318,7 +318,7 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Bericht geplaatst.', type='positive') + ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ # Settings -- channel list (standard view) @@ -331,7 +331,7 @@ class BbsPanel: self._boards_settings_container.clear() with self._boards_settings_container: if not self._device_channels: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) return @@ -343,8 +343,8 @@ class BbsPanel: ui.separator() # Advanced section (collapsed) - with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): - ui.label("Regio's en sleutellijst per kanaal").classes( + with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): + ui.label('Regions and key list per channel').classes( 'text-xs text-gray-500 pb-1' ) advanced_any = False @@ -356,7 +356,7 @@ class BbsPanel: advanced_any = True if not advanced_any: ui.label( - 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + 'Enable at least one channel to see advanced options.' ).classes('text-xs text-gray-400 italic') def _render_channel_settings_row(self, ch: Dict) -> None: @@ -381,22 +381,22 @@ class BbsPanel: with ui.row().classes('w-full items-center justify-between'): ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') active_toggle = ui.toggle( - {True: '● Actief', False: 'β—‹ Uit'}, + {True: '● Active', False: 'β—‹ Off'}, value=is_active, ).classes('text-xs') # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') retention_input = ui.input(value=retention_value).classes('text-xs').style( 'max-width: 80px' ) - ui.label('uur').classes('text-xs text-gray-600') + ui.label('hrs').classes('text-xs text-gray-600') def _save( bid=board_id, @@ -431,18 +431,18 @@ class BbsPanel: allowed_keys=existing.allowed_keys if existing else [], ) self._config_store.set_board(updated) - debug_print(f'BBS settings: kanaal {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings: channel {bid} saved') + ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) if self._active_board and self._active_board.id == bid: self._active_board = None - debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') - ui.notify(f'{bname} uitgeschakeld.', type='warning') + debug_print(f'BBS settings: channel {bid} disabled') + ui.notify(f'{bname} disabled.', type='warning') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') # ------------------------------------------------------------------ # Settings -- advanced section (collapsed) @@ -465,12 +465,12 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regio's (komma-gescheiden)", + label="Regions (comma-separated)", value=', '.join(board.regions), ).classes('w-full text-xs') wl_input = ui.input( - label='Toegestane sleutels (leeg = iedereen op het kanaal)', + label='Allowed keys (empty = everyone on the channel)', value=', '.join(board.allowed_keys), ).classes('w-full text-xs') @@ -481,7 +481,7 @@ class BbsPanel: ] ch_checks: Dict[int, object] = {} if other_channels: - ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') + ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): for other_ch in other_channels: other_idx = other_ch.get('idx', other_ch.get('index', 0)) @@ -502,7 +502,7 @@ class BbsPanel: ) -> None: existing = self._config_store.get_board(bid) if existing is None: - ui.notify('Zet het kanaal eerst op Actief.', type='warning') + ui.notify('Enable this channel first.', type='warning') return regions = [ r.strip() for r in (ri.value or '').split(',') if r.strip() @@ -521,12 +521,12 @@ class BbsPanel: allowed_keys=allowed_keys, ) self._config_store.set_board(updated) - debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings (advanced): {bid} saved') + ui.notify(f'{bname} saved.', type='positive') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() # ------------------------------------------------------------------ diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 554f02b..41adb22 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -146,7 +146,7 @@ class BbsPanel: self._boards_settings_container = ui.column().classes('w-full gap-3') with self._boards_settings_container: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) @@ -167,7 +167,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('Geen actieve boards.').classes( + ui.label('No active boards.').classes( 'text-xs text-gray-400 italic' ) return @@ -238,10 +238,10 @@ class BbsPanel: self._msg_list_container.clear() with self._msg_list_container: if self._active_board is None: - ui.label('Selecteer een board hierboven.').classes('text-xs text-gray-400 italic') + ui.label('Select a board above.').classes('text-xs text-gray-400 italic') return if not self._active_board.channels: - ui.label('Geen kanalen gekoppeld aan dit board.').classes( + ui.label('No channels assigned to this board.').classes( 'text-xs text-gray-400 italic' ) return @@ -251,7 +251,7 @@ class BbsPanel: category=self._active_category, ) if not messages: - ui.label('Geen berichten.').classes('text-xs text-gray-400 italic') + ui.label('No messages.').classes('text-xs text-gray-400 italic') return for msg in messages: self._render_message_row(msg) @@ -270,15 +270,15 @@ class BbsPanel: def _on_post(self) -> None: if self._active_board is None: - ui.notify('Selecteer eerst een board.', type='warning') + ui.notify('Select a board first.', type='warning') return if not self._active_board.channels: - ui.notify('Geen kanalen gekoppeld aan dit board.', type='warning') + ui.notify('No channels assigned to this board.', type='warning') return text = (self._text_input.value or '').strip() if self._text_input else '' if not text: - ui.notify('Berichttekst mag niet leeg zijn.', type='warning') + ui.notify('Message text cannot be empty.', type='warning') return category = ( @@ -286,7 +286,7 @@ class BbsPanel: else (self._active_board.categories[0] if self._active_board.categories else '') ) if not category: - ui.notify('Selecteer een categorie.', type='warning') + ui.notify('Please select a category.', type='warning') return region = '' @@ -318,7 +318,7 @@ class BbsPanel: if self._text_input: self._text_input.value = '' self._refresh_messages() - ui.notify('Bericht geplaatst.', type='positive') + ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ # Settings -- channel list (standard view) @@ -331,7 +331,7 @@ class BbsPanel: self._boards_settings_container.clear() with self._boards_settings_container: if not self._device_channels: - ui.label('Verbind het apparaat om kanalen te zien.').classes( + ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) return @@ -343,8 +343,8 @@ class BbsPanel: ui.separator() # Advanced section (collapsed) - with ui.expansion('Geavanceerd', value=False).classes('w-full').props('dense'): - ui.label("Regio's en sleutellijst per kanaal").classes( + with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): + ui.label('Regions and key list per channel').classes( 'text-xs text-gray-500 pb-1' ) advanced_any = False @@ -356,7 +356,7 @@ class BbsPanel: advanced_any = True if not advanced_any: ui.label( - 'Zet minimaal één kanaal op Actief om geavanceerde opties te zien.' + 'Enable at least one channel to see advanced options.' ).classes('text-xs text-gray-400 italic') def _render_channel_settings_row(self, ch: Dict) -> None: @@ -381,22 +381,22 @@ class BbsPanel: with ui.row().classes('w-full items-center justify-between'): ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') active_toggle = ui.toggle( - {True: '● Actief', False: 'β—‹ Uit'}, + {True: '● Active', False: 'β—‹ Off'}, value=is_active, ).classes('text-xs') # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('CategorieΓ«n:').classes('text-xs text-gray-600 w-24 shrink-0') + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Bewaar:').classes('text-xs text-gray-600 w-24 shrink-0') + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') retention_input = ui.input(value=retention_value).classes('text-xs').style( 'max-width: 80px' ) - ui.label('uur').classes('text-xs text-gray-600') + ui.label('hrs').classes('text-xs text-gray-600') def _save( bid=board_id, @@ -431,18 +431,18 @@ class BbsPanel: allowed_keys=existing.allowed_keys if existing else [], ) self._config_store.set_board(updated) - debug_print(f'BBS settings: kanaal {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings: channel {bid} saved') + ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) if self._active_board and self._active_board.id == bid: self._active_board = None - debug_print(f'BBS settings: kanaal {bid} uitgeschakeld') - ui.notify(f'{bname} uitgeschakeld.', type='warning') + debug_print(f'BBS settings: channel {bid} disabled') + ui.notify(f'{bname} disabled.', type='warning') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') # ------------------------------------------------------------------ # Settings -- advanced section (collapsed) @@ -465,12 +465,12 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regio's (komma-gescheiden)", + label="Regions (comma-separated)", value=', '.join(board.regions), ).classes('w-full text-xs') wl_input = ui.input( - label='Toegestane sleutels (leeg = iedereen op het kanaal)', + label='Allowed keys (empty = everyone on the channel)', value=', '.join(board.allowed_keys), ).classes('w-full text-xs') @@ -481,7 +481,7 @@ class BbsPanel: ] ch_checks: Dict[int, object] = {} if other_channels: - ui.label('Combineer met kanalen:').classes('text-xs text-gray-600 mt-1') + ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1') with ui.row().classes('flex-wrap gap-2'): for other_ch in other_channels: other_idx = other_ch.get('idx', other_ch.get('index', 0)) @@ -502,7 +502,7 @@ class BbsPanel: ) -> None: existing = self._config_store.get_board(bid) if existing is None: - ui.notify('Zet het kanaal eerst op Actief.', type='warning') + ui.notify('Enable this channel first.', type='warning') return regions = [ r.strip() for r in (ri.value or '').split(',') if r.strip() @@ -521,12 +521,12 @@ class BbsPanel: allowed_keys=allowed_keys, ) self._config_store.set_board(updated) - debug_print(f'BBS settings (geavanceerd): {bid} opgeslagen') - ui.notify(f'{bname} opgeslagen.', type='positive') + debug_print(f'BBS settings (advanced): {bid} saved') + ui.notify(f'{bname} saved.', type='positive') self._rebuild_board_buttons() self._rebuild_boards_settings() - ui.button('Opslaan', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') + ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() # ------------------------------------------------------------------ From 52d15c83a766f7e78b1045db93dd0edf9cfcf55e Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 17:09:08 +0100 Subject: [PATCH 07/14] feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#V1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for use on MeshCore mesh networks, designed for emergency communication organisations (NoodNet Zwolle, NoodNet OV, Dalfsen). New files: - services/bbs_service.py: SQLite-backed persistence layer with BbsMessage dataclass, BbsService (post/read/purge) and BbsCommandHandler (!bbs post/read/help mesh command parser). Whitelist enforcement via sender public key (silent drop on unknown sender). Per-channel configurable regions, categories and retention period. - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Region filter is conditionally visible based on channel config. Modified files: - config.py: BBS_CHANNELS configuration block added (ch 2/3/4). Version bumped to 1.14.0. - services/bot.py: MeshBot accepts optional bbs_handler parameter. Incoming !bbs commands are routed to BbsCommandHandler before keyword matching; no changes to existing bot behaviour. - gui/dashboard.py: BbsPanel registered as standalone panel with πŸ“‹ BBS drawer menu item. - gui/panels/__init__.py: BbsPanel re-exported. Storage: ~/.meshcore-gui/bbs/bbs_messages.db (SQLite, stdlib only). No new external dependencies. --- meshcore_gui/__main__.py | 14 +- meshcore_gui/gui/panels/bbs_panel.py | 241 ++++++++++-------- .../meshcore_gui/gui/panels/bbs_panel.py | 241 ++++++++++-------- 3 files changed, 291 insertions(+), 205 deletions(-) diff --git a/meshcore_gui/__main__.py b/meshcore_gui/__main__.py index bab8285..5eaf7be 100644 --- a/meshcore_gui/__main__.py +++ b/meshcore_gui/__main__.py @@ -45,6 +45,7 @@ from meshcore_gui.ble.worker import create_worker from meshcore_gui.core.shared_data import SharedData from meshcore_gui.gui.dashboard import DashboardPage from meshcore_gui.gui.route_page import RoutePage +from meshcore_gui.gui.panels.bbs_panel import BbsSettingsPage from meshcore_gui.gui.archive_page import ArchivePage from meshcore_gui.services.pin_store import PinStore from meshcore_gui.services.room_password_store import RoomPasswordStore @@ -54,6 +55,8 @@ from meshcore_gui.services.room_password_store import RoomPasswordStore _shared = None _dashboard = None _route_page = None +_bbs_settings_page = None +_bbs_config_store_main = None _archive_page = None _pin_store = None _room_password_store = None @@ -73,6 +76,13 @@ def _page_route(msg_key: str): _route_page.render(msg_key) +@ui.page('/bbs-settings') +def _page_bbs_settings(): + """NiceGUI page handler β€” BBS settings.""" + if _bbs_settings_page: + _bbs_settings_page.render() + + @ui.page('/archive') def _page_archive(): """NiceGUI page handler β€” message archive.""" @@ -155,7 +165,7 @@ def main(): Parses CLI arguments, auto-detects the transport, initialises all components and starts the NiceGUI server. """ - global _shared, _dashboard, _route_page, _archive_page, _pin_store, _room_password_store + global _shared, _dashboard, _route_page, _bbs_settings_page, _archive_page, _pin_store, _room_password_store args, flags = _parse_flags(sys.argv[1:]) @@ -259,6 +269,8 @@ def main(): _dashboard = DashboardPage(_shared, _pin_store, _room_password_store) _route_page = RoutePage(_shared) _archive_page = ArchivePage(_shared) + from meshcore_gui.services.bbs_config_store import BbsConfigStore as _BCS + _bbs_settings_page = BbsSettingsPage(_shared, _BCS()) # ── Start worker ── worker = create_worker( diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 41adb22..e84c733 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -13,6 +13,7 @@ from meshcore_gui.services.bbs_config_store import ( DEFAULT_RETENTION_HOURS, ) from meshcore_gui.services.bbs_service import BbsMessage, BbsService +from meshcore_gui.core.protocols import SharedDataReadAndLookup def _slug(name: str) -> str: @@ -27,13 +28,15 @@ def _slug(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" -class BbsPanel: - """BBS panel: board selector, filters, message list, post form and settings. +# --------------------------------------------------------------------------- +# Main BBS panel (message view only β€” settings live on /bbs-settings) +# --------------------------------------------------------------------------- - The settings section automatically derives one board per device channel. - Boards are enabled/disabled per channel; no manual board creation needed. - Advanced options (regions, allowed keys, channel combining) are hidden - in a collapsible section for administrator use. +class BbsPanel: + """BBS panel: board selector, filters, message list and post form. + + Settings are on a separate page (/bbs-settings), reachable via the + gear icon in the panel header. Args: put_command: Callable to enqueue a command dict for the worker. @@ -67,9 +70,6 @@ class BbsPanel: self._post_category_select = None self._msg_list_container = None - # UI refs -- settings - self._boards_settings_container = None - # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] self._last_ch_fingerprint: tuple = () @@ -79,10 +79,15 @@ class BbsPanel: # ------------------------------------------------------------------ def render(self) -> None: - """Build the complete BBS panel layout.""" - # ---- Message view card -------------------------------------- + """Build the BBS message view panel layout.""" with ui.card().classes('w-full'): - ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + # Header row with gear icon + with ui.row().classes('w-full items-center justify-between'): + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + ui.button( + icon='settings', + on_click=lambda: ui.navigate.to('/bbs-settings'), + ).props('flat round dense').tooltip('BBS Settings') self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') with self._board_btn_row: @@ -114,8 +119,9 @@ class BbsPanel: ui.separator() + # Responsive message list: h-72 is overridden by domca-panel CSS self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' ) ui.separator() @@ -135,27 +141,15 @@ class BbsPanel: self._text_input = ui.input( placeholder='Message text...', - ).classes('flex-grow text-sm') + ).classes('flex-grow text-sm min-w-0') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ------------------------------------------ - with ui.card().classes('w-full'): - ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.separator() - - self._boards_settings_container = ui.column().classes('w-full gap-3') - with self._boards_settings_container: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - # Initial render self._rebuild_board_buttons() - self._rebuild_boards_settings() # ------------------------------------------------------------------ - # Board selector (message view) + # Board selector # ------------------------------------------------------------------ def _rebuild_board_buttons(self) -> None: @@ -167,7 +161,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No active boards.').classes( + ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return @@ -177,7 +171,6 @@ class BbsPanel: on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first board if none active or active was deleted ids = [b.id for b in boards] if boards and (self._active_board is None or self._active_board.id not in ids): self._select_board(boards[0]) @@ -260,9 +253,13 @@ class BbsPanel: ts = msg.timestamp[:16].replace('T', ' ') region_label = f' [{msg.region}]' if msg.region else '' header = f'{ts} {msg.sender} [{msg.category}]{region_label}' - with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): - ui.label(header).classes('text-xs text-gray-500') - ui.label(msg.text).classes('text-sm') + with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500').style( + 'word-break: break-all; overflow-wrap: break-word' + ) + ui.label(msg.text).classes('text-sm').style( + 'word-break: break-word; overflow-wrap: break-word' + ) # ------------------------------------------------------------------ # Post @@ -293,7 +290,6 @@ class BbsPanel: if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' - # Post on first assigned channel (primary channel for outgoing) target_channel = self._active_board.channels[0] msg = BbsMessage( @@ -321,11 +317,113 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings -- channel list (standard view) + # External update hook # ------------------------------------------------------------------ - def _rebuild_boards_settings(self) -> None: - """Rebuild settings: one row per device channel + collapsed advanced section.""" + def update(self, data: Dict) -> None: + """Called by the dashboard timer with the SharedData snapshot. + + Args: + data: SharedData snapshot dict. + """ + device_channels = data.get('channels', []) + fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels + ) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_board_buttons() + + +# --------------------------------------------------------------------------- +# Separate settings page (/bbs-settings) +# --------------------------------------------------------------------------- + +class BbsSettingsPage: + """Standalone BBS settings page, registered at /bbs-settings. + + Follows the same pattern as RoutePage: one instance, render() called + per page load. + + Args: + shared: SharedData instance (for device channel list). + config_store: BbsConfigStore instance. + """ + + def __init__( + self, + shared: SharedDataReadAndLookup, + config_store: BbsConfigStore, + ) -> None: + self._shared = shared + self._config_store = config_store + self._device_channels: List[Dict] = [] + self._boards_settings_container = None + + def render(self) -> None: + """Render the BBS settings page.""" + from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy β€” avoids circular import + data = self._shared.get_snapshot() + self._device_channels = data.get('channels', []) + + ui.page_title('BBS Settings') + ui.add_head_html(_DOMCA_HEAD) + ui.dark_mode(True) + + with ui.header().classes('items-center px-4 py-2 shadow-md'): + ui.button( + icon='arrow_back', + on_click=lambda: ui.run_javascript('window.history.back()'), + ).props('flat round dense color=white').tooltip('Back') + ui.label('πŸ“‹ BBS Settings').classes( + 'text-lg font-bold domca-header-text' + ).style("font-family: 'JetBrains Mono', monospace") + ui.space() + + with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'): + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.separator() + + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + if not self._device_channels: + ui.label('Connect device to see channels.').classes( + 'text-xs text-gray-400 italic' + ) + else: + self._render_all() + + # ------------------------------------------------------------------ + # Settings rendering + # ------------------------------------------------------------------ + + def _render_all(self) -> None: + """Render all channel rows and the advanced section.""" + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): + ui.label('Regions and key list per channel').classes( + 'text-xs text-gray-500 pb-1' + ) + advanced_any = False + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + board = self._config_store.get_board(f'ch{idx}') + if board is not None: + self._render_channel_advanced_row(ch, board) + advanced_any = True + if not advanced_any: + ui.label( + 'Enable at least one channel to see advanced options.' + ).classes('text-xs text-gray-400 italic') + + def _rebuild(self) -> None: + """Clear and re-render the settings container in-place.""" if not self._boards_settings_container: return self._boards_settings_container.clear() @@ -334,36 +432,12 @@ class BbsPanel: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) - return - - # Standard view: one row per channel - for ch in self._device_channels: - self._render_channel_settings_row(ch) - - ui.separator() - - # Advanced section (collapsed) - with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): - ui.label('Regions and key list per channel').classes( - 'text-xs text-gray-500 pb-1' - ) - advanced_any = False - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - board = self._config_store.get_board(f'ch{idx}') - if board is not None: - self._render_channel_advanced_row(ch, board) - advanced_any = True - if not advanced_any: - ui.label( - 'Enable at least one channel to see advanced options.' - ).classes('text-xs text-gray-400 italic') + else: + self._render_all() def _render_channel_settings_row(self, ch: Dict) -> None: """Render the standard settings row for a single device channel. - Shows enable toggle, categories, retention and a Save button. - Args: ch: Device channel dict with 'idx'/'index' and 'name' keys. """ @@ -377,7 +451,6 @@ class BbsPanel: retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) with ui.card().classes('w-full p-2'): - # Header row: channel name + active toggle with ui.row().classes('w-full items-center justify-between'): ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') active_toggle = ui.toggle( @@ -385,12 +458,10 @@ class BbsPanel: value=is_active, ).classes('text-xs') - # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') retention_input = ui.input(value=retention_value).classes('text-xs').style( @@ -416,7 +487,6 @@ class BbsPanel: ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) except ValueError: ret_hours = DEFAULT_RETENTION_HOURS - # Preserve extra combined channels and advanced fields if board existed extra_channels = ( [c for c in existing.channels if c != bidx] if existing else [] @@ -435,24 +505,15 @@ class BbsPanel: ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None debug_print(f'BBS settings: channel {bid} disabled') ui.notify(f'{bname} disabled.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - # ------------------------------------------------------------------ - # Settings -- advanced section (collapsed) - # ------------------------------------------------------------------ - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: """Render the advanced settings block for a single active channel. - Shows regions, allowed keys and optional channel combining. - Args: ch: Device channel dict. board: Existing BbsBoard for this channel. @@ -465,7 +526,7 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regions (comma-separated)", + label='Regions (comma-separated)', value=', '.join(board.regions), ).classes('w-full text-xs') @@ -474,7 +535,6 @@ class BbsPanel: value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Combine with other channels other_channels = [ c for c in self._device_channels if c.get('idx', c.get('index', 0)) != idx @@ -523,30 +583,7 @@ class BbsPanel: self._config_store.set_board(updated) debug_print(f'BBS settings (advanced): {bid} saved') ui.notify(f'{bname} saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() - - # ------------------------------------------------------------------ - # External update hook - # ------------------------------------------------------------------ - - def update(self, data: Dict) -> None: - """Called by the dashboard timer with the SharedData snapshot. - - Rebuilds the settings channel list when the device channel list - changes. - - Args: - data: SharedData snapshot dict. - """ - device_channels = data.get('channels', []) - fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels - ) - if fingerprint != self._last_ch_fingerprint: - self._last_ch_fingerprint = fingerprint - self._device_channels = device_channels - self._rebuild_boards_settings() diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index 41adb22..e84c733 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -13,6 +13,7 @@ from meshcore_gui.services.bbs_config_store import ( DEFAULT_RETENTION_HOURS, ) from meshcore_gui.services.bbs_service import BbsMessage, BbsService +from meshcore_gui.core.protocols import SharedDataReadAndLookup def _slug(name: str) -> str: @@ -27,13 +28,15 @@ def _slug(name: str) -> str: return re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "board" -class BbsPanel: - """BBS panel: board selector, filters, message list, post form and settings. +# --------------------------------------------------------------------------- +# Main BBS panel (message view only β€” settings live on /bbs-settings) +# --------------------------------------------------------------------------- - The settings section automatically derives one board per device channel. - Boards are enabled/disabled per channel; no manual board creation needed. - Advanced options (regions, allowed keys, channel combining) are hidden - in a collapsible section for administrator use. +class BbsPanel: + """BBS panel: board selector, filters, message list and post form. + + Settings are on a separate page (/bbs-settings), reachable via the + gear icon in the panel header. Args: put_command: Callable to enqueue a command dict for the worker. @@ -67,9 +70,6 @@ class BbsPanel: self._post_category_select = None self._msg_list_container = None - # UI refs -- settings - self._boards_settings_container = None - # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] self._last_ch_fingerprint: tuple = () @@ -79,10 +79,15 @@ class BbsPanel: # ------------------------------------------------------------------ def render(self) -> None: - """Build the complete BBS panel layout.""" - # ---- Message view card -------------------------------------- + """Build the BBS message view panel layout.""" with ui.card().classes('w-full'): - ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + # Header row with gear icon + with ui.row().classes('w-full items-center justify-between'): + ui.label('BBS -- Bulletin Board System').classes('font-bold text-gray-600') + ui.button( + icon='settings', + on_click=lambda: ui.navigate.to('/bbs-settings'), + ).props('flat round dense').tooltip('BBS Settings') self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') with self._board_btn_row: @@ -114,8 +119,9 @@ class BbsPanel: ui.separator() + # Responsive message list: h-72 is overridden by domca-panel CSS self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto bg-gray-50 rounded p-2' + 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' ) ui.separator() @@ -135,27 +141,15 @@ class BbsPanel: self._text_input = ui.input( placeholder='Message text...', - ).classes('flex-grow text-sm') + ).classes('flex-grow text-sm min-w-0') ui.button('Send', on_click=self._on_post).props('no-caps').classes('text-xs') - # ---- Settings card ------------------------------------------ - with ui.card().classes('w-full'): - ui.label('BBS Settings').classes('font-bold text-gray-600') - ui.separator() - - self._boards_settings_container = ui.column().classes('w-full gap-3') - with self._boards_settings_container: - ui.label('Connect device to see channels.').classes( - 'text-xs text-gray-400 italic' - ) - # Initial render self._rebuild_board_buttons() - self._rebuild_boards_settings() # ------------------------------------------------------------------ - # Board selector (message view) + # Board selector # ------------------------------------------------------------------ def _rebuild_board_buttons(self) -> None: @@ -167,7 +161,7 @@ class BbsPanel: with self._board_btn_row: ui.label('Board:').classes('text-sm text-gray-600') if not boards: - ui.label('No active boards.').classes( + ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return @@ -177,7 +171,6 @@ class BbsPanel: on_click=lambda b=board: self._select_board(b), ).props('flat no-caps').classes('text-xs') - # Auto-select first board if none active or active was deleted ids = [b.id for b in boards] if boards and (self._active_board is None or self._active_board.id not in ids): self._select_board(boards[0]) @@ -260,9 +253,13 @@ class BbsPanel: ts = msg.timestamp[:16].replace('T', ' ') region_label = f' [{msg.region}]' if msg.region else '' header = f'{ts} {msg.sender} [{msg.category}]{region_label}' - with ui.column().classes('w-full gap-0 py-1 border-b border-gray-200'): - ui.label(header).classes('text-xs text-gray-500') - ui.label(msg.text).classes('text-sm') + with ui.column().classes('w-full min-w-0 gap-0 py-1 border-b border-gray-200'): + ui.label(header).classes('text-xs text-gray-500').style( + 'word-break: break-all; overflow-wrap: break-word' + ) + ui.label(msg.text).classes('text-sm').style( + 'word-break: break-word; overflow-wrap: break-word' + ) # ------------------------------------------------------------------ # Post @@ -293,7 +290,6 @@ class BbsPanel: if self._active_board.regions and self._post_region_select: region = self._post_region_select.value or '' - # Post on first assigned channel (primary channel for outgoing) target_channel = self._active_board.channels[0] msg = BbsMessage( @@ -321,11 +317,113 @@ class BbsPanel: ui.notify('Message posted.', type='positive') # ------------------------------------------------------------------ - # Settings -- channel list (standard view) + # External update hook # ------------------------------------------------------------------ - def _rebuild_boards_settings(self) -> None: - """Rebuild settings: one row per device channel + collapsed advanced section.""" + def update(self, data: Dict) -> None: + """Called by the dashboard timer with the SharedData snapshot. + + Args: + data: SharedData snapshot dict. + """ + device_channels = data.get('channels', []) + fingerprint = tuple( + (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels + ) + if fingerprint != self._last_ch_fingerprint: + self._last_ch_fingerprint = fingerprint + self._device_channels = device_channels + self._rebuild_board_buttons() + + +# --------------------------------------------------------------------------- +# Separate settings page (/bbs-settings) +# --------------------------------------------------------------------------- + +class BbsSettingsPage: + """Standalone BBS settings page, registered at /bbs-settings. + + Follows the same pattern as RoutePage: one instance, render() called + per page load. + + Args: + shared: SharedData instance (for device channel list). + config_store: BbsConfigStore instance. + """ + + def __init__( + self, + shared: SharedDataReadAndLookup, + config_store: BbsConfigStore, + ) -> None: + self._shared = shared + self._config_store = config_store + self._device_channels: List[Dict] = [] + self._boards_settings_container = None + + def render(self) -> None: + """Render the BBS settings page.""" + from meshcore_gui.gui.dashboard import _DOMCA_HEAD # lazy β€” avoids circular import + data = self._shared.get_snapshot() + self._device_channels = data.get('channels', []) + + ui.page_title('BBS Settings') + ui.add_head_html(_DOMCA_HEAD) + ui.dark_mode(True) + + with ui.header().classes('items-center px-4 py-2 shadow-md'): + ui.button( + icon='arrow_back', + on_click=lambda: ui.run_javascript('window.history.back()'), + ).props('flat round dense color=white').tooltip('Back') + ui.label('πŸ“‹ BBS Settings').classes( + 'text-lg font-bold domca-header-text' + ).style("font-family: 'JetBrains Mono', monospace") + ui.space() + + with ui.column().classes('domca-panel gap-4').style('padding-top: 1rem'): + with ui.card().classes('w-full'): + ui.label('BBS Settings').classes('font-bold text-gray-600') + ui.separator() + + self._boards_settings_container = ui.column().classes('w-full gap-3') + with self._boards_settings_container: + if not self._device_channels: + ui.label('Connect device to see channels.').classes( + 'text-xs text-gray-400 italic' + ) + else: + self._render_all() + + # ------------------------------------------------------------------ + # Settings rendering + # ------------------------------------------------------------------ + + def _render_all(self) -> None: + """Render all channel rows and the advanced section.""" + for ch in self._device_channels: + self._render_channel_settings_row(ch) + + ui.separator() + + with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): + ui.label('Regions and key list per channel').classes( + 'text-xs text-gray-500 pb-1' + ) + advanced_any = False + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + board = self._config_store.get_board(f'ch{idx}') + if board is not None: + self._render_channel_advanced_row(ch, board) + advanced_any = True + if not advanced_any: + ui.label( + 'Enable at least one channel to see advanced options.' + ).classes('text-xs text-gray-400 italic') + + def _rebuild(self) -> None: + """Clear and re-render the settings container in-place.""" if not self._boards_settings_container: return self._boards_settings_container.clear() @@ -334,36 +432,12 @@ class BbsPanel: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) - return - - # Standard view: one row per channel - for ch in self._device_channels: - self._render_channel_settings_row(ch) - - ui.separator() - - # Advanced section (collapsed) - with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): - ui.label('Regions and key list per channel').classes( - 'text-xs text-gray-500 pb-1' - ) - advanced_any = False - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - board = self._config_store.get_board(f'ch{idx}') - if board is not None: - self._render_channel_advanced_row(ch, board) - advanced_any = True - if not advanced_any: - ui.label( - 'Enable at least one channel to see advanced options.' - ).classes('text-xs text-gray-400 italic') + else: + self._render_all() def _render_channel_settings_row(self, ch: Dict) -> None: """Render the standard settings row for a single device channel. - Shows enable toggle, categories, retention and a Save button. - Args: ch: Device channel dict with 'idx'/'index' and 'name' keys. """ @@ -377,7 +451,6 @@ class BbsPanel: retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) with ui.card().classes('w-full p-2'): - # Header row: channel name + active toggle with ui.row().classes('w-full items-center justify-between'): ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') active_toggle = ui.toggle( @@ -385,12 +458,10 @@ class BbsPanel: value=is_active, ).classes('text-xs') - # Categories with ui.row().classes('w-full items-center gap-2 mt-1'): ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - # Retention with ui.row().classes('w-full items-center gap-2 mt-1'): ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') retention_input = ui.input(value=retention_value).classes('text-xs').style( @@ -416,7 +487,6 @@ class BbsPanel: ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) except ValueError: ret_hours = DEFAULT_RETENTION_HOURS - # Preserve extra combined channels and advanced fields if board existed extra_channels = ( [c for c in existing.channels if c != bidx] if existing else [] @@ -435,24 +505,15 @@ class BbsPanel: ui.notify(f'{bname} saved.', type='positive') else: self._config_store.delete_board(bid) - if self._active_board and self._active_board.id == bid: - self._active_board = None debug_print(f'BBS settings: channel {bid} disabled') ui.notify(f'{bname} disabled.', type='warning') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - # ------------------------------------------------------------------ - # Settings -- advanced section (collapsed) - # ------------------------------------------------------------------ - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: """Render the advanced settings block for a single active channel. - Shows regions, allowed keys and optional channel combining. - Args: ch: Device channel dict. board: Existing BbsBoard for this channel. @@ -465,7 +526,7 @@ class BbsPanel: ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') regions_input = ui.input( - label="Regions (comma-separated)", + label='Regions (comma-separated)', value=', '.join(board.regions), ).classes('w-full text-xs') @@ -474,7 +535,6 @@ class BbsPanel: value=', '.join(board.allowed_keys), ).classes('w-full text-xs') - # Combine with other channels other_channels = [ c for c in self._device_channels if c.get('idx', c.get('index', 0)) != idx @@ -523,30 +583,7 @@ class BbsPanel: self._config_store.set_board(updated) debug_print(f'BBS settings (advanced): {bid} saved') ui.notify(f'{bname} saved.', type='positive') - self._rebuild_board_buttons() - self._rebuild_boards_settings() + self._rebuild() ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') ui.separator() - - # ------------------------------------------------------------------ - # External update hook - # ------------------------------------------------------------------ - - def update(self, data: Dict) -> None: - """Called by the dashboard timer with the SharedData snapshot. - - Rebuilds the settings channel list when the device channel list - changes. - - Args: - data: SharedData snapshot dict. - """ - device_channels = data.get('channels', []) - fingerprint = tuple( - (ch.get('idx', 0), ch.get('name', '')) for ch in device_channels - ) - if fingerprint != self._last_ch_fingerprint: - self._last_ch_fingerprint = fingerprint - self._device_channels = device_channels - self._rebuild_boards_settings() From 2d582b79b848cee7d1f46f7408d485fccd9199a4 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 17:25:43 +0100 Subject: [PATCH 08/14] feat: add offline BBS with GUI configuration and persistent storage(#v1.14.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully offline Bulletin Board System for emergency mesh communication (NoodNet Zwolle, NoodNet OV, Dalfsen and similar organisations). New files: - services/bbs_config_store.py: Manages ~/.meshcore-gui/bbs/bbs_config.json. Thread-safe, atomic writes. Created on first run. Channels are enabled and configured at runtime via the GUI β€” no code changes required. - services/bbs_service.py: SQLite persistence at ~/.meshcore-gui/bbs/bbs_messages.db. WAL-mode enabled so multiple simultaneous instances (e.g. 800 MHz + 433 MHz) share the same bulletin board safely. BbsCommandHandler parses !bbs post/read/help mesh commands with live config from BbsConfigStore. Whitelist enforcement via sender public key (silent drop on unknown key). - gui/panels/bbs_panel.py: Dashboard panel with channel selector, region/category filters, scrollable message list and post form. Settings section lists all active device channels; per channel: enable toggle, categories, regions, retention and key whitelist. Changes take effect immediately without restart. Modified files: - services/bot.py: MeshBot accepts optional bbs_handler; !bbs commands are routed to BbsCommandHandler before keyword matching. - config.py: BBS_CHANNELS removed (config now lives in bbs_config.json). Version bumped to 1.14.0. - gui/dashboard.py: BbsConfigStore and BbsService instantiated and shared across handler and panel. BBS drawer menu item added. - gui/panels/__init__.py: BbsPanel re-exported. Storage layout: ~/.meshcore-gui/bbs/bbs_config.json β€” channel configuration ~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store No new external dependencies (SQLite is stdlib). --- meshcore_gui/gui/panels/bbs_panel.py | 145 ++++++++++-------- .../meshcore_gui/gui/panels/bbs_panel.py | 145 ++++++++++-------- 2 files changed, 170 insertions(+), 120 deletions(-) diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index e84c733..692acf1 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -33,7 +33,7 @@ def _slug(name: str) -> str: # --------------------------------------------------------------------------- class BbsPanel: - """BBS panel: board selector, filters, message list and post form. + """BBS panel: board selector, category buttons, message list and post form. Settings are on a separate page (/bbs-settings), reachable via the gear icon in the panel header. @@ -56,19 +56,20 @@ class BbsPanel: # Active view state self._active_board: Optional[BbsBoard] = None - self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI refs -- message view + # UI refs self._board_btn_row = None - self._region_row = None - self._region_select = None - self._category_select = None - self._text_input = None + self._category_btn_row = None + self._msg_list_container = None self._post_region_row = None self._post_region_select = None self._post_category_select = None - self._msg_list_container = None + self._text_input = None + + # Button refs for active highlight + self._board_buttons: Dict[str, object] = {} + self._category_buttons: Dict[str, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -89,43 +90,30 @@ class BbsPanel: on_click=lambda: ui.navigate.to('/bbs-settings'), ).props('flat round dense').tooltip('BBS Settings') - self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + # Board selector row + self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') with self._board_btn_row: - ui.label('Board:').classes('text-sm text-gray-600') + ui.label('No active boards β€” open Settings to enable a channel.').classes( + 'text-xs text-gray-400 italic' + ) ui.separator() - with ui.row().classes('w-full items-center gap-4 flex-wrap'): - ui.label('Filter:').classes('text-sm text-gray-600') - - self._region_row = ui.row().classes('items-center gap-2') - with self._region_row: - ui.label('Region:').classes('text-xs text-gray-600') - self._region_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_region_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - with ui.row().classes('items-center gap-2'): - ui.label('Category:').classes('text-xs text-gray-600') - self._category_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_category_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - ui.button( - 'Refresh', on_click=self._refresh_messages, - ).props('flat no-caps').classes('text-xs') + # Category filter row (clickable buttons, replaces dropdown) + self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') + with self._category_btn_row: + ui.label('Select a board first.').classes('text-xs text-gray-400 italic') ui.separator() - # Responsive message list: h-72 is overridden by domca-panel CSS + # Message list self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' - ) + 'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' + ).style('max-height: calc(100vh - 24rem); min-height: 8rem') ui.separator() + # Post row β€” keep selects for sending with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') @@ -157,69 +145,106 @@ class BbsPanel: if not self._board_btn_row: return self._board_btn_row.clear() + self._board_buttons = {} boards = self._config_store.get_boards() with self._board_btn_row: - ui.label('Board:').classes('text-sm text-gray-600') if not boards: ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return for board in boards: - ui.button( + btn = ui.button( board.name, on_click=lambda b=board: self._select_board(b), - ).props('flat no-caps').classes('text-xs') + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._board_buttons[board.id] = btn ids = [b.id for b in boards] if boards and (self._active_board is None or self._active_board.id not in ids): self._select_board(boards[0]) + elif self._active_board and self._active_board.id in self._board_buttons: + self._board_buttons[self._active_board.id].classes('domca-menu-active') def _select_board(self, board: BbsBoard) -> None: - """Activate a board and rebuild filter selects. + """Activate a board and rebuild category buttons. Args: board: Board to activate. """ self._active_board = board - self._active_region = None self._active_category = None - has_regions = bool(board.regions) - if self._region_row: - self._region_row.set_visibility(has_regions) - if self._post_region_row: - self._post_region_row.set_visibility(has_regions) + # Update board button highlights + for bid, btn in self._board_buttons.items(): + if bid == board.id: + btn.classes('domca-menu-active', remove='') + else: + btn.classes(remove='domca-menu-active') - region_opts = ['(all)'] + board.regions - if self._region_select: - self._region_select.options = region_opts - self._region_select.value = '(all)' + # Update post selects + if self._post_region_row: + self._post_region_row.set_visibility(bool(board.regions)) if self._post_region_select: self._post_region_select.options = board.regions self._post_region_select.value = board.regions[0] if board.regions else None - - cat_opts = ['(all)'] + board.categories - if self._category_select: - self._category_select.options = cat_opts - self._category_select.value = '(all)' if self._post_category_select: self._post_category_select.options = board.categories self._post_category_select.value = board.categories[0] if board.categories else None + self._rebuild_category_buttons() self._refresh_messages() # ------------------------------------------------------------------ - # Filters + # Category buttons # ------------------------------------------------------------------ - def _on_region_filter(self, value: Optional[str]) -> None: - self._active_region = None if (not value or value == '(all)') else value + def _rebuild_category_buttons(self) -> None: + """Rebuild clickable category filter buttons for the active board.""" + if not self._category_btn_row: + return + self._category_btn_row.clear() + self._category_buttons = {} + if self._active_board is None: + with self._category_btn_row: + ui.label('Select a board first.').classes('text-xs text-gray-400 italic') + return + with self._category_btn_row: + # "All" button + all_btn = ui.button( + 'ALL', + on_click=lambda: self._on_category_filter(None), + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._category_buttons['__all__'] = all_btn + + for cat in self._active_board.categories: + btn = ui.button( + cat, + on_click=lambda c=cat: self._on_category_filter(c), + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._category_buttons[cat] = btn + + # Highlight the current active category + self._update_category_highlight() + + def _on_category_filter(self, category: Optional[str]) -> None: + """Handle category button click. + + Args: + category: Category string, or None for all. + """ + self._active_category = category + self._update_category_highlight() self._refresh_messages() - def _on_category_filter(self, value: Optional[str]) -> None: - self._active_category = None if (not value or value == '(all)') else value - self._refresh_messages() + def _update_category_highlight(self) -> None: + """Apply domca-menu-active to the currently selected category button.""" + active_key = self._active_category if self._active_category else '__all__' + for key, btn in self._category_buttons.items(): + if key == active_key: + btn.classes('domca-menu-active', remove='') + else: + btn.classes(remove='domca-menu-active') # ------------------------------------------------------------------ # Message list @@ -240,7 +265,7 @@ class BbsPanel: return messages = self._service.get_all_messages( channels=self._active_board.channels, - region=self._active_region, + region=None, category=self._active_category, ) if not messages: diff --git a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py index e84c733..692acf1 100644 --- a/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py @@ -33,7 +33,7 @@ def _slug(name: str) -> str: # --------------------------------------------------------------------------- class BbsPanel: - """BBS panel: board selector, filters, message list and post form. + """BBS panel: board selector, category buttons, message list and post form. Settings are on a separate page (/bbs-settings), reachable via the gear icon in the panel header. @@ -56,19 +56,20 @@ class BbsPanel: # Active view state self._active_board: Optional[BbsBoard] = None - self._active_region: Optional[str] = None self._active_category: Optional[str] = None - # UI refs -- message view + # UI refs self._board_btn_row = None - self._region_row = None - self._region_select = None - self._category_select = None - self._text_input = None + self._category_btn_row = None + self._msg_list_container = None self._post_region_row = None self._post_region_select = None self._post_category_select = None - self._msg_list_container = None + self._text_input = None + + # Button refs for active highlight + self._board_buttons: Dict[str, object] = {} + self._category_buttons: Dict[str, object] = {} # Cached device channels (updated by update()) self._device_channels: List[Dict] = [] @@ -89,43 +90,30 @@ class BbsPanel: on_click=lambda: ui.navigate.to('/bbs-settings'), ).props('flat round dense').tooltip('BBS Settings') - self._board_btn_row = ui.row().classes('w-full items-center gap-2 flex-wrap') + # Board selector row + self._board_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') with self._board_btn_row: - ui.label('Board:').classes('text-sm text-gray-600') + ui.label('No active boards β€” open Settings to enable a channel.').classes( + 'text-xs text-gray-400 italic' + ) ui.separator() - with ui.row().classes('w-full items-center gap-4 flex-wrap'): - ui.label('Filter:').classes('text-sm text-gray-600') - - self._region_row = ui.row().classes('items-center gap-2') - with self._region_row: - ui.label('Region:').classes('text-xs text-gray-600') - self._region_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_region_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - with ui.row().classes('items-center gap-2'): - ui.label('Category:').classes('text-xs text-gray-600') - self._category_select = ui.select( - options=[], value=None, - on_change=lambda e: self._on_category_filter(e.value), - ).classes('text-xs').style('min-width: 120px') - - ui.button( - 'Refresh', on_click=self._refresh_messages, - ).props('flat no-caps').classes('text-xs') + # Category filter row (clickable buttons, replaces dropdown) + self._category_btn_row = ui.row().classes('w-full items-center gap-1 flex-wrap') + with self._category_btn_row: + ui.label('Select a board first.').classes('text-xs text-gray-400 italic') ui.separator() - # Responsive message list: h-72 is overridden by domca-panel CSS + # Message list self._msg_list_container = ui.column().classes( - 'w-full gap-1 h-72 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' - ) + 'w-full gap-1 overflow-y-auto overflow-x-hidden bg-gray-50 rounded p-2' + ).style('max-height: calc(100vh - 24rem); min-height: 8rem') ui.separator() + # Post row β€” keep selects for sending with ui.row().classes('w-full items-center gap-2 flex-wrap'): ui.label('Post:').classes('text-sm text-gray-600') @@ -157,69 +145,106 @@ class BbsPanel: if not self._board_btn_row: return self._board_btn_row.clear() + self._board_buttons = {} boards = self._config_store.get_boards() with self._board_btn_row: - ui.label('Board:').classes('text-sm text-gray-600') if not boards: ui.label('No active boards β€” open Settings to enable a channel.').classes( 'text-xs text-gray-400 italic' ) return for board in boards: - ui.button( + btn = ui.button( board.name, on_click=lambda b=board: self._select_board(b), - ).props('flat no-caps').classes('text-xs') + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._board_buttons[board.id] = btn ids = [b.id for b in boards] if boards and (self._active_board is None or self._active_board.id not in ids): self._select_board(boards[0]) + elif self._active_board and self._active_board.id in self._board_buttons: + self._board_buttons[self._active_board.id].classes('domca-menu-active') def _select_board(self, board: BbsBoard) -> None: - """Activate a board and rebuild filter selects. + """Activate a board and rebuild category buttons. Args: board: Board to activate. """ self._active_board = board - self._active_region = None self._active_category = None - has_regions = bool(board.regions) - if self._region_row: - self._region_row.set_visibility(has_regions) - if self._post_region_row: - self._post_region_row.set_visibility(has_regions) + # Update board button highlights + for bid, btn in self._board_buttons.items(): + if bid == board.id: + btn.classes('domca-menu-active', remove='') + else: + btn.classes(remove='domca-menu-active') - region_opts = ['(all)'] + board.regions - if self._region_select: - self._region_select.options = region_opts - self._region_select.value = '(all)' + # Update post selects + if self._post_region_row: + self._post_region_row.set_visibility(bool(board.regions)) if self._post_region_select: self._post_region_select.options = board.regions self._post_region_select.value = board.regions[0] if board.regions else None - - cat_opts = ['(all)'] + board.categories - if self._category_select: - self._category_select.options = cat_opts - self._category_select.value = '(all)' if self._post_category_select: self._post_category_select.options = board.categories self._post_category_select.value = board.categories[0] if board.categories else None + self._rebuild_category_buttons() self._refresh_messages() # ------------------------------------------------------------------ - # Filters + # Category buttons # ------------------------------------------------------------------ - def _on_region_filter(self, value: Optional[str]) -> None: - self._active_region = None if (not value or value == '(all)') else value + def _rebuild_category_buttons(self) -> None: + """Rebuild clickable category filter buttons for the active board.""" + if not self._category_btn_row: + return + self._category_btn_row.clear() + self._category_buttons = {} + if self._active_board is None: + with self._category_btn_row: + ui.label('Select a board first.').classes('text-xs text-gray-400 italic') + return + with self._category_btn_row: + # "All" button + all_btn = ui.button( + 'ALL', + on_click=lambda: self._on_category_filter(None), + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._category_buttons['__all__'] = all_btn + + for cat in self._active_board.categories: + btn = ui.button( + cat, + on_click=lambda c=cat: self._on_category_filter(c), + ).props('flat no-caps').classes('text-xs domca-menu-btn') + self._category_buttons[cat] = btn + + # Highlight the current active category + self._update_category_highlight() + + def _on_category_filter(self, category: Optional[str]) -> None: + """Handle category button click. + + Args: + category: Category string, or None for all. + """ + self._active_category = category + self._update_category_highlight() self._refresh_messages() - def _on_category_filter(self, value: Optional[str]) -> None: - self._active_category = None if (not value or value == '(all)') else value - self._refresh_messages() + def _update_category_highlight(self) -> None: + """Apply domca-menu-active to the currently selected category button.""" + active_key = self._active_category if self._active_category else '__all__' + for key, btn in self._category_buttons.items(): + if key == active_key: + btn.classes('domca-menu-active', remove='') + else: + btn.classes(remove='domca-menu-active') # ------------------------------------------------------------------ # Message list @@ -240,7 +265,7 @@ class BbsPanel: return messages = self._service.get_all_messages( channels=self._active_board.channels, - region=self._active_region, + region=None, category=self._active_category, ) if not messages: From 374897448e6a05beb2d6de048954fc0df0740895 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 18:36:58 +0100 Subject: [PATCH 09/14] feat(bbs): DM-based BBS with short syntax and auto-abbreviations(#v1.14.0) Adds an offline Bulletin Board System accessible via Direct Message to the node's own key. All BBS commands (!p, !r, !bbs) are handled directly in EventHandler.on_contact_msg, independent of MeshBot. - One node = one board; settings reduced to a single channel selector - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - !r and !bbs help always include the abbreviation table in the reply - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- CHANGELOG.md | 34 +-- README.md | 96 +++++- meshcore_gui/ble/events.py | 56 +++- meshcore_gui/ble/worker.py | 10 + meshcore_gui/gui/panels/bbs_panel.py | 287 +++++++---------- meshcore_gui/services/bbs_config_store.py | 80 ++++- meshcore_gui/services/bbs_service.py | 357 ++++++++++++++++------ meshcore_gui/services/bot.py | 48 +-- 8 files changed, 625 insertions(+), 343 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df97ad..13142ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,33 +30,31 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver > lower CPU usage during idle operation, and more stable map rendering. --- -## [1.14.0] - 2026-03-14 β€” Offline BBS (Bulletin Board System) +## [1.14.0] - 2026-03-14 β€” BBS (Bulletin Board System) ### Added -- πŸ†• **`meshcore_gui/services/bbs_config_store.py`** β€” `BbsBoard` dataclass + `BbsConfigStore`. Beheert `~/.meshcore-gui/bbs/bbs_config.json` (config v2). Automatische migratie van v1. Thread-safe, atomische schrijfoperaties. Een board groepeert een of meerdere channel-indices tot één bulletin board. Methoden: `get_boards()`, `get_board()`, `get_board_for_channel()`, `set_board()`, `delete_board()`, `board_id_exists()`. -- πŸ†• **`meshcore_gui/services/bbs_service.py`** β€” SQLite-backed BBS persistence layer. `BbsMessage` dataclass. `BbsService.get_messages()` en `get_all_messages()` queryen via `WHERE channel IN (...)` zodat één board meerdere channels kan omvatten. WAL-mode + busy_timeout=3s voor veilig gebruik door meerdere processen. Database op `~/.meshcore-gui/bbs/bbs_messages.db`. `BbsCommandHandler` zoekt het board op via `get_board_for_channel()`. -- πŸ†• **`meshcore_gui/gui/panels/bbs_panel.py`** β€” BBS panel voor het dashboard. - - Board-selector (knoppen per geconfigureerd board). - - Regio- en categorie-filter (regio alleen zichtbaar als board regio's heeft). - - Scrollbare berichtenlijst over alle channels van het actieve board. - - Post-formulier: post op het eerste channel van het board. - - **Settings-sectie**: boards aanmaken (naam β†’ Create), per board channels toewijzen via checkboxes (dynamisch gevuld vanuit device channels), categorieΓ«n, regio's, retentie, whitelist, Save en Delete. + +- πŸ†• **BBS β€” Bulletin Board System** β€” offline berichtenbord voor mesh-netwerken. + - Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon. + - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee. + - Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`. + - Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist. + - Settings-pagina (`/bbs-settings`): één channel-selector, categorieΓ«n, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys. + - Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode). ### Changed -- πŸ”„ **`meshcore_gui/services/bot.py`** β€” `MeshBot` accepteert optionele `bbs_handler`; `!bbs` commando's worden doorgesluisd naar `BbsCommandHandler`. -- πŸ”„ **`meshcore_gui/config.py`** β€” `BBS_CHANNELS` verwijderd; versie `1.14.0`. -- πŸ”„ **`meshcore_gui/gui/dashboard.py`** β€” `BbsConfigStore` en `BbsService` instanties; `BbsPanel` geregistreerd; `πŸ“‹ BBS` drawer-item. -- πŸ”„ **`meshcore_gui/gui/panels/__init__.py`** β€” `BbsPanel` re-exported. + +- πŸ”„ **`ble/events.py`** β€” DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`. +- πŸ”„ **`services/bot.py`** β€” `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd. +- πŸ”„ **`services/bbs_config_store.py`** β€” `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd. +- πŸ”„ **`gui/dashboard.py`** β€” `BbsPanel` geregistreerd, `πŸ“‹ BBS` drawer-item toegevoegd. ### Storage ``` -~/.meshcore-gui/bbs/bbs_config.json -- board configuratie (v2) -~/.meshcore-gui/bbs/bbs_messages.db -- SQLite berichtenopslag +~/.meshcore-gui/bbs/bbs_config.json β€” board configuratie +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag ``` -### Not changed -- BLE-laag, SharedData, core/models, route_page, map_panel, message_archive, alle overige services en panels. - --- ## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes diff --git a/README.md b/README.md index 798de13..2361b3d 100644 --- a/README.md +++ b/README.md @@ -1184,11 +1184,105 @@ meshcore-gui/ └── README.md ``` -## 15. Roadmap +## 15. BBS β€” Bulletin Board System + +MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. + +### Design + +One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** β€” the channel stays clean and replies are private to the sender. + +``` +User ──DM──▢ BBS node (public key) + processes command +User ◀──DM── reply (only visible to sender) +``` + +Channel commands remain available as a fallback, but DM is the primary interface. + +### Settings + +Open the BBS settings via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. + +``` +BBS Settings +───────────────────────────────────────────── +Channel: [2] NoodNet Zwolle β–Ό +Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL +Retain: 48 hours +[Save] + +β–Ά Advanced + Regions (comma-separated) + Allowed keys (empty = everyone on the channel) +``` + +- **Channel** β€” select which device channel this node's board listens on. +- **Categories** β€” comma-separated list of valid category tags. +- **Retain** β€” message retention in hours (default 48). +- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. +- **Advanced β†’ Allowed keys** β€” sender public key whitelist; empty = all senders allowed. + +### Command syntax + +#### Short syntax + +| Command | Description | +|---|---| +| `!p ` | Post a message | +| `!p ` | Post with region | +| `!r` | Read 5 most recent (all categories) | +| `!r ` | Read filtered by category | +| `!r ` | Read filtered by region and category | + +Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: + +``` +U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table. + +#### Full syntax + +| Command | Description | +|---|---| +| `!bbs help` | Show commands and abbreviation table | +| `!bbs post ` | Post a message | +| `!bbs post ` | Post with region | +| `!bbs read` | Read 5 most recent | +| `!bbs read ` | Read filtered by category | +| `!bbs read ` | Read filtered by region and category | + +#### Example help reply + +``` +BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +### Error handling + +| Situation | Reply | +|---|---| +| Unknown category | Lists valid categories and abbreviations | +| Ambiguous abbreviation | Lists all matching categories | +| Sender not on whitelist | Silent drop β€” no reply | + +### Storage + +``` +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration (v2 format) +``` + +--- + +## 16. Roadmap This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include: - [x] **Cross-frequency bridge** β€” standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge)) +- [x] **BBS β€” Bulletin Board System** β€” offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system)) - [ ] **Observer mode** β€” passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging - [ ] **Room Server administration** β€” authenticate as admin to manage Room Server settings and users directly from the GUI - [ ] **Repeater management** β€” connect to repeater nodes to view status and adjust configuration diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 5700d40..35da67e 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -4,9 +4,16 @@ Device event callbacks for MeshCore GUI. Handles ``CHANNEL_MSG_RECV``, ``CONTACT_MSG_RECV`` and ``RX_LOG_DATA`` events from the MeshCore library. Extracted from ``SerialWorker`` so the worker only deals with connection lifecycle. + +BBS routing +~~~~~~~~~~~ +Direct Messages (``CONTACT_MSG_RECV``) whose text starts with ``!`` are +forwarded to :class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` +**before** any other DM processing. This path is completely independent of +:class:`~meshcore_gui.services.bot.MeshBot`. """ -from typing import Dict, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Optional from meshcore_gui.config import debug_print from meshcore_gui.core.models import Message, RxLogEntry @@ -15,6 +22,9 @@ from meshcore_gui.ble.packet_decoder import PacketDecoder, PayloadType from meshcore_gui.services.bot import MeshBot from meshcore_gui.services.dedup import DualDeduplicator +if TYPE_CHECKING: + from meshcore_gui.services.bbs_service import BbsCommandHandler + class EventHandler: """Processes device events and writes results to shared data. @@ -35,11 +45,15 @@ class EventHandler: decoder: PacketDecoder, dedup: DualDeduplicator, bot: MeshBot, + bbs_handler: Optional["BbsCommandHandler"] = None, + command_sink: Optional[Callable[[Dict], None]] = None, ) -> None: self._shared = shared self._decoder = decoder self._dedup = dedup self._bot = bot + self._bbs_handler = bbs_handler + self._command_sink = command_sink # Cache: message_hash β†’ path_hashes (from RX_LOG decode). # Used by on_channel_msg fallback to recover hashes that the @@ -409,9 +423,45 @@ class EventHandler: or (pubkey[:8] if pubkey else '') ) + dm_text = payload.get('text', '') + + # BBS routing: DMs starting with '!' go directly to BbsCommandHandler. + # This path is independent of the bot (MeshBot is for channel messages only). + if ( + self._bbs_handler is not None + and self._command_sink is not None + and dm_text.strip().startswith("!") + ): + bbs_reply = self._bbs_handler.handle_dm( + sender=sender, + sender_key=pubkey, + text=dm_text, + ) + if bbs_reply is not None: + debug_print(f"BBS DM reply to {sender} ({pubkey[:8]}): {bbs_reply[:60]}") + self._command_sink({ + "action": "send_dm", + "pubkey": pubkey, + "text": bbs_reply, + }) + # Always store the incoming DM in the message archive too + self._shared.add_message(Message.incoming( + sender, + dm_text, + None, + snr=self._extract_snr(payload), + path_len=path_len, + sender_pubkey=pubkey, + path_hashes=path_hashes, + path_names=path_names, + message_hash=msg_hash, + )) + debug_print(f"BBS DM stored from {sender}: {dm_text[:30]}") + return + self._shared.add_message(Message.incoming( sender, - payload.get('text', ''), + dm_text, None, snr=self._extract_snr(payload), path_len=path_len, @@ -420,7 +470,7 @@ class EventHandler: path_names=path_names, message_hash=msg_hash, )) - debug_print(f"DM received from {sender}: {payload.get('text', '')[:30]}") + debug_print(f"DM received from {sender}: {dm_text[:30]}") # ------------------------------------------------------------------ # Helpers diff --git a/meshcore_gui/ble/worker.py b/meshcore_gui/ble/worker.py index 484ff0a..4b45090 100644 --- a/meshcore_gui/ble/worker.py +++ b/meshcore_gui/ble/worker.py @@ -54,6 +54,8 @@ from meshcore_gui.ble.commands import CommandHandler from meshcore_gui.ble.events import EventHandler from meshcore_gui.ble.packet_decoder import PacketDecoder from meshcore_gui.services.bot import BotConfig, MeshBot +from meshcore_gui.services.bbs_service import BbsCommandHandler, BbsService +from meshcore_gui.services.bbs_config_store import BbsConfigStore from meshcore_gui.services.cache import DeviceCache from meshcore_gui.services.dedup import DualDeduplicator from meshcore_gui.services.device_identity import write_device_identity @@ -124,6 +126,12 @@ class _BaseWorker(abc.ABC): enabled_check=shared.is_bot_enabled, ) + # BBS handler β€” wired directly into EventHandler for DM routing. + # Independent of the bot; uses a shared config store and service. + _bbs_config = BbsConfigStore() + _bbs_service = BbsService() + self._bbs_handler = BbsCommandHandler(service=_bbs_service, config_store=_bbs_config) + # Channel indices that still need keys from device self._pending_keys: Set[int] = set() @@ -244,6 +252,8 @@ class _BaseWorker(abc.ABC): decoder=self._decoder, dedup=self._dedup, bot=self._bot, + bbs_handler=self._bbs_handler, + command_sink=self.shared.put_command, ) self._cmd_handler = CommandHandler( mc=self.mc, shared=self.shared, cache=self._cache, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 692acf1..2e7ee6b 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -368,8 +368,10 @@ class BbsPanel: class BbsSettingsPage: """Standalone BBS settings page, registered at /bbs-settings. - Follows the same pattern as RoutePage: one instance, render() called - per page load. + One node = one board. The page shows a single channel selector + populated from the active device channels, plus a categories field, + a retention field, and a collapsible Advanced section for regions + and allowed keys. There is no board creation or deletion UI. Args: shared: SharedData instance (for device channel list). @@ -384,7 +386,7 @@ class BbsSettingsPage: self._shared = shared self._config_store = config_store self._device_channels: List[Dict] = [] - self._boards_settings_container = None + self._container = None def render(self) -> None: """Render the BBS settings page.""" @@ -411,204 +413,125 @@ class BbsSettingsPage: ui.label('BBS Settings').classes('font-bold text-gray-600') ui.separator() - self._boards_settings_container = ui.column().classes('w-full gap-3') - with self._boards_settings_container: + self._container = ui.column().classes('w-full gap-3') + with self._container: if not self._device_channels: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) else: - self._render_all() + self._render_settings() # ------------------------------------------------------------------ # Settings rendering # ------------------------------------------------------------------ - def _render_all(self) -> None: - """Render all channel rows and the advanced section.""" - for ch in self._device_channels: - self._render_channel_settings_row(ch) + def _render_settings(self) -> None: + """Render the single-board settings block.""" + board = self._config_store.get_single_board() - ui.separator() + # Build channel options: {idx: "[idx] name"} + ch_options = { + ch.get('idx', ch.get('index', 0)): + f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}" + for ch in self._device_channels + } - with ui.expansion('Advanced', value=False).classes('w-full').props('dense'): - ui.label('Regions and key list per channel').classes( - 'text-xs text-gray-500 pb-1' + current_idx = ( + board.channels[0] if board and board.channels + else next(iter(ch_options), 0) + ) + cats_value = ( + ', '.join(board.categories) if board + else ', '.join(DEFAULT_CATEGORIES) + ) + retention_value = ( + str(board.retention_hours) if board + else str(DEFAULT_RETENTION_HOURS) + ) + adv_regions_value = ', '.join(board.regions) if board else '' + adv_keys_value = ', '.join(board.allowed_keys) if board else '' + + # ── Main block ─────────────────────────────────────────────── + with ui.column().classes('w-full gap-2'): + with ui.row().classes('w-full items-center gap-2'): + ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0') + ch_select = ui.select( + options=ch_options, + value=current_idx, + ).classes('text-xs flex-grow') + + with ui.row().classes('w-full items-center gap-2'): + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + + with ui.row().classes('w-full items-center gap-2'): + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input( + value=retention_value, + ).classes('text-xs').style('max-width: 80px') + ui.label('hours').classes('text-xs text-gray-600') + + # ── Advanced (collapsed) ───────────────────────────────────── + with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'): + ui.label('Regions and allowed keys').classes('text-xs text-gray-500 pb-1') + + regions_input = ui.input( + label='Regions (comma-separated)', + value=adv_regions_value, + ).classes('w-full text-xs') + + keys_input = ui.input( + label='Allowed keys (empty = everyone on the channel)', + value=adv_keys_value, + ).classes('w-full text-xs') + + # ── Save ───────────────────────────────────────────────────── + def _save( + cs=ch_select, + ci=cats_input, + ri=retention_input, + rgi=regions_input, + ki=keys_input, + ) -> None: + idx = cs.value + ch_name = ch_options.get(idx, f'Ch {idx}') + categories = [ + c.strip().upper() + for c in (ci.value or '').split(',') if c.strip() + ] or list(DEFAULT_CATEGORIES) + try: + ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) + except ValueError: + ret_hours = DEFAULT_RETENTION_HOURS + regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()] + allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + + self._config_store.set_single_board( + channel_idx=idx, + channel_name=ch_name, + categories=categories, + retention_hours=ret_hours, + regions=regions, + allowed_keys=allowed_keys, ) - advanced_any = False - for ch in self._device_channels: - idx = ch.get('idx', ch.get('index', 0)) - board = self._config_store.get_board(f'ch{idx}') - if board is not None: - self._render_channel_advanced_row(ch, board) - advanced_any = True - if not advanced_any: - ui.label( - 'Enable at least one channel to see advanced options.' - ).classes('text-xs text-gray-400 italic') + debug_print(f'BBS settings: saved ch{idx} {ch_name}') + ui.notify(f'BBS saved β€” {ch_name}.', type='positive') + self._rebuild() + + ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2') def _rebuild(self) -> None: """Clear and re-render the settings container in-place.""" - if not self._boards_settings_container: + if not self._container: return - self._boards_settings_container.clear() - with self._boards_settings_container: + data = self._shared.get_snapshot() + self._device_channels = data.get('channels', []) + self._container.clear() + with self._container: if not self._device_channels: ui.label('Connect device to see channels.').classes( 'text-xs text-gray-400 italic' ) else: - self._render_all() - - def _render_channel_settings_row(self, ch: Dict) -> None: - """Render the standard settings row for a single device channel. - - Args: - ch: Device channel dict with 'idx'/'index' and 'name' keys. - """ - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') - board_id = f'ch{idx}' - board = self._config_store.get_board(board_id) - - is_active = board is not None - cats_value = ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) - retention_value = str(board.retention_hours) if board else str(DEFAULT_RETENTION_HOURS) - - with ui.card().classes('w-full p-2'): - with ui.row().classes('w-full items-center justify-between'): - ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') - active_toggle = ui.toggle( - {True: '● Active', False: 'β—‹ Off'}, - value=is_active, - ).classes('text-xs') - - with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') - cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - - with ui.row().classes('w-full items-center gap-2 mt-1'): - ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') - retention_input = ui.input(value=retention_value).classes('text-xs').style( - 'max-width: 80px' - ) - ui.label('hrs').classes('text-xs text-gray-600') - - def _save( - bid=board_id, - bname=ch_name, - bidx=idx, - tog=active_toggle, - ci=cats_input, - ri=retention_input, - ) -> None: - if tog.value: - existing = self._config_store.get_board(bid) - categories = [ - c.strip().upper() - for c in (ci.value or '').split(',') if c.strip() - ] or list(DEFAULT_CATEGORIES) - try: - ret_hours = int(ri.value or DEFAULT_RETENTION_HOURS) - except ValueError: - ret_hours = DEFAULT_RETENTION_HOURS - extra_channels = ( - [c for c in existing.channels if c != bidx] - if existing else [] - ) - updated = BbsBoard( - id=bid, - name=bname, - channels=[bidx] + extra_channels, - categories=categories, - regions=existing.regions if existing else [], - retention_hours=ret_hours, - allowed_keys=existing.allowed_keys if existing else [], - ) - self._config_store.set_board(updated) - debug_print(f'BBS settings: channel {bid} saved') - ui.notify(f'{bname} saved.', type='positive') - else: - self._config_store.delete_board(bid) - debug_print(f'BBS settings: channel {bid} disabled') - ui.notify(f'{bname} disabled.', type='warning') - self._rebuild() - - ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-1') - - def _render_channel_advanced_row(self, ch: Dict, board: BbsBoard) -> None: - """Render the advanced settings block for a single active channel. - - Args: - ch: Device channel dict. - board: Existing BbsBoard for this channel. - """ - idx = ch.get('idx', ch.get('index', 0)) - ch_name = ch.get('name', f'Ch {idx}') - board_id = f'ch{idx}' - - with ui.column().classes('w-full gap-1 py-2'): - ui.label(f'[{idx}] {ch_name}').classes('text-sm font-medium') - - regions_input = ui.input( - label='Regions (comma-separated)', - value=', '.join(board.regions), - ).classes('w-full text-xs') - - wl_input = ui.input( - label='Allowed keys (empty = everyone on the channel)', - value=', '.join(board.allowed_keys), - ).classes('w-full text-xs') - - other_channels = [ - c for c in self._device_channels - if c.get('idx', c.get('index', 0)) != idx - ] - ch_checks: Dict[int, object] = {} - if other_channels: - ui.label('Combine with channels:').classes('text-xs text-gray-600 mt-1') - with ui.row().classes('flex-wrap gap-2'): - for other_ch in other_channels: - other_idx = other_ch.get('idx', other_ch.get('index', 0)) - other_name = other_ch.get('name', f'Ch {other_idx}') - cb = ui.checkbox( - f'[{other_idx}] {other_name}', - value=other_idx in board.channels, - ).classes('text-xs') - ch_checks[other_idx] = cb - - def _save_adv( - bid=board_id, - bidx=idx, - bname=ch_name, - ri=regions_input, - wli=wl_input, - cc=ch_checks, - ) -> None: - existing = self._config_store.get_board(bid) - if existing is None: - ui.notify('Enable this channel first.', type='warning') - return - regions = [ - r.strip() for r in (ri.value or '').split(',') if r.strip() - ] - allowed_keys = [ - k.strip() for k in (wli.value or '').split(',') if k.strip() - ] - combined = [bidx] + [oidx for oidx, cb in cc.items() if cb.value] - updated = BbsBoard( - id=bid, - name=bname, - channels=combined, - categories=existing.categories, - regions=regions, - retention_hours=existing.retention_hours, - allowed_keys=allowed_keys, - ) - self._config_store.set_board(updated) - debug_print(f'BBS settings (advanced): {bid} saved') - ui.notify(f'{bname} saved.', type='positive') - self._rebuild() - - ui.button('Save', on_click=_save_adv).props('no-caps').classes('text-xs mt-1') - ui.separator() + self._render_settings() diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c05727b..c8c925d 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -4,12 +4,15 @@ BBS board configuration store for MeshCore GUI. Persists BBS board configuration to ``~/.meshcore-gui/bbs/bbs_config.json``. -A **board** groups one or more MeshCore channel indices into a single -bulletin board. Messages posted on any of the board's channels are -visible in the board view. This supports two usage patterns: +Design (v1.14.0 redesign) +~~~~~~~~~~~~~~~~~~~~~~~~~ +One node = one board. The settings UI exposes a single channel selector; +the board id is always ``ch{channel_idx}`` and the name is taken from the +device channel. There is no Create/Delete UI β€” the board is saved or +cleared through :meth:`set_single_board` / :meth:`clear_single_board`. -- One board per channel (classic per-channel BBS) -- One board spanning multiple channels (shared bulletin board) +Multiple-board storage is retained internally so that the storage layer +(``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged. Config version history ~~~~~~~~~~~~~~~~~~~~~~ @@ -300,3 +303,70 @@ class BbsConfigStore: """ with self._lock: return any(b.id == board_id for b in self._boards) + + # ------------------------------------------------------------------ + # Single-board convenience API (v1.14.0 redesign) + # ------------------------------------------------------------------ + + def get_single_board(self) -> Optional[BbsBoard]: + """Return the one configured board, or ``None`` if none exists. + + This is the primary accessor for the simplified single-board UI. + + Returns: + The first ``BbsBoard`` in the store, or ``None``. + """ + with self._lock: + if self._boards: + return BbsBoard.from_dict(self._boards[0].to_dict()) + return None + + def set_single_board( + self, + channel_idx: int, + channel_name: str, + categories: List[str], + retention_hours: int = DEFAULT_RETENTION_HOURS, + regions: Optional[List[str]] = None, + allowed_keys: Optional[List[str]] = None, + ) -> None: + """Replace the single board with a fresh config derived from one channel. + + The board id is always ``ch{channel_idx}`` and the board name is + taken from *channel_name*. Any previously stored boards are + discarded so the store always holds at most one board. + + Args: + channel_idx: MeshCore channel index to assign to this board. + channel_name: Human-readable name of the channel (display only). + categories: Category tag list. + retention_hours: Message retention period in hours. + regions: Optional region tags (``None`` β†’ empty list). + allowed_keys: Sender public key whitelist (``None`` β†’ all allowed). + """ + board = BbsBoard( + id=f"ch{channel_idx}", + name=channel_name, + channels=[channel_idx], + categories=list(categories), + regions=list(regions) if regions else [], + retention_hours=retention_hours, + allowed_keys=list(allowed_keys) if allowed_keys else [], + ) + with self._lock: + self._boards = [board] + self._save_unlocked() + debug_print( + f"BBS config: single board set β†’ ch{channel_idx} '{channel_name}'" + ) + + def clear_single_board(self) -> None: + """Remove the configured board (disable BBS on this node). + + After this call :meth:`get_single_board` returns ``None`` and + the BBS command handler will not respond to any channel. + """ + with self._lock: + self._boards = [] + self._save_unlocked() + debug_print("BBS config: single board cleared") diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 52b7968..0432eef 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -281,11 +281,34 @@ class BbsService: # --------------------------------------------------------------------------- class BbsCommandHandler: - """Parses ``!bbs`` mesh commands and delegates to :class:`BbsService`. + """Parses BBS commands arriving as DMs and delegates to :class:`BbsService`. - Looks up the board for the incoming channel via ``BbsConfigStore`` - so that a single board spanning multiple channels handles commands - from all of them. + Entry point + ~~~~~~~~~~~ + All BBS commands arrive as **Direct Messages** addressed to the node's + own public key. :meth:`handle_dm` is the sole public entry point and is + called directly from + :class:`~meshcore_gui.ble.events.EventHandler.on_contact_msg`. + It is completely independent of :class:`~meshcore_gui.services.bot.MeshBot`. + + Command syntax + ~~~~~~~~~~~~~~ + Both styles are accepted: + + Short syntax:: + + !p [region] β€” post a message + !r [region] [abbrev] β€” read (5 most recent) + + Full syntax:: + + !bbs post [region] + !bbs read [region] [category] + !bbs help + + Category abbreviations are computed automatically as the shortest unique + prefix per category within the configured list. ``!r`` and ``!bbs help`` + always include the abbreviation table in the reply. Args: service: Shared ``BbsService`` instance. @@ -299,99 +322,179 @@ class BbsCommandHandler: self._config_store = config_store # ------------------------------------------------------------------ - # Public entry point + # Public entry point β€” called from EventHandler.on_contact_msg # ------------------------------------------------------------------ - def handle( + def handle_dm( self, - channel_idx: int, sender: str, sender_key: str, text: str, ) -> Optional[str]: - """Parse an incoming message and return a reply string (or ``None``). + """Parse a DM addressed to this node and return a reply (or ``None``). + + This is the **only** entry point for BBS commands. It is called + directly by ``EventHandler.on_contact_msg`` when a DM arrives whose + text starts with ``!``. The bot is never involved. + + The board is looked up from the single configured board via + ``BbsConfigStore.get_single_board()``. Args: - channel_idx: MeshCore channel index the message arrived on. - sender: Display name of the sender. - sender_key: Public key of the sender (hex string). - text: Raw message text. + sender: Display name of the DM sender. + sender_key: Public key of the sender (hex string). + text: Raw DM text. Returns: - Reply string, or ``None`` if no reply should be sent. + Reply string to send back as DM, or ``None`` for silent drop. """ text = (text or "").strip() - if not text.lower().startswith("!bbs"): + first = text.split()[0].lower() if text else "" + if not first.startswith("!"): return None - board = self._config_store.get_board_for_channel(channel_idx) + board = self._config_store.get_single_board() if board is None: + debug_print("BBS: no board configured, ignoring DM") return None # Whitelist check if board.allowed_keys and sender_key not in board.allowed_keys: debug_print( - f"BBS: silently dropping msg from {sender} " + f"BBS: silently dropping DM from {sender} " f"(key not in whitelist for board '{board.id}')" ) return None - parts = text.split(None, 1) - args = parts[1].strip() if len(parts) > 1 else "" - return self._dispatch(board, channel_idx, sender, sender_key, args) + # Channel for storing posted messages + channel_idx = board.channels[0] if board.channels else 0 + + # Route by command prefix + if first in ("!p",): + rest = text[len(first):].strip() + return self._handle_post_short(board, channel_idx, sender, sender_key, rest) + + if first in ("!r",): + rest = text[len(first):].strip() + return self._handle_read_short(board, rest) + + if first == "!bbs": + parts = text.split(None, 2) + sub = parts[1].lower() if len(parts) > 1 else "" + rest = parts[2] if len(parts) > 2 else "" + if sub == "post": + return self._handle_post(board, channel_idx, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown subcommand '{sub}'. " + self._handle_help(board) + + # Unknown !-command starting with something else + return None # ------------------------------------------------------------------ - # Dispatch + # Abbreviation helpers # ------------------------------------------------------------------ - def _dispatch(self, board, channel_idx, sender, sender_key, args): - sub = args.split(None, 1)[0].lower() if args else "" - rest = args.split(None, 1)[1] if len(args.split(None, 1)) > 1 else "" - if sub == "post": - return self._handle_post(board, channel_idx, sender, sender_key, rest) - if sub == "read": - return self._handle_read(board, rest) - if sub == "help" or not sub: - return self._handle_help(board) - return f"Unknown command '{sub}'. {self._handle_help(board)}" + @staticmethod + def compute_abbreviations(categories: List[str]) -> Dict[str, str]: + """Compute shortest unique prefix for each category. + + Returns a dict mapping ``abbrev.upper()`` β†’ ``category``. + + Examples:: + + ["URGENT", "MEDICAL", "LOGISTICS", "STATUS", "GENERAL"] + β†’ {"U": "URGENT", "M": "MEDICAL", "L": "LOGISTICS", + "S": "STATUS", "G": "GENERAL"} + + ["MEDICAL", "MISSING"] + β†’ {"ME": "MEDICAL", "MI": "MISSING"} + """ + abbrevs: Dict[str, str] = {} + cats_upper = [c.upper() for c in categories] + for cat in cats_upper: + for length in range(1, len(cat) + 1): + prefix = cat[:length] + # Unique if no other category starts with this prefix + if sum(1 for c in cats_upper if c.startswith(prefix)) == 1: + abbrevs[prefix] = cat + break + return abbrevs + + def _abbrev_table(self, categories: List[str]) -> str: + """Return a compact abbreviation table string, e.g. ``U=URGENT M=MEDICAL``.""" + abbrevs = self.compute_abbreviations(categories) + # abbrevs maps prefix β†’ full name; invert for display + inv = {v: k for k, v in abbrevs.items()} + return " ".join(f"{inv[c]}={c}" for c in [cu.upper() for cu in categories] if cu.upper() in inv) + + def _resolve_category(self, token: str, categories: List[str]) -> Optional[str]: + """Resolve *token* to a category via exact match or abbreviation. + + Returns the matching category string (original case from board + config), or ``None`` if unresolvable. + """ + token_up = token.upper() + cats_upper = [c.upper() for c in categories] + + # Exact match first + if token_up in cats_upper: + return categories[cats_upper.index(token_up)] + + # Abbreviation match + abbrevs = self.compute_abbreviations(categories) + if token_up in abbrevs: + matched = abbrevs[token_up] + return categories[cats_upper.index(matched)] + + return None + + def _resolve_region(self, token: str, regions: List[str]) -> Optional[str]: + """Resolve *token* to a region via exact (case-insensitive) match.""" + token_up = token.upper() + regs_upper = [r.upper() for r in regions] + if token_up in regs_upper: + return regions[regs_upper.index(token_up)] + return None # ------------------------------------------------------------------ - # post + # Short syntax β€” !p and !r # ------------------------------------------------------------------ - def _handle_post(self, board, channel_idx, sender, sender_key, args): + def _handle_post_short(self, board, channel_idx, sender, sender_key, args): + """Handle ``!p [region] ``.""" regions = board.regions categories = board.categories tokens = args.split(None, 2) if args else [] - if regions: - if len(tokens) < 3: - return ( - f"Usage: !bbs post [region] [category] [text] | " - f"Regions: {', '.join(regions)} | " - f"Categories: {', '.join(categories)}" - ) - region, category, text = tokens[0], tokens[1], tokens[2] - valid_r = [r.upper() for r in regions] - if region.upper() not in valid_r: - return f"Invalid region '{region}'. Valid: {', '.join(regions)}" - region = regions[valid_r.index(region.upper())] - valid_c = [c.upper() for c in categories] - if category.upper() not in valid_c: - return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_c.index(category.upper())] - else: - if len(tokens) < 2: - return ( - f"Usage: !bbs post [category] [text] | " - f"Categories: {', '.join(categories)}" - ) - region = "" - category, text = tokens[0], tokens[1] - valid_c = [c.upper() for c in categories] - if category.upper() not in valid_c: - return f"Invalid category '{category}'. Valid: {', '.join(categories)}" - category = categories[valid_c.index(category.upper())] + region = "" + if regions and tokens: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] # consume region token + + # Now tokens should be [abbrev, text] + if len(tokens) < 2: + abbr = self._abbrev_table(categories) + return ( + f"Usage: !p [region] | {abbr}" + ) + + cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else "" + # Rebuild text in case split(None,2) on a shorter string + if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3): + # re-split with region consumed + pass + + category = self._resolve_category(cat_token, categories) + if category is None: + abbr = self._abbrev_table(categories) + return ( + f"Unknown category '{cat_token}'. Valid: {abbr}" + ) msg = BbsMessage( channel=channel_idx, @@ -402,44 +505,107 @@ class BbsCommandHandler: region_label = f" [{region}]" if region else "" return f"Posted [{category}]{region_label}: {text[:60]}" - # ------------------------------------------------------------------ - # read - # ------------------------------------------------------------------ + def _handle_read_short(self, board, args): + """Handle ``!r [region] [abbrev]``. - def _handle_read(self, board, args): + With no arguments returns 5 most recent messages across all + categories and always includes the abbreviation table. + """ regions = board.regions categories = board.categories tokens = args.split() if args else [] + region = None category = None - if regions: - valid_r = [r.upper() for r in regions] - valid_c = [c.upper() for c in categories] - if tokens: - if tokens[0].upper() in valid_r: - region = regions[valid_r.index(tokens[0].upper())] - if len(tokens) >= 2: - if tokens[1].upper() in valid_c: - category = categories[valid_c.index(tokens[1].upper())] - else: - return f"Invalid category '{tokens[1]}'. Valid: {', '.join(categories)}" - else: - return f"Invalid region '{tokens[0]}'. Valid: {', '.join(regions)}" - else: - valid_c = [c.upper() for c in categories] - if tokens: - if tokens[0].upper() in valid_c: - category = categories[valid_c.index(tokens[0].upper())] - else: - return f"Invalid category '{tokens[0]}'. Valid: {', '.join(categories)}" + if tokens and regions: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + if tokens: + category = self._resolve_category(tokens[0], categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{tokens[0]}'. Valid: {abbr}" + + return self._format_messages(board, region, category, include_abbrevs=not args) + + # ------------------------------------------------------------------ + # Full syntax β€” !bbs post / read + # ------------------------------------------------------------------ + + def _handle_post(self, board, channel_idx, sender, sender_key, args): + """Handle ``!bbs post [region] ``.""" + regions = board.regions + categories = board.categories + tokens = args.split(None, 2) if args else [] + + region = "" + if regions and tokens: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + + if len(tokens) < 2: + abbr = self._abbrev_table(categories) + region_hint = f" [region]" if regions else "" + return f"Usage: !bbs post{region_hint} | {abbr}" + + cat_token, text = tokens[0], tokens[1] + category = self._resolve_category(cat_token, categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{cat_token}'. Valid: {abbr}" + + msg = BbsMessage( + channel=channel_idx, + region=region, category=category, + sender=sender, sender_key=sender_key, text=text, + ) + self._service.post_message(msg) + region_label = f" [{region}]" if region else "" + return f"Posted [{category}]{region_label}: {text[:60]}" + + def _handle_read(self, board, args): + """Handle ``!bbs read [region] [category]``.""" + regions = board.regions + categories = board.categories + tokens = args.split() if args else [] + + region = None + category = None + + if tokens and regions: + resolved_r = self._resolve_region(tokens[0], regions) + if resolved_r: + region = resolved_r + tokens = tokens[1:] + + if tokens: + category = self._resolve_category(tokens[0], categories) + if category is None: + abbr = self._abbrev_table(categories) + return f"Unknown category '{tokens[0]}'. Valid: {abbr}" + + return self._format_messages(board, region, category, include_abbrevs=False) + + # ------------------------------------------------------------------ + # Shared message formatter + # ------------------------------------------------------------------ + + def _format_messages(self, board, region, category, include_abbrevs: bool) -> str: messages = self._service.get_messages( board.channels, region=region, category=category, limit=self.READ_LIMIT, ) - if not messages: - return "BBS: no messages found." lines = [] + if include_abbrevs: + lines.append(self._handle_help(board)) + if not messages: + lines.append("BBS: no messages found.") + return "\n".join(lines) for m in messages: ts = m.timestamp[:16].replace("T", " ") region_label = f"[{m.region}] " if m.region else "" @@ -447,22 +613,13 @@ class BbsCommandHandler: return "\n".join(lines) # ------------------------------------------------------------------ - # help + # Help # ------------------------------------------------------------------ def _handle_help(self, board) -> str: - cats = ", ".join(board.categories) + abbr = self._abbrev_table(board.categories) + header = f"BBS [{board.name}] | !p [cat] [text] | !r [cat]" if board.regions: regs = ", ".join(board.regions) - return ( - f"BBS [{board.name}] | " - f"!bbs post [region] [cat] [text] | " - f"!bbs read [region] [cat] | " - f"Regions: {regs} | Categories: {cats}" - ) - return ( - f"BBS [{board.name}] | " - f"!bbs post [cat] [text] | " - f"!bbs read [cat] | " - f"Categories: {cats}" - ) + return f"{header} | Regions: {regs} | {abbr}" + return f"{header} | {abbr}" diff --git a/meshcore_gui/services/bot.py b/meshcore_gui/services/bot.py index 9279a72..d31a2c2 100644 --- a/meshcore_gui/services/bot.py +++ b/meshcore_gui/services/bot.py @@ -11,20 +11,19 @@ New keywords are added via ``BotConfig.keywords`` (data) without modifying the ``MeshBot`` class (code). Custom matching strategies can be implemented by subclassing and overriding ``_match_keyword``. -BBS integration -~~~~~~~~~~~~~~~ -``MeshBot.check_and_reply`` delegates ``!bbs`` commands to a -:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` when one -is injected via the ``bbs_handler`` parameter. When ``bbs_handler`` is -``None`` (default), BBS routing is simply skipped. +BBS separation +~~~~~~~~~~~~~~ +BBS commands (``!bbs``, ``!p``, ``!r``) are handled by +:class:`~meshcore_gui.services.bbs_service.BbsCommandHandler` which is +wired directly into +:class:`~meshcore_gui.ble.events.EventHandler`. They never pass +through ``MeshBot`` β€” the bot is a pure keyword/channel-message +responder only. """ import time from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Dict, List, Optional - -if TYPE_CHECKING: - from meshcore_gui.services.bbs_service import BbsCommandHandler +from typing import Callable, Dict, List, Optional from meshcore_gui.config import debug_print @@ -91,13 +90,11 @@ class MeshBot: config: BotConfig, command_sink: Callable[[Dict], None], enabled_check: Callable[[], bool], - bbs_handler: Optional["BbsCommandHandler"] = None, ) -> None: self._config = config self._sink = command_sink self._enabled = enabled_check self._last_reply: float = 0.0 - self._bbs_handler = bbs_handler def check_and_reply( self, @@ -108,7 +105,7 @@ class MeshBot: path_len: int, path_hashes: Optional[List[str]] = None, ) -> None: - """Evaluate an incoming message and queue a reply if appropriate. + """Evaluate an incoming channel message and queue a reply if appropriate. Guards (in order): 1. Bot is enabled (checkbox in GUI). @@ -117,6 +114,10 @@ class MeshBot: 4. Sender name does not end with ``'Bot'`` (prevent loops). 5. Cooldown period has elapsed. 6. Message text contains a recognised keyword. + + Note: BBS commands (``!bbs``, ``!p``, ``!r``) are NOT handled here. + They arrive as DMs and are handled by ``BbsCommandHandler`` directly + inside ``EventHandler.on_contact_msg``. """ # Guard 1: enabled? if not self._enabled(): @@ -141,27 +142,6 @@ class MeshBot: debug_print("BOT: cooldown active, skipping") return - # BBS routing: delegate !bbs commands to BbsCommandHandler - if self._bbs_handler is not None: - text_stripped = (text or "").strip() - if text_stripped.lower().startswith("!bbs"): - bbs_reply = self._bbs_handler.handle( - channel_idx=channel_idx, - sender=sender, - sender_key="", # sender_key not available at this call-site - text=text_stripped, - ) - if bbs_reply is not None: - self._last_reply = now - self._sink({ - "action": "send_message", - "channel": channel_idx, - "text": bbs_reply, - "_bot": True, - }) - debug_print(f"BOT: BBS reply to '{sender}': {bbs_reply[:60]}") - return # Do not fall through to keyword matching - # Guard 6: keyword match template = self._match_keyword(text) if template is None: From d9ad4c83b8b1a7a2cfa7bbbf7813e9f29ee4ba27 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:01:07 +0100 Subject: [PATCH 10/14] feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0) Adds an offline BBS accessible via Direct Message to the node's own key. Access is channel-based: anyone seen on a configured BBS channel is automatically whitelisted for DM access. Channels stay clean. - Multi-channel configuration: any combination of device channels can be selected; senders on any of them are auto-whitelisted - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - handle_channel_msg: bootstrap reply on channel + auto-whitelist sender - handle_dm: DM entry point, checks whitelist, routes to post/read/help - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- CHANGELOG.md | 12 +-- README.md | 87 +++++++++++----------- meshcore_gui/ble/events.py | 17 +++++ meshcore_gui/gui/panels/bbs_panel.py | 86 +++++++++++---------- meshcore_gui/services/bbs_config_store.py | 91 ++++++++++++++++------- meshcore_gui/services/bbs_service.py | 71 +++++++++++++++++- 6 files changed, 247 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13142ee..68b92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,18 +35,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ### Added - πŸ†• **BBS β€” Bulletin Board System** β€” offline berichtenbord voor mesh-netwerken. - - Één node beheert één board op één channel. Alle commando's via **Direct Message** aan de node; het channel blijft schoon. - - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). `!r` zonder args toont de afkortingstabel altijd mee. + - **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel. + - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). - Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`. - - Optioneel regio-filter (`!p Zwolle U hulp nodig`) en sender-whitelist. - - Settings-pagina (`/bbs-settings`): één channel-selector, categorieΓ«n, retentie (uur), en een ingeklapte Advanced-sectie voor regio's en allowed keys. + - Optioneel regio-filter en handmatige allowed-keys override in Advanced. + - Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieΓ«n, retentie, Advanced voor regio's en handmatige keys. - Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode). ### Changed -- πŸ”„ **`ble/events.py`** β€” DMs die beginnen met `!` worden direct verwerkt door `BbsCommandHandler`, volledig los van `MeshBot`. +- πŸ”„ **`ble/events.py`** β€” `on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden volledig los van `MeshBot`. - πŸ”„ **`services/bot.py`** β€” `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd. -- πŸ”„ **`services/bbs_config_store.py`** β€” `get_single_board()`, `set_single_board()`, `clear_single_board()` toegevoegd. +- πŸ”„ **`services/bbs_config_store.py`** β€” `configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`. - πŸ”„ **`gui/dashboard.py`** β€” `BbsPanel` geregistreerd, `πŸ“‹ BBS` drawer-item toegevoegd. ### Storage diff --git a/README.md b/README.md index 2361b3d..1b056d0 100644 --- a/README.md +++ b/README.md @@ -1188,91 +1188,94 @@ meshcore-gui/ MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. -### Design +### Toegangsmodel -One node manages one board. Multiple boards require multiple nodes. All BBS commands are sent as a **Direct Message to the BBS node** β€” the channel stays clean and replies are private to the sender. +De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node β€” het channel zelf blijft schoon. ``` -User ──DM──▢ BBS node (public key) - processes command -User ◀──DM── reply (only visible to sender) +Eerste contact: !bbs help op het channel + β†’ node ziet de public key β†’ whitelist +Daarna: !p U hulp nodig als DM naar de node + β†’ verwerkt, reply via DM terug ``` -Channel commands remain available as a fallback, but DM is the primary interface. +Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd. ### Settings -Open the BBS settings via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. +Open via het tandwiel (βš™) in het BBS-panel, of navigeer naar `/bbs-settings`. ``` BBS Settings -───────────────────────────────────────────── -Channel: [2] NoodNet Zwolle β–Ό +────────────────────────────────────────── +Channels: β˜‘ [1] NoodNet Zwolle + β˜‘ [2] NoodNet Dalfsen + ☐ [3] NoodNet OV Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL Retain: 48 hours [Save] β–Ά Advanced Regions (comma-separated) - Allowed keys (empty = everyone on the channel) + Allowed keys (leeg = auto-geleerd via channel-activiteit) ``` -- **Channel** β€” select which device channel this node's board listens on. -- **Categories** β€” comma-separated list of valid category tags. -- **Retain** β€” message retention in hours (default 48). -- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. -- **Advanced β†’ Allowed keys** β€” sender public key whitelist; empty = all senders allowed. +- **Channels** β€” vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS. +- **Categories** β€” komma-gescheiden lijst van geldige categorie-tags. +- **Retain** β€” berichtretentie in uren (standaard 48). +- **Advanced β†’ Regions** β€” optionele regio-tags voor geografische filtering. +- **Advanced β†’ Allowed keys** β€” handmatige whitelist-override; leeg laten om alleen automatisch te leren. -### Command syntax +### Commando-syntax -#### Short syntax +#### Korte syntax -| Command | Description | +| Commando | Beschrijving | |---|---| -| `!p ` | Post a message | -| `!p ` | Post with region | -| `!r` | Read 5 most recent (all categories) | -| `!r ` | Read filtered by category | -| `!r ` | Read filtered by region and category | +| `!p ` | Bericht posten | +| `!p ` | Posten met regio | +| `!r` | Laatste 5 berichten lezen (alle categorieΓ«n) | +| `!r ` | Lezen gefilterd op categorie | +| `!r ` | Lezen gefilterd op regio en categorie | -Category abbreviations are computed automatically as the shortest unique prefix within the configured category list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: +Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: ``` U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), the node calculates longer prefixes automatically: `ME` and `MI`. The `!r` and `!bbs help` replies always include the current abbreviation table. +`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee. -#### Full syntax +#### Volledige syntax -| Command | Description | +| Commando | Beschrijving | |---|---| -| `!bbs help` | Show commands and abbreviation table | -| `!bbs post ` | Post a message | -| `!bbs post ` | Post with region | -| `!bbs read` | Read 5 most recent | -| `!bbs read ` | Read filtered by category | -| `!bbs read ` | Read filtered by region and category | +| `!bbs help` | Toon commando's en afkortingstabel | +| `!bbs post ` | Bericht posten | +| `!bbs post ` | Posten met regio | +| `!bbs read` | Laatste 5 berichten | +| `!bbs read ` | Gefilterd op categorie | +| `!bbs read ` | Gefilterd op regio en categorie | -#### Example help reply +#### Voorbeeld help-reply ``` -BBS [NoodNet Zwolle] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -### Error handling +### Foutafhandeling -| Situation | Reply | +| Situatie | Reply | |---|---| -| Unknown category | Lists valid categories and abbreviations | -| Ambiguous abbreviation | Lists all matching categories | -| Sender not on whitelist | Silent drop β€” no reply | +| Onbekende categorie | Lijst met geldige categorieΓ«n en afkortingen | +| Ambigue afkorting | Lijst met overeenkomende categorieΓ«n | +| Sender niet op whitelist | Silent drop β€” geen reply | ### Storage ``` -~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) -~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration (v2 format) +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag (WAL-mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board-configuratie ``` --- diff --git a/meshcore_gui/ble/events.py b/meshcore_gui/ble/events.py index 35da67e..361836c 100644 --- a/meshcore_gui/ble/events.py +++ b/meshcore_gui/ble/events.py @@ -301,6 +301,23 @@ class EventHandler: message_hash=msg_hash, )) + # BBS channel hook: auto-whitelist sender + bootstrap reply for !-commands. + # Runs on every message on a configured BBS channel, independent of the bot. + if self._bbs_handler is not None and self._command_sink is not None: + bbs_reply = self._bbs_handler.handle_channel_msg( + channel_idx=ch_idx, + sender=sender, + sender_key=sender_pubkey, + text=msg_text, + ) + if bbs_reply is not None: + debug_print(f"BBS channel reply on ch{ch_idx} to {sender!r}: {bbs_reply[:60]}") + self._command_sink({ + "action": "send_message", + "channel": ch_idx, + "text": bbs_reply, + }) + self._bot.check_and_reply( sender=sender, text=msg_text, diff --git a/meshcore_gui/gui/panels/bbs_panel.py b/meshcore_gui/gui/panels/bbs_panel.py index 2e7ee6b..5abb6b9 100644 --- a/meshcore_gui/gui/panels/bbs_panel.py +++ b/meshcore_gui/gui/panels/bbs_panel.py @@ -427,20 +427,9 @@ class BbsSettingsPage: # ------------------------------------------------------------------ def _render_settings(self) -> None: - """Render the single-board settings block.""" + """Render the board settings block.""" board = self._config_store.get_single_board() - - # Build channel options: {idx: "[idx] name"} - ch_options = { - ch.get('idx', ch.get('index', 0)): - f"[{ch.get('idx', ch.get('index', 0))}] {ch.get('name', '?')}" - for ch in self._device_channels - } - - current_idx = ( - board.channels[0] if board and board.channels - else next(iter(ch_options), 0) - ) + active_channels = set(board.channels) if board else set() cats_value = ( ', '.join(board.categories) if board else ', '.join(DEFAULT_CATEGORIES) @@ -452,25 +441,31 @@ class BbsSettingsPage: adv_regions_value = ', '.join(board.regions) if board else '' adv_keys_value = ', '.join(board.allowed_keys) if board else '' - # ── Main block ─────────────────────────────────────────────── - with ui.column().classes('w-full gap-2'): - with ui.row().classes('w-full items-center gap-2'): - ui.label('Channel:').classes('text-xs text-gray-600 w-24 shrink-0') - ch_select = ui.select( - options=ch_options, - value=current_idx, - ).classes('text-xs flex-grow') + # ── Channel checkboxes ─────────────────────────────────────── + ch_checks: Dict[int, object] = {} + with ui.column().classes('w-full gap-1'): + ui.label('Channels:').classes('text-xs text-gray-600') + with ui.column().classes('w-full gap-1 pl-2'): + for ch in self._device_channels: + idx = ch.get('idx', ch.get('index', 0)) + name = ch.get('name', f'Ch {idx}') + cb = ui.checkbox( + f'[{idx}] {name}', + value=idx in active_channels, + ).classes('text-xs') + ch_checks[idx] = cb - with ui.row().classes('w-full items-center gap-2'): - ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') - cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') + # ── Categories + retention ─────────────────────────────────── + with ui.row().classes('w-full items-center gap-2 mt-1'): + ui.label('Categories:').classes('text-xs text-gray-600 w-24 shrink-0') + cats_input = ui.input(value=cats_value).classes('text-xs flex-grow') - with ui.row().classes('w-full items-center gap-2'): - ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') - retention_input = ui.input( - value=retention_value, - ).classes('text-xs').style('max-width: 80px') - ui.label('hours').classes('text-xs text-gray-600') + with ui.row().classes('w-full items-center gap-2'): + ui.label('Retain:').classes('text-xs text-gray-600 w-24 shrink-0') + retention_input = ui.input( + value=retention_value, + ).classes('text-xs').style('max-width: 80px') + ui.label('hours').classes('text-xs text-gray-600') # ── Advanced (collapsed) ───────────────────────────────────── with ui.expansion('Advanced', value=False).classes('w-full mt-2').props('dense'): @@ -482,20 +477,27 @@ class BbsSettingsPage: ).classes('w-full text-xs') keys_input = ui.input( - label='Allowed keys (empty = everyone on the channel)', + label='Allowed keys (empty = auto-learned from channel activity)', value=adv_keys_value, ).classes('w-full text-xs') # ── Save ───────────────────────────────────────────────────── def _save( - cs=ch_select, + cc=ch_checks, ci=cats_input, ri=retention_input, rgi=regions_input, ki=keys_input, ) -> None: - idx = cs.value - ch_name = ch_options.get(idx, f'Ch {idx}') + selected = [idx for idx, cb in cc.items() if cb.value] + if not selected: + ui.notify('Select at least one channel.', type='warning') + return + + ch_names = { + ch.get('idx', ch.get('index', 0)): ch.get('name', '?') + for ch in self._device_channels + } categories = [ c.strip().upper() for c in (ci.value or '').split(',') if c.strip() @@ -505,18 +507,22 @@ class BbsSettingsPage: except ValueError: ret_hours = DEFAULT_RETENTION_HOURS regions = [r.strip() for r in (rgi.value or '').split(',') if r.strip()] - allowed_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + # Only pass allowed_keys if the field was explicitly filled; + # empty field means "keep auto-learned keys" + raw_keys = [k.strip() for k in (ki.value or '').split(',') if k.strip()] + allowed_keys = raw_keys if raw_keys else None - self._config_store.set_single_board( - channel_idx=idx, - channel_name=ch_name, + self._config_store.configure_board( + channel_indices=selected, + channel_names=ch_names, categories=categories, retention_hours=ret_hours, regions=regions, allowed_keys=allowed_keys, ) - debug_print(f'BBS settings: saved ch{idx} {ch_name}') - ui.notify(f'BBS saved β€” {ch_name}.', type='positive') + ch_labels = ', '.join(f"[{i}] {ch_names.get(i, '?')}" for i in sorted(selected)) + debug_print(f'BBS settings: configured channels {ch_labels}') + ui.notify(f'BBS saved β€” {ch_labels}.', type='positive') self._rebuild() ui.button('Save', on_click=_save).props('no-caps').classes('text-xs mt-2') diff --git a/meshcore_gui/services/bbs_config_store.py b/meshcore_gui/services/bbs_config_store.py index c8c925d..2eb5a35 100644 --- a/meshcore_gui/services/bbs_config_store.py +++ b/meshcore_gui/services/bbs_config_store.py @@ -9,7 +9,7 @@ Design (v1.14.0 redesign) One node = one board. The settings UI exposes a single channel selector; the board id is always ``ch{channel_idx}`` and the name is taken from the device channel. There is no Create/Delete UI β€” the board is saved or -cleared through :meth:`set_single_board` / :meth:`clear_single_board`. +cleared through :meth:`configure_board` / :meth:`clear_board`. Multiple-board storage is retained internally so that the storage layer (``bbs_service.py``) and :meth:`get_board_for_channel` remain unchanged. @@ -305,13 +305,11 @@ class BbsConfigStore: return any(b.id == board_id for b in self._boards) # ------------------------------------------------------------------ - # Single-board convenience API (v1.14.0 redesign) + # Board API (v1.14.0 redesign) # ------------------------------------------------------------------ def get_single_board(self) -> Optional[BbsBoard]: - """Return the one configured board, or ``None`` if none exists. - - This is the primary accessor for the simplified single-board UI. + """Return the configured board, or ``None`` if none exists. Returns: The first ``BbsBoard`` in the store, or ``None``. @@ -321,52 +319,89 @@ class BbsConfigStore: return BbsBoard.from_dict(self._boards[0].to_dict()) return None - def set_single_board( + def configure_board( self, - channel_idx: int, - channel_name: str, + channel_indices: List[int], + channel_names: Dict[int, str], categories: List[str], retention_hours: int = DEFAULT_RETENTION_HOURS, regions: Optional[List[str]] = None, allowed_keys: Optional[List[str]] = None, ) -> None: - """Replace the single board with a fresh config derived from one channel. + """Save the board configuration. - The board id is always ``ch{channel_idx}`` and the board name is - taken from *channel_name*. Any previously stored boards are - discarded so the store always holds at most one board. + Multiple channels can be assigned. Every sender seen on any of + these channels is automatically eligible for DM access (the + worker calls :meth:`add_allowed_key` when it sees them). + + The board id is always ``'bbs_board'``. The board name is built + from the channel names in *channel_names*. Args: - channel_idx: MeshCore channel index to assign to this board. - channel_name: Human-readable name of the channel (display only). + channel_indices: MeshCore channel indices to assign. + channel_names: Mapping ``idx β†’ display name`` for labelling. categories: Category tag list. retention_hours: Message retention period in hours. - regions: Optional region tags (``None`` β†’ empty list). - allowed_keys: Sender public key whitelist (``None`` β†’ all allowed). + regions: Optional region tags. + allowed_keys: Manual sender key whitelist seed (auto-learned + keys are added via :meth:`add_allowed_key`). """ + name = ", ".join( + channel_names.get(i, f"Ch {i}") for i in sorted(channel_indices) + ) or "BBS" + + # Preserve existing auto-learned keys unless caller supplies a new list + existing = self.get_single_board() + merged_keys = list(allowed_keys) if allowed_keys is not None else ( + existing.allowed_keys if existing else [] + ) + board = BbsBoard( - id=f"ch{channel_idx}", - name=channel_name, - channels=[channel_idx], + id="bbs_board", + name=name, + channels=sorted(channel_indices), categories=list(categories), regions=list(regions) if regions else [], retention_hours=retention_hours, - allowed_keys=list(allowed_keys) if allowed_keys else [], + allowed_keys=merged_keys, ) with self._lock: self._boards = [board] self._save_unlocked() debug_print( - f"BBS config: single board set β†’ ch{channel_idx} '{channel_name}'" + f"BBS config: board configured β†’ channels={sorted(channel_indices)} " + f"name='{name}'" ) - def clear_single_board(self) -> None: - """Remove the configured board (disable BBS on this node). - - After this call :meth:`get_single_board` returns ``None`` and - the BBS command handler will not respond to any channel. - """ + def clear_board(self) -> None: + """Remove the configured board (disable BBS on this node).""" with self._lock: self._boards = [] self._save_unlocked() - debug_print("BBS config: single board cleared") + debug_print("BBS config: board cleared") + + def add_allowed_key(self, sender_key: str) -> bool: + """Add *sender_key* to the board's allowed_keys whitelist. + + Called automatically by the worker whenever a sender is seen on + a configured BBS channel. No-op if the key is already present + or if no board is configured. + + Args: + sender_key: Public key hex string of the sender. + + Returns: + ``True`` if the key was newly added, ``False`` otherwise. + """ + if not sender_key: + return False + with self._lock: + if not self._boards: + return False + board = self._boards[0] + if sender_key in board.allowed_keys: + return False + board.allowed_keys.append(sender_key) + self._save_unlocked() + debug_print(f"BBS config: auto-whitelisted key {sender_key[:12]}…") + return True diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index 0432eef..e73ff18 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -322,7 +322,76 @@ class BbsCommandHandler: self._config_store = config_store # ------------------------------------------------------------------ - # Public entry point β€” called from EventHandler.on_contact_msg + # Public entry points + # ------------------------------------------------------------------ + + def handle_channel_msg( + self, + channel_idx: int, + sender: str, + sender_key: str, + text: str, + ) -> Optional[str]: + """Handle a channel message on a configured BBS channel. + + Called from ``EventHandler.on_channel_msg`` **after** the message + has been stored. Two responsibilities: + + 1. **Auto-whitelist**: every sender seen on a BBS channel gets their + key added to ``allowed_keys`` so they can use DMs afterwards. + 2. **Bootstrap reply**: if the message starts with ``!``, reply on + the channel so the sender knows the BBS is active and receives + the abbreviation table. + + Args: + channel_idx: MeshCore channel index the message arrived on. + sender: Display name of the sender. + sender_key: Public key of the sender (hex string). + text: Raw message text. + + Returns: + Reply string to post on the channel, or ``None``. + """ + board = self._config_store.get_single_board() + if board is None: + return None + if channel_idx not in board.channels: + return None + + # Auto-whitelist: register this sender so they can use DMs + if sender_key: + self._config_store.add_allowed_key(sender_key) + + # Bootstrap reply only for !-commands + text = (text or "").strip() + if not text.startswith("!"): + return None + + first = text.split()[0].lower() + channel_for_post = channel_idx + + if first == "!p": + rest = text[len(first):].strip() + return self._handle_post_short(board, channel_for_post, sender, sender_key, rest) + + if first == "!r": + rest = text[len(first):].strip() + return self._handle_read_short(board, rest) + + if first == "!bbs": + parts = text.split(None, 2) + sub = parts[1].lower() if len(parts) > 1 else "" + rest = parts[2] if len(parts) > 2 else "" + if sub == "post": + return self._handle_post(board, channel_for_post, sender, sender_key, rest) + if sub == "read": + return self._handle_read(board, rest) + if sub == "help" or not sub: + return self._handle_help(board) + return f"Unknown subcommand '{sub}'. " + self._handle_help(board) + + return None + # ------------------------------------------------------------------ def handle_dm( From e2a911838bfa687457bb89ba5d4984dd398e87a5 Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:38:32 +0100 Subject: [PATCH 11/14] feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0) Adds an offline BBS accessible via Direct Message to the node's own key. Access is channel-based: anyone seen on a configured BBS channel is automatically whitelisted for DM access. Channels stay clean. - Multi-channel configuration: any combination of device channels can be selected; senders on any of them are auto-whitelisted - Short syntax: !p and !r [cat] alongside full !bbs syntax - Category abbreviations computed automatically (shortest unique prefix) - handle_channel_msg: bootstrap reply on channel + auto-whitelist sender - handle_dm: DM entry point, checks whitelist, routes to post/read/help - DM reply routed back to sender via command_sink - SQLite message store with WAL mode and configurable retention --- README.md | 80 ++++++++++++++-------------- meshcore_gui/services/bbs_service.py | 53 +++++++++--------- 2 files changed, 69 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 1b056d0..8e72d38 100644 --- a/README.md +++ b/README.md @@ -1188,22 +1188,22 @@ meshcore-gui/ MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. -### Toegangsmodel +### Access model -De beheerder koppelt één of meer channels aan het BBS. Iedereen die op zo'n channel een bericht stuurt wordt automatisch gewhitelist. Daarna kunnen zij commando's sturen via **Direct Message** aan de BBS-node β€” het channel zelf blijft schoon. +The operator links one or more channels to the BBS. Anyone who sends a message on a configured BBS channel is automatically added to the whitelist. After that, they can send commands via **Direct Message** to the BBS node β€” the channel itself stays clean. ``` -Eerste contact: !bbs help op het channel - β†’ node ziet de public key β†’ whitelist -Daarna: !p U hulp nodig als DM naar de node - β†’ verwerkt, reply via DM terug +First contact: !bbs help on the configured channel + β†’ node sees the public key β†’ whitelists it +After that: !p U need assistance as DM to the node + β†’ processed, reply sent back via DM ``` -Wie nooit iets heeft gestuurd op een geconfigureerd channel staat niet op de whitelist en wordt silently genegeerd. +Anyone who has never sent a message on a configured channel is not on the whitelist and is silently ignored. ### Settings -Open via het tandwiel (βš™) in het BBS-panel, of navigeer naar `/bbs-settings`. +Open via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. ``` BBS Settings @@ -1217,65 +1217,65 @@ Retain: 48 hours β–Ά Advanced Regions (comma-separated) - Allowed keys (leeg = auto-geleerd via channel-activiteit) + Allowed keys (empty = auto-learned from channel activity) ``` -- **Channels** β€” vink alle channels aan waarvan deelnemers toegang krijgen tot het BBS. -- **Categories** β€” komma-gescheiden lijst van geldige categorie-tags. -- **Retain** β€” berichtretentie in uren (standaard 48). -- **Advanced β†’ Regions** β€” optionele regio-tags voor geografische filtering. -- **Advanced β†’ Allowed keys** β€” handmatige whitelist-override; leeg laten om alleen automatisch te leren. +- **Channels** β€” check all channels whose participants should have access to the BBS. Multiple channels can be selected. +- **Categories** β€” comma-separated list of valid category tags. +- **Retain** β€” message retention in hours (default 48). +- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. +- **Advanced β†’ Allowed keys** β€” manual whitelist override; leave empty to rely on auto-learned keys only. -### Commando-syntax +### Command syntax -#### Korte syntax +#### Short syntax -| Commando | Beschrijving | +| Command | Description | |---|---| -| `!p ` | Bericht posten | -| `!p ` | Posten met regio | -| `!r` | Laatste 5 berichten lezen (alle categorieΓ«n) | -| `!r ` | Lezen gefilterd op categorie | -| `!r ` | Lezen gefilterd op regio en categorie | +| `!p ` | Post a message | +| `!p ` | Post with region | +| `!r` | Read 5 most recent messages (all categories) | +| `!r ` | Read filtered by category | +| `!r ` | Read filtered by region and category | -Categorie-afkortingen worden automatisch berekend als de kortste unieke prefix. Voorbeeld met `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: +Category abbreviations are computed automatically as the shortest unique prefix within the configured list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: ``` U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -`!r` zonder argumenten en `!bbs help` geven altijd de afkortingstabel mee. +If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), longer prefixes are calculated automatically: `ME` and `MI`. The `!r` (without arguments) and `!bbs help` replies always include the current abbreviation table. -#### Volledige syntax +#### Full syntax -| Commando | Beschrijving | +| Command | Description | |---|---| -| `!bbs help` | Toon commando's en afkortingstabel | -| `!bbs post ` | Bericht posten | -| `!bbs post ` | Posten met regio | -| `!bbs read` | Laatste 5 berichten | -| `!bbs read ` | Gefilterd op categorie | -| `!bbs read ` | Gefilterd op regio en categorie | +| `!bbs help` | Show commands and abbreviation table | +| `!bbs post ` | Post a message | +| `!bbs post ` | Post with region | +| `!bbs read` | Read 5 most recent messages | +| `!bbs read ` | Read filtered by category | +| `!bbs read ` | Read filtered by region and category | -#### Voorbeeld help-reply +#### Example help reply ``` BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL ``` -### Foutafhandeling +### Error handling -| Situatie | Reply | +| Situation | Reply | |---|---| -| Onbekende categorie | Lijst met geldige categorieΓ«n en afkortingen | -| Ambigue afkorting | Lijst met overeenkomende categorieΓ«n | -| Sender niet op whitelist | Silent drop β€” geen reply | +| Unknown category | Lists valid categories and abbreviations | +| Ambiguous abbreviation | Lists all matching categories | +| Sender not on whitelist | Silent drop β€” no reply | ### Storage ``` -~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag (WAL-mode) -~/.meshcore-gui/bbs/bbs_config.json β€” Board-configuratie +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration ``` --- diff --git a/meshcore_gui/services/bbs_service.py b/meshcore_gui/services/bbs_service.py index e73ff18..9f04bc7 100644 --- a/meshcore_gui/services/bbs_service.py +++ b/meshcore_gui/services/bbs_service.py @@ -536,34 +536,32 @@ class BbsCommandHandler: """Handle ``!p [region] ``.""" regions = board.regions categories = board.categories - tokens = args.split(None, 2) if args else [] + + # First token may be a region; split loosely to detect it + first_tokens = args.split(None, 1) if args else [] region = "" - if regions and tokens: - resolved_r = self._resolve_region(tokens[0], regions) + remainder = args # everything after optional region + + if regions and first_tokens: + resolved_r = self._resolve_region(first_tokens[0], regions) if resolved_r: region = resolved_r - tokens = tokens[1:] # consume region token + remainder = first_tokens[1] if len(first_tokens) > 1 else "" - # Now tokens should be [abbrev, text] - if len(tokens) < 2: + # Split remainder into exactly [category/abbrev, full_text] + cat_and_text = remainder.split(None, 1) if remainder else [] + + if len(cat_and_text) < 2: abbr = self._abbrev_table(categories) - return ( - f"Usage: !p [region] | {abbr}" - ) + return f"Usage: !p [region] | {abbr}" - cat_token, text = tokens[0], tokens[1] if len(tokens) >= 2 else "" - # Rebuild text in case split(None,2) on a shorter string - if len(args.split(None, 2 if not region else 3)) > (2 if not region else 3): - # re-split with region consumed - pass + cat_token, text = cat_and_text[0], cat_and_text[1] category = self._resolve_category(cat_token, categories) if category is None: abbr = self._abbrev_table(categories) - return ( - f"Unknown category '{cat_token}'. Valid: {abbr}" - ) + return f"Unknown category '{cat_token}'. Valid: {abbr}" msg = BbsMessage( channel=channel_idx, @@ -609,21 +607,28 @@ class BbsCommandHandler: """Handle ``!bbs post [region] ``.""" regions = board.regions categories = board.categories - tokens = args.split(None, 2) if args else [] + + # First token may be a region; split loosely to detect it + first_tokens = args.split(None, 1) if args else [] region = "" - if regions and tokens: - resolved_r = self._resolve_region(tokens[0], regions) + remainder = args # everything after optional region + + if regions and first_tokens: + resolved_r = self._resolve_region(first_tokens[0], regions) if resolved_r: region = resolved_r - tokens = tokens[1:] + remainder = first_tokens[1] if len(first_tokens) > 1 else "" - if len(tokens) < 2: + # Split remainder into exactly [category, full_text] + cat_and_text = remainder.split(None, 1) if remainder else [] + + if len(cat_and_text) < 2: abbr = self._abbrev_table(categories) - region_hint = f" [region]" if regions else "" + region_hint = " [region]" if regions else "" return f"Usage: !bbs post{region_hint} | {abbr}" - cat_token, text = tokens[0], tokens[1] + cat_token, text = cat_and_text[0], cat_and_text[1] category = self._resolve_category(cat_token, categories) if category is None: abbr = self._abbrev_table(categories) From 2963e1c8552e2da6442973ec3369ed0e328693ca Mon Sep 17 00:00:00 2001 From: pe1hvh Date: Sat, 14 Mar 2026 20:58:56 +0100 Subject: [PATCH 12/14] feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax --- CHANGELOG.md | 186 +++- README.md | 103 +- {docs => meshcore_gui}/CHANGELOG.md | 149 +++ meshcore_gui/__main__.py | 14 +- meshcore_gui/ble/commands.py | 100 +- meshcore_gui/ble/events.py | 170 ++- meshcore_gui/ble/events.py.bak | 379 +++++++ meshcore_gui/ble/worker.py | 41 +- meshcore_gui/ble/worker.py.bak | 964 ++++++++++++++++++ meshcore_gui/config.py | 9 +- meshcore_gui/core/shared_data.py | 52 +- meshcore_gui/gui/dashboard.py | 167 +-- meshcore_gui/gui/panels/__init__.py | 1 + meshcore_gui/gui/panels/bbs_panel.py | 543 ++++++++++ meshcore_gui/gui/panels/map_panel.py | 11 +- meshcore_gui/gui/panels/messages_panel.py | 27 +- meshcore_gui/gui/panels/room_server_panel.py | 43 +- meshcore_gui/gui/route_page.py | 37 +- meshcore_gui/meshcore_gui/config.py | 395 +++++++ meshcore_gui/meshcore_gui/gui/dashboard.py | 850 +++++++++++++++ .../meshcore_gui/gui/panels/__init__.py | 18 + .../meshcore_gui/gui/panels/bbs_panel.py | 614 +++++++++++ .../meshcore_gui/services/bbs_config_store.py | 302 ++++++ .../meshcore_gui/services/bbs_service.py | 468 +++++++++ meshcore_gui/meshcore_gui/services/bot.py | 221 ++++ meshcore_gui/services/bbs_config_store.py | 407 ++++++++ meshcore_gui/services/bbs_service.py | 699 +++++++++++++ meshcore_gui/services/bot.py | 15 +- meshcore_gui/static/leaflet_map_panel.js | 129 ++- 29 files changed, 6832 insertions(+), 282 deletions(-) rename {docs => meshcore_gui}/CHANGELOG.md (83%) create mode 100644 meshcore_gui/ble/events.py.bak create mode 100644 meshcore_gui/ble/worker.py.bak create mode 100644 meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/meshcore_gui/config.py create mode 100644 meshcore_gui/meshcore_gui/gui/dashboard.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/__init__.py create mode 100644 meshcore_gui/meshcore_gui/gui/panels/bbs_panel.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_config_store.py create mode 100644 meshcore_gui/meshcore_gui/services/bbs_service.py create mode 100644 meshcore_gui/meshcore_gui/services/bot.py create mode 100644 meshcore_gui/services/bbs_config_store.py create mode 100644 meshcore_gui/services/bbs_service.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2202e54..68b92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,4 @@ -## [1.13.1] - 2026-03-09 - -### Fixed -- Route map markers now use the same JS-rendered node icons as the main MAP instead of NiceGUI default blue markers. -- Route detail pages now bootstrap their Leaflet assets explicitly so the shared map icon runtime is available there too. - -### Changed -- Route maps are now rendered browser-side through the shared Leaflet JS runtime for icon consistency with MAP, Messages, and Archive. # CHANGELOG @@ -16,43 +8,181 @@ All notable changes to MeshCore GUI are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/). + --- -## [1.13.1] - 2026-03-09 β€” Message Icon Consistency + +> **πŸ“ˆ Performance note β€” v1.13.1 through v1.13.4** +> Although versions 1.13.1–1.13.4 were released as targeted bugfix releases, the +> cumulative effect of the fixes delivered a significant performance improvement: +> +> - **v1.13.1** β€” Bot non-response fix eliminated a silent failure path that caused +> repeated dedup-marked command re-evaluation on every message tick. +> - **v1.13.2** β€” Map display fixes prevented Leaflet from being initialized on hidden +> zero-size containers, removing a source of repeated failed bootstrap retries and +> associated DOM churn. +> - **v1.13.3** β€” Active panel timer gating reduced the 500 ms dashboard update work to +> only the currently visible panel, cutting unnecessary UI updates and background +> redraw load substantially β€” especially noticeable over VPN or on slower hardware. +> - **v1.13.4** β€” Room Server event classification fix and sender name resolution removed +> redundant fallback processing paths and reduced per-tick contact lookup overhead. +> +> Users upgrading from v1.12.x or earlier will notice noticeably faster panel switching, +> lower CPU usage during idle operation, and more stable map rendering. + +--- +## [1.14.0] - 2026-03-14 β€” BBS (Bulletin Board System) + +### Added + +- πŸ†• **BBS β€” Bulletin Board System** β€” offline berichtenbord voor mesh-netwerken. + - **Toegangsmodel:** de beheerder selecteert één of meer channels in de settings. Iedereen die op een van die channels een bericht stuurt, wordt automatisch gewhitelist en kan daarna commando's sturen via **Direct Message** aan de node. Het channel blijft schoon; alleen de eerste interactie verloopt via het channel. + - Korte syntax: `!p ` (post) en `!r [cat]` (lezen). Categorie-afkortingen automatisch berekend als kortste unieke prefix (bijv. `U=URGENT M=MEDICAL`). + - Volledige syntax behouden: `!bbs post`, `!bbs read`, `!bbs help`. + - Optioneel regio-filter en handmatige allowed-keys override in Advanced. + - Settings-pagina (`/bbs-settings`): checkboxes per channel, categorieΓ«n, retentie, Advanced voor regio's en handmatige keys. + - Berichten opgeslagen in SQLite (`~/.meshcore-gui/bbs/bbs_messages.db`, WAL-mode). ### Changed -- πŸ”„ `meshcore_gui/gui/constants.py` β€” Added shared helper functions to resolve node-type icons and labels from the same contact type mapping used by the map and contacts panel -- πŸ”„ `meshcore_gui/core/models.py` β€” `Message.format_line()` now supports an optional sender prefix so message-related views can prepend the same node icon set without changing existing formatting logic -- πŸ”„ `meshcore_gui/gui/panels/messages_panel.py` β€” Message rows now prepend the sender with the same node icon mapping as the map/contact views -- πŸ”„ `meshcore_gui/gui/archive_page.py` β€” Archive rows now use the same sender icon mapping as the live messages panel and map/contact views -- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Route header and route detail table now show node-type icons derived from the shared contact type mapping instead of generic hardcoded role icons + +- πŸ”„ **`ble/events.py`** β€” `on_channel_msg` roept `BbsCommandHandler.handle_channel_msg()` aan op geconfigureerde BBS-channels: auto-whitelist + bootstrap reply. `on_contact_msg` stuurt `!`-DMs direct naar `handle_dm()`. Beide paden volledig los van `MeshBot`. +- πŸ”„ **`services/bot.py`** β€” `MeshBot` is weer een pure keyword/channel responder; BBS-routing verwijderd. +- πŸ”„ **`services/bbs_config_store.py`** β€” `configure_board()` (multi-channel), `add_allowed_key()` (auto-whitelist), `clear_board()`. +- πŸ”„ **`gui/dashboard.py`** β€” `BbsPanel` geregistreerd, `πŸ“‹ BBS` drawer-item toegevoegd. + +### Storage +``` +~/.meshcore-gui/bbs/bbs_config.json β€” board configuratie +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite berichtenopslag +``` + +--- + +## [1.13.5] - 2026-03-14 β€” Route back-button and map popup flicker fixes + +### Fixed +- πŸ›  **Route page back-button navigated to main menu regardless of origin** β€” the two fixed navigation buttons (`/` and `/archive`) are replaced by a single `arrow_back` button that calls `window.history.back()`, so the user is always returned to the screen that opened the route page. +- πŸ›  **Map marker popup flickered on every 500 ms update tick** β€” the periodic `applyContacts` / `applyDevice` calls in `leaflet_map_panel.js` invoked `setIcon()` and `setPopupContent()` on all existing markers unconditionally. `setIcon()` rebuilds the marker DOM element; when a popup was open this caused the popup anchor to detach and reattach, producing visible flickering. Both functions now check `marker.isPopupOpen()` and skip icon/content updates while the popup is visible. +- πŸ›  **Map marker popup appeared with a flicker/flash on first click (main map and route map)** β€” Leaflet's default `fadeAnimation: true` caused popups to fade in from opacity 0, which on the Raspberry Pi rendered as a visible flicker. Both `L.map()` initialisations (`ensureMap` and `MeshCoreRouteMapBoot`) now set `fadeAnimation: false` and `markerZoomAnimation: false` so popups appear immediately without animation artefacts. + +### Changed +- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Replaced two fixed-destination header buttons with a single `arrow_back` button using `window.history.back()`. +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” `applyDevice` and `applyContacts` guard `setIcon` / `setPopupContent` behind `isPopupOpen()`. Both `L.map()` calls add `fadeAnimation: false, markerZoomAnimation: false`. +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.5`. ### Impact -- Message-driven views now use one consistent icon language across map, contacts, messages, archive and route detail -- Existing map runtime and panel behavior remain unchanged -- No breaking changes outside icon rendering +- Back navigation from the route page now always returns to the correct origin screen. +- Open marker popups are stable during map update ticks; content refreshes on next tick after the popup is closed. +- Popup opening is instant on both maps; no animation artefacts on low-power hardware. +--- +## [1.13.4] - 2026-03-12 β€” Room Server message classification fix + +### Fixed +- πŸ›  **Incoming room messages from other participants could be misclassified as normal DMs** β€” `CONTACT_MSG_RECV` room detection now keys on `txt_type == 2` instead of requiring `signature`. +- πŸ›  **Incoming room traffic could be attached to the wrong key** β€” room message handling now prefers `room_pubkey` / receiver-style payload keys before falling back to `pubkey_prefix`. +- πŸ›  **Room login UI could stay out of sync with the actual server-confirmed state** β€” `LOGIN_SUCCESS` now updates `room_login_states` and refreshes room history using the resolved room key. +- πŸ›  **Room Server panel showed hex codes instead of sender names** β€” when a contact was not yet known at the time a room message was archived, `msg.sender` was stored as a raw hex prefix. The panel now performs a live lookup against the current contacts snapshot on every render tick, so names are shown as soon as the contact is known. + +### Changed +- πŸ”„ `meshcore_gui/ble/events.py` β€” Broadened room payload parsing and added payload-key debug logging for incoming room traffic. +- πŸ”„ `meshcore_gui/ble/worker.py` β€” `LOGIN_SUCCESS` handler now updates per-room login state and refreshes cached room history. +- πŸ”„ `meshcore_gui/config.py` β€” Version kept at `1.13.4`. + +### Impact +- Keeps the existing Room Server panel logic intact. +- Fix is limited to room event classification and room login confirmation handling. +- No intended behavioural change for ordinary DMs or channel messages. + +--- +--- +## [1.13.3] - 2026-03-12 β€” Active Panel Timer Gating + +### Changed +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” The 500 ms dashboard timer now keeps only lightweight global state updates running continuously (status label, channel filters/options, drawer submenu consistency). Expensive panel refreshes are now gated to the currently active panel only +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” Added immediate active-panel refresh on panel switch so newly opened panels populate at once instead of waiting for the next timer tick +- πŸ”„ `meshcore_gui/gui/panels/map_panel.py` β€” Removed eager hidden `ensure_map` bootstrap from `render()`; the browser map now starts only when real snapshot work exists or when a live map already exists +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” Theme-only calls without snapshot work no longer start hidden host retry processing before a real map exists +- πŸ”„ `meshcore_gui/config.py` β€” Version bumped to `1.13.3` + +### Fixed +- πŸ›  **Hidden panels still refreshed every 500 ms** β€” Device, actions, contacts, messages, rooms and RX log are no longer needlessly updated while another panel is active +- πŸ›  **Map bootstrap activity while panel is not visible** β€” Removed one source of `MeshCoreLeafletBoot timeout waiting for visible map host` caused by eager hidden startup traffic +- πŸ›  **Slow navigation over VPN** β€” Reduced unnecessary dashboard-side UI churn by limiting timer-driven work to the active panel + +### Impact +- Faster panel switching because the selected panel gets one direct refresh immediately +- Lower background UI/update load on dashboard level, especially when the map panel is not active +- Smaller chance of Leaflet hidden-host retries and related console noise outside active map usage +- No intended functional regression for route maps or visible panel behaviour + +--- +## [1.13.2] - 2026-03-11 β€” Map Display Bugfix + +### Fixed +- πŸ›  **MAP panel blank when contacts list is empty at startup** β€” dashboard update loop + had two separate conditional map-update blocks that both silently stopped firing after + tick 1 when `data['contacts']` was empty. Map panel received no further snapshots and + remained blank indefinitely. +- πŸ›  **Leaflet map initialized on hidden (zero-size) container** β€” `processPending` in + the browser runtime called `L.map()` on the host element while it was still + `display:none` (Vue v-show, panel not yet visible). This produced a broken 0Γ—0 map + that never recovered because `ensureMap` returned the cached broken state on all + subsequent calls. Fixed by adding a `clientWidth/clientHeight` guard in `ensureMap`: + initialization is deferred until the host has real dimensions. +- πŸ›  **Route map container had no height** β€” `route_page.py` used the Tailwind class + `h-96` for the Leaflet host `
`. NiceGUI/Quasar does not include Tailwind CSS, + so `h-96` had no effect and the container rendered at height 0. Leaflet initialized + on a zero-height element and produced a blank map. +- πŸ›  **Route map not rendered when no node has GPS coordinates** β€” `_render_map` + returned early before creating the Leaflet container when `payload['nodes']` was + empty. Fixed: container is always created; a notice label is shown instead. + +### Changed +- πŸ”„ `meshcore_gui/static/leaflet_map_panel.js` β€” Added size guard in `ensureMap`: + returns `null` when host has `clientWidth === 0 && clientHeight === 0` and no map + state exists yet. `processPending` retries on the next tick once the panel is visible. +- πŸ”„ `meshcore_gui/gui/dashboard.py` β€” Consolidated two conditional map-update blocks + into a single unconditional update while the MAP panel is active. Added `h-96` to the + DOMCA CSS height overrides for consistency with the route page map container. +- πŸ”„ `meshcore_gui/gui/route_page.py` β€” Replaced `h-96` Tailwind class on the route + map host `
` with an explicit inline `style` (height: 24rem). Removed early + `return` guard so the Leaflet container is always created. + +### Impact +- MAP panel now renders reliably on first open regardless of contact/GPS availability +- Route map now always shows with correct height even when route nodes have no GPS +- No breaking changes outside the three files listed above + +--- ## [1.13.0] - 2026-03-09 β€” Leaflet Map Runtime Stabilization ### Added -- βœ… `meshcore_gui/static/leaflet_map_panel.js` β€” Dedicated browser-side Leaflet runtime responsible for map lifecycle, marker registry and theme handling independent from NiceGUI redraw cycles -- βœ… `meshcore_gui/static/leaflet_map_panel.css` β€” Styling for browser-side node markers and map container +- βœ… `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 @@ -60,6 +190,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and [Semantic Ver ### 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 --- @@ -787,20 +918,3 @@ overwriting all historical data with only the new buffered messages. - 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 - -## [1.13.0] - 2026-03-09 - -### Added -- Leaflet marker clustering using Leaflet.markercluster for contact nodes. -- Browser-side cluster rendering with the device marker kept outside the cluster layer. -- Cluster performance tuning with `chunkedLoading: true`. -- Spiderfy support at max zoom for overlapping markers. - -### Fixed -- Wrong asset load order causing `L is not defined` in MarkerClusterGroup. -- Cluster initialization failure caused by missing `maxZoom` on map startup. -- Retry cascade causing `Map container is already initialized`. - -### Changed -- Map lifecycle is browser-owned: NiceGUI hosts the container, Leaflet owns map state. -- Contact markers are updated incrementally in the existing cluster layer. diff --git a/README.md b/README.md index aa47c90..8e72d38 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-orange.svg) ![Transport](https://img.shields.io/badge/Transport-USB%20Serial%20%7C%20BLE-blueviolet.svg) ![Bridge](https://img.shields.io/badge/Bridge-Cross--Frequency%20LoRa%20↔%20LoRa-ff6600.svg) +image + A graphical user interface for MeshCore mesh network devices with native USB serial and Bluetooth Low Energy (BLE) support, for on your desktop or as a headless service on your local network. @@ -131,6 +133,8 @@ Under the hood it uses `meshcore` as the protocol layer, `meshcoredecoder` for r a_Screenshots Screenshot from 2026-02-18 09-27-59 Screenshot from 2026-02-18 09-28-27 +image + ## 4. Requirements @@ -1180,11 +1184,108 @@ meshcore-gui/ └── README.md ``` -## 15. Roadmap +## 15. BBS β€” Bulletin Board System + +MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. + +### Access model + +The operator links one or more channels to the BBS. Anyone who sends a message on a configured BBS channel is automatically added to the whitelist. After that, they can send commands via **Direct Message** to the BBS node β€” the channel itself stays clean. + +``` +First contact: !bbs help on the configured channel + β†’ node sees the public key β†’ whitelists it +After that: !p U need assistance as DM to the node + β†’ processed, reply sent back via DM +``` + +Anyone who has never sent a message on a configured channel is not on the whitelist and is silently ignored. + +### Settings + +Open via the gear icon (βš™) in the BBS panel, or navigate to `/bbs-settings`. + +``` +BBS Settings +────────────────────────────────────────── +Channels: β˜‘ [1] NoodNet Zwolle + β˜‘ [2] NoodNet Dalfsen + ☐ [3] NoodNet OV +Categories: URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL +Retain: 48 hours +[Save] + +β–Ά Advanced + Regions (comma-separated) + Allowed keys (empty = auto-learned from channel activity) +``` + +- **Channels** β€” check all channels whose participants should have access to the BBS. Multiple channels can be selected. +- **Categories** β€” comma-separated list of valid category tags. +- **Retain** β€” message retention in hours (default 48). +- **Advanced β†’ Regions** β€” optional region tags for geographic filtering. +- **Advanced β†’ Allowed keys** β€” manual whitelist override; leave empty to rely on auto-learned keys only. + +### Command syntax + +#### Short syntax + +| Command | Description | +|---|---| +| `!p ` | Post a message | +| `!p ` | Post with region | +| `!r` | Read 5 most recent messages (all categories) | +| `!r ` | Read filtered by category | +| `!r ` | Read filtered by region and category | + +Category abbreviations are computed automatically as the shortest unique prefix within the configured list. Example with `URGENT, MEDICAL, LOGISTICS, STATUS, GENERAL`: + +``` +U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +If two categories share the same leading letters (e.g. `MEDICAL` and `MISSING`), longer prefixes are calculated automatically: `ME` and `MI`. The `!r` (without arguments) and `!bbs help` replies always include the current abbreviation table. + +#### Full syntax + +| Command | Description | +|---|---| +| `!bbs help` | Show commands and abbreviation table | +| `!bbs post ` | Post a message | +| `!bbs post ` | Post with region | +| `!bbs read` | Read 5 most recent messages | +| `!bbs read ` | Read filtered by category | +| `!bbs read ` | Read filtered by region and category | + +#### Example help reply + +``` +BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] | U=URGENT M=MEDICAL L=LOGISTICS S=STATUS G=GENERAL +``` + +### Error handling + +| Situation | Reply | +|---|---| +| Unknown category | Lists valid categories and abbreviations | +| Ambiguous abbreviation | Lists all matching categories | +| Sender not on whitelist | Silent drop β€” no reply | + +### Storage + +``` +~/.meshcore-gui/bbs/bbs_messages.db β€” SQLite message store (WAL mode) +~/.meshcore-gui/bbs/bbs_config.json β€” Board configuration +``` + +--- + +## 16. Roadmap This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include: - [x] **Cross-frequency bridge** β€” standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge)) +- [x] **BBS β€” Bulletin Board System** β€” offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system)) - [ ] **Observer mode** β€” passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging - [ ] **Room Server administration** β€” authenticate as admin to manage Room Server settings and users directly from the GUI - [ ] **Repeater management** β€” connect to repeater nodes to view status and adjust configuration diff --git a/docs/CHANGELOG.md b/meshcore_gui/CHANGELOG.md similarity index 83% rename from docs/CHANGELOG.md rename to meshcore_gui/CHANGELOG.md index 8b154a3..6df97ad 100644 --- a/docs/CHANGELOG.md +++ b/meshcore_gui/CHANGELOG.md @@ -1,3 +1,5 @@ + + # CHANGELOG -Route table data (path hashes, resolved repeater names and channel names) is captured at receive time and stored in the archive. This means route tables (names and IDs) remain correct even when contacts are renamed, removed or offline. Sender identity is resolved via pubkey lookup with an automatic name-based fallback when the pubkey lookup fails. Map visualization still depends on live contact GPS data β€” see [12. Known Limitations](#12-known-limitations). +Route table data (path hashes, resolved repeater names and channel names) is captured at receive time and stored in the archive. This means route tables (names and IDs) remain correct even when contacts are renamed, removed or offline. Sender identity is resolved via pubkey lookup with an automatic name-based fallback when the pubkey lookup fails. Map visualization still depends on live contact GPS data β€” see [13. Known Limitations](#13-known-limitations). ### 9.7. Room Server @@ -923,268 +923,7 @@ To uninstall: `sudo bash install_bridge.sh --uninstall` For full documentation including architecture details, troubleshooting and assumptions, see [BRIDGE.md](BRIDGE.md). -## 12. Known Limitations - -1. **Channel discovery timing** β€” Dynamic channel discovery probes the device at startup; on very slow links (especially BLE), some channels may be missed on first attempt. Channels are retried in the background and cached for subsequent startups when `CHANNEL_CACHE_ENABLED = True` -2. **Initial load time** β€” GUI waits for device data before the first render is complete (mitigated by cache: if cached data exists, the GUI populates instantly) -3. **Archive route map visualization** β€” Route table names and IDs are now stored at receive time and display correctly regardless of current contacts. However, the route *map* still depends on GPS coordinates from contacts currently in memory; archived messages without recent contact data may show incomplete map markers - -4. **Room Server message latency** β€” Room Server messages travel over LoRa RF and arrive asynchronously (10–75 seconds per message). With many logged-in clients, receiving all historical messages can take 10+ minutes due to the round-robin push protocol -5. **BLE Linux only** β€” BLE mode requires Linux with BlueZ and D-Bus. macOS and Windows are not supported for BLE connections because the PIN agent relies on the D-Bus system bus -6. **BlueZ 5.66+ instability** β€” Recent BlueZ versions (shipped with Ubuntu 24.04, Debian Bookworm, Raspberry Pi OS Bookworm) can cause BLE connection instability, pairing failures and unexpected disconnects. USB serial is not affected and is recommended as the most reliable transport - -## 13. Troubleshooting - -### 14.1. Linux - -For Linux troubleshooting, start by checking device permissions and that the correct device argument is used. - -#### 13.1.1. Serial Quick Fixes - -##### GUI remains empty / serial connection fails - -1. Check the service logs: - ```bash - journalctl -u meshcore-gui -n 50 --no-pager - ``` -2. Confirm the serial device exists and is readable: - ```bash - ls -l /dev/serial/by-id - ``` -3. Ensure your user has serial permissions (commonly `dialout` on Linux): - ```bash - sudo usermod -a -G dialout $USER - # Log out and back in - ``` -4. Kill any existing GUI instance and free the port: - ```bash - pkill -9 -f meshcore_gui - sleep 3 - ``` -5. Restart the GUI: - ```bash - python meshcore_gui.py /dev/ttyUSB0 - ``` - -#### 13.1.2. BLE Quick Fixes - -##### GUI remains empty / BLE connection fails - -1. Verify Bluetooth is running: - ```bash - sudo systemctl status bluetooth - ``` - If not running: `sudo systemctl start bluetooth` - -2. Check that the device is visible: - ```bash - bluetoothctl scan on - ``` - Look for your device's MAC address. Press `Ctrl+C` to stop scanning. - -3. Verify the D-Bus policy is installed: - ```bash - ls -l /etc/dbus-1/system.d/meshcore-ble.conf - ``` - If missing, see [5.1.1. D-Bus Policy for BLE](#511-d-bus-policy-for-ble-linux-only). - -4. Remove stale BLE bond (if the device was previously paired): - ```bash - bluetoothctl remove AA:BB:CC:DD:EE:FF - ``` - -5. Kill any existing GUI instance: - ```bash - pkill -9 -f meshcore_gui - sleep 3 - ``` - -6. Restart the GUI: - ```bash - python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF --debug-on - ``` - Check the debug output for D-Bus or pairing errors. - -##### BLE PIN agent errors - -If you see `org.freedesktop.DBus.Error.AccessDenied` in the logs, the D-Bus policy is missing or incorrect. Reinstall it per [5.1.1](#511-d-bus-policy-for-ble-linux-only) and reload D-Bus: - -```bash -sudo systemctl reload dbus -``` - -##### BLE reconnect issues - -If the connection drops and does not recover, the BLE bond may be stale. The application includes automatic bond cleanup and reconnect logic, but in some cases a manual bond removal is needed: - -```bash -bluetoothctl remove AA:BB:CC:DD:EE:FF -sudo systemctl restart meshcore-gui -``` - -##### BlueZ 5.66+ driver instability - -If BLE connections are consistently unreliable (frequent disconnects, pairing loops, authentication errors), the issue is likely caused by changes in BlueZ 5.66+. Check your BlueZ version: - -```bash -bluetoothctl --version -``` - -If the version is 5.66 or higher, you have several options: - -1. **Switch to USB serial** (recommended) β€” the most reliable workaround. Connect your device via USB and use serial mode instead: - ```bash - python meshcore_gui.py /dev/ttyACM0 - ``` - -2. **Downgrade BlueZ** β€” on Debian/Ubuntu, you can pin an older version: - ```bash - sudo apt install bluez=5.65-0ubuntu1 - sudo apt-mark hold bluez - ``` - Note: exact package versions vary by distribution. - -3. **Disable LE Privacy and Secure Connections** β€” in some cases, adding these options to `/etc/bluetooth/main.conf` can help: - ```ini - [General] - Privacy = off - - [LE] - MinConnectionInterval=6 - MaxConnectionInterval=9 - ConnectionLatency=0 - ``` - Restart Bluetooth after editing: `sudo systemctl restart bluetooth` - -### 14.2. macOS - -- Ensure the device shows up under `/dev/tty.usb*`, `/dev/tty.usbserial*`, or `/dev/tty.usbmodem*` -- Close any other app that might be using the serial port - -### 13.3. Windows - -- Confirm the COM port in Device Manager β†’ Ports (COM & LPT) -- Close any other app that might be using the COM port - -### 13.4. All Platforms - -#### 13.4.1. Device Not Found - -**Serial:** Make sure the MeshCore device is powered on, running Serial Companion firmware, and the correct serial port is selected. - -**BLE:** Ensure the device is powered on and discoverable (`bluetoothctl scan on`). Check that the MAC address is correct and that the BLE PIN matches (default: `123456`). On Linux, verify D-Bus permissions β€” see `docs/ble/BLE_ARCHITECTURE.md` for details. - -#### 13.4.2. Messages Not Arriving - -- Check if your channels are correctly configured -- Use `meshcli` to verify that messages are arriving - -#### 13.4.3. Clearing the Cache - -If cached data causes issues (e.g. stale contacts), delete the cache file: - -```bash -rm ~/.meshcore-gui/cache/*.json -``` - -The cache will be recreated on the next successful serial connection. - -## 14. Development - -### 14.1. Debug Mode - -Enable via command line flag: - -```bash -python meshcore_gui.py /dev/ttyUSB0 --debug-on -``` - -Or set `DEBUG = True` in `meshcore_gui/config.py`. - -Debug output is written to both stdout and a per-device rotating log file at `~/.meshcore-gui/logs/
_meshcore_gui.log` (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`). - -### 14.2. Project Structure - - - -``` -meshcore-gui/ -β”œβ”€β”€ meshcore_gui.py # Entry point (auto-detects Serial or BLE) -β”œβ”€β”€ install_ble_stable.sh # BLE installer (systemd service for BLE connections) -β”œβ”€β”€ install_serial.sh # Serial installer (systemd service for serial connections) -β”œβ”€β”€ meshcore_gui/ # Application package -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ __main__.py # Alternative entry: python -m meshcore_gui -β”‚ β”œβ”€β”€ config.py # OPERATOR_CALLSIGN, LANDING_SVG_PATH, DEBUG flag, channel discovery settings (MAX_CHANNELS, CHANNEL_CACHE_ENABLED), SERIAL_* defaults, BLE_PIN, TRANSPORT mode, RECONNECT_* settings, refresh interval, retention settings, BOT_DEVICE_NAME, per-device log file naming -β”‚ β”œβ”€β”€ ble/ # Connection layer (serial + BLE transport) -β”‚ β”‚ β”œβ”€β”€ __init__.py -β”‚ β”‚ β”œβ”€β”€ worker.py # _BaseWorker + SerialWorker + BLEWorker + create_worker() factory; thread lifecycle, cache-first startup, disconnect detection, auto-reconnect, background key retry -β”‚ β”‚ β”œβ”€β”€ ble_agent.py # BlueZ D-Bus PIN agent for BLE pairing (Linux only, lazy-loaded) -β”‚ β”‚ β”œβ”€β”€ ble_reconnect.py # BLE bond cleanup and reconnect loop via D-Bus (lazy-loaded) -β”‚ β”‚ β”œβ”€β”€ commands.py # Command execution (send, refresh, advert) -β”‚ β”‚ β”œβ”€β”€ events.py # Event callbacks (messages, RX log) with path hash caching and name resolution at receive time -β”‚ β”‚ └── packet_decoder.py # Raw LoRa packet decoding via meshcoredecoder -β”‚ β”œβ”€β”€ core/ # Domain models and shared state -β”‚ β”‚ β”œβ”€β”€ __init__.py -β”‚ β”‚ β”œβ”€β”€ models.py # Dataclasses: Message, Contact, DeviceInfo, RxLogEntry, RouteNode -β”‚ β”‚ β”œβ”€β”€ shared_data.py # Thread-safe shared data store -β”‚ β”‚ └── protocols.py # Protocol interfaces (ISP/DIP) -β”‚ β”œβ”€β”€ gui/ # NiceGUI web interface -β”‚ β”‚ β”œβ”€β”€ __init__.py -β”‚ β”‚ β”œβ”€β”€ constants.py # UI display constants -β”‚ β”‚ β”œβ”€β”€ dashboard.py # Main dashboard page orchestrator, loads landing SVG from config.LANDING_SVG_PATH -β”‚ β”‚ β”œβ”€β”€ route_page.py # Message route visualization page -β”‚ β”‚ β”œβ”€β”€ archive_page.py # Message archive viewer with filters and pagination -β”‚ β”‚ └── panels/ # Modular UI panels -β”‚ β”‚ β”œβ”€β”€ __init__.py -β”‚ β”‚ β”œβ”€β”€ device_panel.py # Device info display -β”‚ β”‚ β”œβ”€β”€ contacts_panel.py # Contacts list with DM, pin/unpin, bulk delete, auto-add toggle -β”‚ β”‚ β”œβ”€β”€ map_panel.py # Leaflet map -β”‚ β”‚ β”œβ”€β”€ input_panel.py # Message input and channel select -β”‚ β”‚ β”œβ”€β”€ filter_panel.py # Channel filters and bot toggle -β”‚ β”‚ β”œβ”€β”€ messages_panel.py # Filtered message display with archive button -β”‚ β”‚ β”œβ”€β”€ actions_panel.py # Refresh and advert buttons -β”‚ β”‚ β”œβ”€β”€ room_server_panel.py # Per-room-server card with login/logout and messages -β”‚ β”‚ └── rxlog_panel.py # RX log table -β”‚ └── services/ # Business logic -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ bot.py # Keyword-triggered auto-reply bot -β”‚ β”œβ”€β”€ cache.py # Local JSON cache per device -β”‚ β”œβ”€β”€ contact_cleaner.py # Bulk-delete logic for unpinned contacts -β”‚ β”œβ”€β”€ dedup.py # Message deduplication -β”‚ β”œβ”€β”€ message_archive.py # Persistent message and RX log archive -β”‚ β”œβ”€β”€ pin_store.py # Persistent pin state storage per device -β”‚ β”œβ”€β”€ room_password_store.py # Persistent Room Server password storage per device -β”‚ └── route_builder.py # Route data construction -β”œβ”€β”€ docs/ -β”‚ β”œβ”€β”€ TROUBLESHOOTING.md # BLE troubleshooting guide (detailed) -β”‚ β”œβ”€β”€ MeshCore_GUI_Design.docx # Design document -β”‚ β”œβ”€β”€ ble_capture_workflow_t_1000_e_explanation.md -β”‚ └── ble_capture_workflow_t_1000_e_uitleg.md -β”œβ”€β”€ meshcore_bridge.py # Bridge entry point -β”œβ”€β”€ meshcore_bridge/ # Bridge daemon package -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ __main__.py # CLI, dual-worker setup, NiceGUI server -β”‚ β”œβ”€β”€ config.py # YAML config loading (BridgeConfig dataclass) -β”‚ β”œβ”€β”€ bridge_engine.py # Core bridge logic: poll, forward, dedup, loop prevention -β”‚ └── gui/ # Bridge dashboard (DOMCA themed) -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ dashboard.py # Bridge status dashboard page -β”‚ └── panels/ -β”‚ β”œβ”€β”€ __init__.py -β”‚ β”œβ”€β”€ status_panel.py # Device A/B connection status + statistics -β”‚ └── log_panel.py # Forwarded message log -β”œβ”€β”€ bridge_config.yaml # Bridge configuration template (YAML) -β”œβ”€β”€ install_bridge.sh # Bridge systemd service installer -β”œβ”€β”€ BRIDGE.md # Bridge documentation -β”œβ”€β”€ .gitattributes -β”œβ”€β”€ .gitignore -β”œβ”€β”€ LICENSE -β”œβ”€β”€ CHANGELOG.md -└── README.md -``` - -## 15. BBS β€” Bulletin Board System +## 12. BBS β€” Bulletin Board System MeshCore GUI includes an offline BBS that lets mesh nodes exchange structured messages by category, with optional region tagging. @@ -1300,31 +1039,292 @@ BBS [NoodNet Zwolle, NoodNet Dalfsen] | !p [cat] [text] | !r [cat] [1-5] | !s [c --- +## 13. Known Limitations + +1. **Channel discovery timing** β€” Dynamic channel discovery probes the device at startup; on very slow links (especially BLE), some channels may be missed on first attempt. Channels are retried in the background and cached for subsequent startups when `CHANNEL_CACHE_ENABLED = True` +2. **Initial load time** β€” GUI waits for device data before the first render is complete (mitigated by cache: if cached data exists, the GUI populates instantly) +3. **Archive route map visualization** β€” Route table names and IDs are now stored at receive time and display correctly regardless of current contacts. However, the route *map* still depends on GPS coordinates from contacts currently in memory; archived messages without recent contact data may show incomplete map markers + +4. **Room Server message latency** β€” Room Server messages travel over LoRa RF and arrive asynchronously (10–75 seconds per message). With many logged-in clients, receiving all historical messages can take 10+ minutes due to the round-robin push protocol +5. **BLE Linux only** β€” BLE mode requires Linux with BlueZ and D-Bus. macOS and Windows are not supported for BLE connections because the PIN agent relies on the D-Bus system bus +6. **BlueZ 5.66+ instability** β€” Recent BlueZ versions (shipped with Ubuntu 24.04, Debian Bookworm, Raspberry Pi OS Bookworm) can cause BLE connection instability, pairing failures and unexpected disconnects. USB serial is not affected and is recommended as the most reliable transport + +## 14. Troubleshooting + +### 14.1. Linux + +For Linux troubleshooting, start by checking device permissions and that the correct device argument is used. + +#### 14.1.1. Serial Quick Fixes + +##### GUI remains empty / serial connection fails + +1. Check the service logs: + ```bash + journalctl -u meshcore-gui -n 50 --no-pager + ``` +2. Confirm the serial device exists and is readable: + ```bash + ls -l /dev/serial/by-id + ``` +3. Ensure your user has serial permissions (commonly `dialout` on Linux): + ```bash + sudo usermod -a -G dialout $USER + # Log out and back in + ``` +4. Kill any existing GUI instance and free the port: + ```bash + pkill -9 -f meshcore_gui + sleep 3 + ``` +5. Restart the GUI: + ```bash + python meshcore_gui.py /dev/ttyUSB0 + ``` + +#### 14.1.2. BLE Quick Fixes + +##### GUI remains empty / BLE connection fails + +1. Verify Bluetooth is running: + ```bash + sudo systemctl status bluetooth + ``` + If not running: `sudo systemctl start bluetooth` + +2. Check that the device is visible: + ```bash + bluetoothctl scan on + ``` + Look for your device's MAC address. Press `Ctrl+C` to stop scanning. + +3. Verify the D-Bus policy is installed: + ```bash + ls -l /etc/dbus-1/system.d/meshcore-ble.conf + ``` + If missing, see [5.1.1. D-Bus Policy for BLE](#511-d-bus-policy-for-ble-linux-only). + +4. Remove stale BLE bond (if the device was previously paired): + ```bash + bluetoothctl remove AA:BB:CC:DD:EE:FF + ``` + +5. Kill any existing GUI instance: + ```bash + pkill -9 -f meshcore_gui + sleep 3 + ``` + +6. Restart the GUI: + ```bash + python meshcore_gui.py literal:AA:BB:CC:DD:EE:FF --debug-on + ``` + Check the debug output for D-Bus or pairing errors. + +##### BLE PIN agent errors + +If you see `org.freedesktop.DBus.Error.AccessDenied` in the logs, the D-Bus policy is missing or incorrect. Reinstall it per [5.1.1](#511-d-bus-policy-for-ble-linux-only) and reload D-Bus: + +```bash +sudo systemctl reload dbus +``` + +##### BLE reconnect issues + +If the connection drops and does not recover, the BLE bond may be stale. The application includes automatic bond cleanup and reconnect logic, but in some cases a manual bond removal is needed: + +```bash +bluetoothctl remove AA:BB:CC:DD:EE:FF +sudo systemctl restart meshcore-gui +``` + +##### BlueZ 5.66+ driver instability + +If BLE connections are consistently unreliable (frequent disconnects, pairing loops, authentication errors), the issue is likely caused by changes in BlueZ 5.66+. Check your BlueZ version: + +```bash +bluetoothctl --version +``` + +If the version is 5.66 or higher, you have several options: + +1. **Switch to USB serial** (recommended) β€” the most reliable workaround. Connect your device via USB and use serial mode instead: + ```bash + python meshcore_gui.py /dev/ttyACM0 + ``` + +2. **Downgrade BlueZ** β€” on Debian/Ubuntu, you can pin an older version: + ```bash + sudo apt install bluez=5.65-0ubuntu1 + sudo apt-mark hold bluez + ``` + Note: exact package versions vary by distribution. + +3. **Disable LE Privacy and Secure Connections** β€” in some cases, adding these options to `/etc/bluetooth/main.conf` can help: + ```ini + [General] + Privacy = off + + [LE] + MinConnectionInterval=6 + MaxConnectionInterval=9 + ConnectionLatency=0 + ``` + Restart Bluetooth after editing: `sudo systemctl restart bluetooth` + +### 14.2. macOS + +- Ensure the device shows up under `/dev/tty.usb*`, `/dev/tty.usbserial*`, or `/dev/tty.usbmodem*` +- Close any other app that might be using the serial port + +### 14.3. Windows + +- Confirm the COM port in Device Manager β†’ Ports (COM & LPT) +- Close any other app that might be using the COM port + +### 14.4. All Platforms + +#### 14.4.1. Device Not Found + +**Serial:** Make sure the MeshCore device is powered on, running Serial Companion firmware, and the correct serial port is selected. + +**BLE:** Ensure the device is powered on and discoverable (`bluetoothctl scan on`). Check that the MAC address is correct and that the BLE PIN matches (default: `123456`). On Linux, verify D-Bus permissions β€” see `docs/ble/BLE_ARCHITECTURE.md` for details. + +#### 14.4.2. Messages Not Arriving + +- Check if your channels are correctly configured +- Use `meshcli` to verify that messages are arriving + +#### 14.4.3. Clearing the Cache + +If cached data causes issues (e.g. stale contacts), delete the cache file: + +```bash +rm ~/.meshcore-gui/cache/*.json +``` + +The cache will be recreated on the next successful serial connection. + +## 15. Development + +### 15.1. Debug Mode + +Enable via command line flag: + +```bash +python meshcore_gui.py /dev/ttyUSB0 --debug-on +``` + +Or set `DEBUG = True` in `meshcore_gui/config.py`. + +Debug output is written to both stdout and a per-device rotating log file at `~/.meshcore-gui/logs/
_meshcore_gui.log` (e.g. `F0_9E_9E_75_A3_01_meshcore_gui.log`). + +### 15.2. Project Structure + + + +``` +meshcore-gui/ +β”œβ”€β”€ meshcore_gui.py # Entry point (auto-detects Serial or BLE) +β”œβ”€β”€ install_ble_stable.sh # BLE installer (systemd service for BLE connections) +β”œβ”€β”€ install_serial.sh # Serial installer (systemd service for serial connections) +β”œβ”€β”€ meshcore_gui/ # Application package +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ __main__.py # Alternative entry: python -m meshcore_gui +β”‚ β”œβ”€β”€ config.py # OPERATOR_CALLSIGN, LANDING_SVG_PATH, DEBUG flag, channel discovery settings (MAX_CHANNELS, CHANNEL_CACHE_ENABLED), SERIAL_* defaults, BLE_PIN, TRANSPORT mode, RECONNECT_* settings, refresh interval, retention settings, BOT_DEVICE_NAME, per-device log file naming +β”‚ β”œβ”€β”€ ble/ # Connection layer (serial + BLE transport) +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ worker.py # _BaseWorker + SerialWorker + BLEWorker + create_worker() factory; thread lifecycle, cache-first startup, disconnect detection, auto-reconnect, background key retry +β”‚ β”‚ β”œβ”€β”€ ble_agent.py # BlueZ D-Bus PIN agent for BLE pairing (Linux only, lazy-loaded) +β”‚ β”‚ β”œβ”€β”€ ble_reconnect.py # BLE bond cleanup and reconnect loop via D-Bus (lazy-loaded) +β”‚ β”‚ β”œβ”€β”€ commands.py # Command execution (send, refresh, advert) +β”‚ β”‚ β”œβ”€β”€ events.py # Event callbacks (messages, RX log) with path hash caching and name resolution at receive time +β”‚ β”‚ └── packet_decoder.py # Raw LoRa packet decoding via meshcoredecoder +β”‚ β”œβ”€β”€ core/ # Domain models and shared state +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ models.py # Dataclasses: Message, Contact, DeviceInfo, RxLogEntry, RouteNode +β”‚ β”‚ β”œβ”€β”€ shared_data.py # Thread-safe shared data store +β”‚ β”‚ └── protocols.py # Protocol interfaces (ISP/DIP) +β”‚ β”œβ”€β”€ gui/ # NiceGUI web interface +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ constants.py # UI display constants +β”‚ β”‚ β”œβ”€β”€ dashboard.py # Main dashboard page orchestrator, loads landing SVG from config.LANDING_SVG_PATH +β”‚ β”‚ β”œβ”€β”€ route_page.py # Message route visualization page +β”‚ β”‚ β”œβ”€β”€ archive_page.py # Message archive viewer with filters and pagination +β”‚ β”‚ └── panels/ # Modular UI panels +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ device_panel.py # Device info display +β”‚ β”‚ β”œβ”€β”€ contacts_panel.py # Contacts list with DM, pin/unpin, bulk delete, auto-add toggle +β”‚ β”‚ β”œβ”€β”€ map_panel.py # Leaflet map +β”‚ β”‚ β”œβ”€β”€ input_panel.py # Message input and channel select +β”‚ β”‚ β”œβ”€β”€ filter_panel.py # Channel filters and bot toggle +β”‚ β”‚ β”œβ”€β”€ messages_panel.py # Filtered message display with archive button +β”‚ β”‚ β”œβ”€β”€ actions_panel.py # Refresh and advert buttons +β”‚ β”‚ β”œβ”€β”€ room_server_panel.py # Per-room-server card with login/logout and messages +β”‚ β”‚ └── rxlog_panel.py # RX log table +β”‚ └── services/ # Business logic +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ bot.py # Keyword-triggered auto-reply bot +β”‚ β”œβ”€β”€ cache.py # Local JSON cache per device +β”‚ β”œβ”€β”€ contact_cleaner.py # Bulk-delete logic for unpinned contacts +β”‚ β”œβ”€β”€ dedup.py # Message deduplication +β”‚ β”œβ”€β”€ message_archive.py # Persistent message and RX log archive +β”‚ β”œβ”€β”€ pin_store.py # Persistent pin state storage per device +β”‚ β”œβ”€β”€ room_password_store.py # Persistent Room Server password storage per device +β”‚ └── route_builder.py # Route data construction +β”œβ”€β”€ docs/ +β”‚ β”œβ”€β”€ TROUBLESHOOTING.md # BLE troubleshooting guide (detailed) +β”‚ β”œβ”€β”€ MeshCore_GUI_Design.docx # Design document +β”‚ β”œβ”€β”€ ble_capture_workflow_t_1000_e_explanation.md +β”‚ └── ble_capture_workflow_t_1000_e_uitleg.md +β”œβ”€β”€ meshcore_bridge.py # Bridge entry point +β”œβ”€β”€ meshcore_bridge/ # Bridge daemon package +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ __main__.py # CLI, dual-worker setup, NiceGUI server +β”‚ β”œβ”€β”€ config.py # YAML config loading (BridgeConfig dataclass) +β”‚ β”œβ”€β”€ bridge_engine.py # Core bridge logic: poll, forward, dedup, loop prevention +β”‚ └── gui/ # Bridge dashboard (DOMCA themed) +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ dashboard.py # Bridge status dashboard page +β”‚ └── panels/ +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ status_panel.py # Device A/B connection status + statistics +β”‚ └── log_panel.py # Forwarded message log +β”œβ”€β”€ bridge_config.yaml # Bridge configuration template (YAML) +β”œβ”€β”€ install_bridge.sh # Bridge systemd service installer +β”œβ”€β”€ BRIDGE.md # Bridge documentation +β”œβ”€β”€ .gitattributes +β”œβ”€β”€ .gitignore +β”œβ”€β”€ LICENSE +β”œβ”€β”€ CHANGELOG.md +└── README.md +``` + ## 16. Roadmap This project is under active development. The most common features from the official MeshCore Companion apps are being implemented gradually. Planned additions include: - [x] **Cross-frequency bridge** β€” standalone daemon connecting two devices on different frequencies via configurable channel forwarding (see [11. Cross-Frequency Bridge](#11-cross-frequency-bridge)) -- [x] **BBS β€” Bulletin Board System** β€” offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [15. BBS](#15-bbs--bulletin-board-system)) +- [x] **BBS β€” Bulletin Board System** β€” offline message board with DM-based commands, category/region filtering and automatic abbreviations (see [12. BBS](#12-bbs--bulletin-board-system)) - [ ] **Observer mode** β€” passively monitor mesh traffic without transmitting, useful for network analysis, coverage mapping and long-term logging - [ ] **Room Server administration** β€” authenticate as admin to manage Room Server settings and users directly from the GUI - [ ] **Repeater management** β€” connect to repeater nodes to view status and adjust configuration Have a feature request or want to contribute? Open an issue or submit a pull request. -## 16. Disclaimer +## 17. Disclaimer This is an **independent community project** and is not affiliated with or endorsed by the official [MeshCore](https://github.com/meshcore-dev) development team. It is built on top of the open-source `meshcore` Python library. -## 17. License +## 18. License MIT License - see LICENSE file -## 18. Author +## 19. Author **PE1HVH** β€” [GitHub](https://github.com/pe1hvh) -## 19. Acknowledgments +## 20. Acknowledgments - [MeshCore](https://github.com/meshcore-dev) β€” Mesh networking firmware and protocol - [meshcore_py](https://github.com/meshcore-dev/meshcore_py) β€” Python bindings for MeshCore