Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/meshcore_console/meshcore/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion src/meshcore_console/ui_gtk/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand Down
56 changes: 47 additions & 9 deletions src/meshcore_console/ui_gtk/windows/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/test_settings_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading