diff --git a/app/radio.py b/app/radio.py
index 70710e0..cacb9a9 100644
--- a/app/radio.py
+++ b/app/radio.py
@@ -12,6 +12,7 @@ from app.config import settings
from app.keystore import clear_keys
logger = logging.getLogger(__name__)
+MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
class RadioOperationError(RuntimeError):
@@ -131,6 +132,7 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
+ self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
@@ -387,6 +389,21 @@ class RadioManager:
self._last_connected = False
await self.disconnect()
+ def _reset_reconnect_error_broadcasts(self) -> None:
+ self._frontend_reconnect_error_broadcasts = 0
+
+ def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
+ from app.websocket import broadcast_error
+
+ self._frontend_reconnect_error_broadcasts += 1
+ if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
+ return
+
+ if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
+ details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
+
+ broadcast_error("Reconnection failed", details)
+
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
@@ -485,6 +502,7 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
clear_keys()
+ self._reset_reconnect_error_broadcasts()
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
mc = self._meshcore
@@ -511,7 +529,7 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts.
"""
- from app.websocket import broadcast_error, broadcast_health
+ from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None:
@@ -549,6 +567,7 @@ class RadioManager:
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
+ self._reset_reconnect_error_broadcasts()
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
@@ -558,7 +577,7 @@ class RadioManager:
except Exception as e:
logger.warning("Reconnection failed: %s", e, exc_info=True)
- broadcast_error("Reconnection failed", str(e))
+ self._broadcast_reconnect_error_if_needed(str(e))
return False
async def start_connection_monitor(self) -> None:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ac1a7c0..9e68741 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -194,6 +194,7 @@ export function App() {
channels,
contacts,
contactsLoaded,
+ suspendHashSync: showSettings,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -254,6 +255,12 @@ export function App() {
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
+ useEffect(() => {
+ if (showSettings && !config && settingsSection === 'radio') {
+ setSettingsSection('local');
+ }
+ }, [config, settingsSection, setSettingsSection, showSettings]);
+
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
@@ -566,6 +573,7 @@ export function App() {
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
showCracker={showCracker}
+ disabledSettingsSections={config ? [] : ['radio']}
onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning}
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index d283508..b141b97 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -41,6 +41,7 @@ interface AppShellProps {
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
+ disabledSettingsSections?: SettingsSection[];
onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void;
@@ -69,6 +70,7 @@ export function AppShell({
settingsSection,
sidebarOpen,
showCracker,
+ disabledSettingsSections = [],
onSettingsSectionChange,
onSidebarOpenChange,
onCrackerRunningChange,
@@ -118,13 +120,16 @@ export function AppShell({
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
+ const disabled = disabledSettingsSections.includes(section);
return (