diff --git a/src/meshcore_console/meshcore/settings.py b/src/meshcore_console/meshcore/settings.py index 6eddf2a..9825d9c 100644 --- a/src/meshcore_console/meshcore/settings.py +++ b/src/meshcore_console/meshcore/settings.py @@ -15,6 +15,7 @@ class MeshcoreSettings: autoconnect: bool = False # Automatically connect to radio on app startup suppress_service_dialog: bool = False # Don't prompt to stop conflicting services log_level: str = "INFO" # stderr log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + show_radio_error_toasts: bool = False # Show radio warnings as in-app toast alerts # Radio radio_preset: str = "meshcore-us" diff --git a/src/meshcore_console/ui_gtk/views/settings.py b/src/meshcore_console/ui_gtk/views/settings.py index bbaafcc..02ad495 100644 --- a/src/meshcore_console/ui_gtk/views/settings.py +++ b/src/meshcore_console/ui_gtk/views/settings.py @@ -316,10 +316,17 @@ def _build_logging_panel(self) -> Gtk.Box: self._log_level_combo.connect("changed", self._on_log_level_changed) grid.attach(self._log_level_combo, 1, 0, 1, 1) + # Radio warnings are retained in logs and the header status indicator; + # this controls only the intrusive bottom-of-window toast alerts. + grid.attach(self._grid_label("Radio Error Toasts"), 0, 1, 1, 1) + radio_toast_switch = self._grid_switch("show_radio_error_toasts") + radio_toast_switch.set_tooltip_text("Show radio warnings as popup alerts") + grid.attach(radio_toast_switch, 1, 1, 1, 1) + # Export logs button export_btn = Gtk.Button.new_with_label("Export Logs") export_btn.connect("clicked", self._on_export_logs) - grid.attach(export_btn, 0, 1, 2, 1) + grid.attach(export_btn, 0, 2, 2, 1) panel.append(grid) return panel @@ -526,6 +533,7 @@ def _load_from_service(self) -> None: # Logging self._log_level_combo.set_active_id(settings.log_level) + self._set_switch("show_radio_error_toasts", settings.show_radio_error_toasts) def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: current = self._service.get_settings() @@ -578,6 +586,7 @@ def _collect_settings(self, allow_partial: bool = False) -> MeshcoreSettings: # Logging out.log_level = self._log_level_combo.get_active_id() or "INFO" + out.show_radio_error_toasts = self._switches["show_radio_error_toasts"].get_active() return out diff --git a/src/meshcore_console/ui_gtk/windows/main_window.py b/src/meshcore_console/ui_gtk/windows/main_window.py index dac5ecb..52e8093 100644 --- a/src/meshcore_console/ui_gtk/windows/main_window.py +++ b/src/meshcore_console/ui_gtk/windows/main_window.py @@ -24,6 +24,9 @@ from meshcore_console.ui_gtk.state import UiEventStore from meshcore_console.ui_gtk.widgets import ConflictScreen, LoadingScreen, StatusPill +# How long the header badge shows "Radio Error" before reverting to connection state. +RADIO_ERROR_BADGE_MS = 5000 + class MainWindow(Adw.ApplicationWindow): def __init__(self, application: Adw.Application, service: MeshcoreService) -> None: @@ -37,6 +40,7 @@ def __init__(self, application: Adw.Application, service: MeshcoreService) -> No self._last_window_height = 0 self._surface_debug_wired = False self._shutting_down = False + self._radio_error_badge_id = 0 self.set_title("Meshcore Console") self._apply_window_geometry() self.add_css_class("app-root") @@ -210,6 +214,10 @@ def _on_close_request(self, _window: Adw.ApplicationWindow) -> bool: self._shutting_down = True logger.info("Shutdown requested, showing shutdown screen") + if self._radio_error_badge_id: + GLib.source_remove(self._radio_error_badge_id) + self._radio_error_badge_id = 0 + shutdown_screen = LoadingScreen(title="MeshCore Console", status="Shutting down...") self._content_stack.add_named(shutdown_screen, "shutdown") self._content_stack.set_visible_child_name("shutdown") @@ -652,14 +660,44 @@ def _on_service_dialog_response( settings.suppress_service_dialog = True self._service.update_settings(settings) - def _refresh_connection_state(self) -> None: - status = self._service.get_status() - status_text = "Connected" if status.connected else "Offline" - self._status_badge.set_text(status_text) - self._status_badge.set_state("ok" if status.connected else "offline") + def _set_status_badge(self, text: str, state: str) -> None: + self._status_badge.set_text(text) + self._status_badge.set_state(state) self._status_badge.update_property( [Gtk.AccessibleProperty.LABEL], - [f"Connection status: {status_text}"], + [f"Connection status: {text}"], + ) + + def _flash_radio_error(self) -> None: + """Show a transient error badge, then revert to the connection state. + + Radio errors can arrive continuously (e.g. CRC noise), so the badge must + not latch — otherwise it permanently hides Connected/Offline. + """ + self._set_status_badge("Radio Error", "warn") + if self._radio_error_badge_id: + GLib.source_remove(self._radio_error_badge_id) + self._radio_error_badge_id = GLib.timeout_add( + RADIO_ERROR_BADGE_MS, self._clear_radio_error_badge + ) + + def _clear_radio_error_badge(self) -> bool: + self._radio_error_badge_id = 0 + status = self._service.get_status() + self._set_status_badge( + "Connected" if status.connected else "Offline", + "ok" if status.connected else "offline", + ) + return GLib.SOURCE_REMOVE + + def _refresh_connection_state(self) -> None: + if self._radio_error_badge_id: + GLib.source_remove(self._radio_error_badge_id) + self._radio_error_badge_id = 0 + status = self._service.get_status() + self._set_status_badge( + "Connected" if status.connected else "Offline", + "ok" if status.connected else "offline", ) self._connect_button.set_label("Disconnect" if status.connected else "Connect") self._advert_btn.set_sensitive(status.connected) @@ -685,9 +723,9 @@ def _on_events_available(self, _store: object) -> None: self._refresh_connection_state() elif etype == "radio_error": msg = (event.get("data") or {}).get("message", "Unknown radio error") - self._toast_overlay.add_toast(Adw.Toast.new(msg)) - self._status_badge.set_text("Radio Error") - self._status_badge.set_state("warn") + if self._service.get_settings().show_radio_error_toasts: + self._toast_overlay.add_toast(Adw.Toast.new(msg)) + self._flash_radio_error() self._service.flush_stores() def _safety_net_pump(self) -> bool: diff --git a/tests/unit/test_settings_store.py b/tests/unit/test_settings_store.py index ba0b57d..2225765 100644 --- a/tests/unit/test_settings_store.py +++ b/tests/unit/test_settings_store.py @@ -28,3 +28,19 @@ def test_log_level_round_trip(tmp_path) -> None: loaded = store.load() assert loaded.log_level == "DEBUG" conn.close() + + +def test_radio_error_toasts_default_to_disabled_and_round_trip(tmp_path) -> None: + conn = open_db(str(tmp_path / "test.db")) + store = SettingsStore(conn) + + assert MeshcoreSettings().show_radio_error_toasts is False + + # Persisted False must survive the store's string round-trip, not just the + # dataclass default that load() falls back to on an empty table. + store.save(MeshcoreSettings()) + assert store.load().show_radio_error_toasts is False + + store.save(MeshcoreSettings(show_radio_error_toasts=True)) + assert store.load().show_radio_error_toasts is True + conn.close()