feat: add offline BBS (Bulletin Board System) for emergency mesh communication(#V1.14.0)

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.
This commit is contained in:
pe1hvh
2026-03-14 17:09:08 +01:00
parent 7d61b7ddd2
commit 52d15c83a7
3 changed files with 291 additions and 205 deletions

View File

@@ -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(

View File

@@ -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()

View File

@@ -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()