From 7f6fa49385bbaced109def84d181e7522480e481 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 25 Jul 2026 21:46:27 -0700 Subject: [PATCH] Enhance reconnect logic and error handling in UI and interface modules - Implement automatic reconnection attempts in the main UI when disconnected. - Add a non-blocking connection status dialog to inform users during reconnection. - Improve error handling when sending messages and during interface reconnections. - Update the reconnect_interface function to increase the number of attempts and refine connection checks. --- contact/ui/contact_ui.py | 79 ++++++++++++++++++++++++++++++--- contact/ui/control_ui.py | 4 -- contact/ui/ui_state.py | 1 + contact/utilities/interfaces.py | 12 +++-- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index cfbbbef..ab25d19 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -383,6 +383,41 @@ def main_ui(stdscr: curses.window) -> None: handle_resize(stdscr, True) while True: + interface = interface_state.interface + if ( + hasattr(interface, "stream") + and interface.stream is None + and not ui_state.reconnect_attempted + ): + ui_state.reconnect_attempted = True + status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…") + try: + from contact.ui.control_ui import reconnect_interface_with_splash + + reconnect_interface_with_splash(stdscr, interface) + if status_win is not None: + status_win.erase() + status_win.refresh() + ui_state.reconnect_attempted = False + handle_resize(stdscr, False) + except Exception: + if status_win is not None: + status_win.erase() + status_win.refresh() + handle_resize(stdscr, False) + logging.exception("Automatic reconnect after transport disconnect failed") + retry = get_list_input( + "Contact could not reconnect after 20 seconds.", + "Retry", + ["Retry", "Cancel"], + mandatory=True, + ) + if retry == "Retry": + ui_state.reconnect_attempted = False + handle_resize(stdscr, False) + continue + handle_resize(stdscr, False) + with app_state.lock: process_pending_ui_updates(stdscr) entry_display = f"{ui_state.reply_context}{input_text or ''}" @@ -652,12 +687,27 @@ def handle_enter(input_text: str) -> str: ) return input_text # Enter key pressed, send user input as message - send_message( - input_text, - channel=ui_state.selected_channel, - reply_id=ui_state.reply_id, - reply_context=ui_state.reply_context, - ) + try: + send_message( + input_text, + channel=ui_state.selected_channel, + reply_id=ui_state.reply_id, + reply_context=ui_state.reply_context, + ) + except Exception as exc: + logging.warning("Message send failed; reconnecting interface: %s", exc) + try: + from contact.ui.control_ui import reconnect_interface_with_splash + + reconnect_interface_with_splash(root_win, interface_state.interface) + contact.ui.dialog.dialog("Reconnected", "Connection restored. Your message was not sent; press Enter to retry.") + except Exception as reconnect_exc: + logging.exception("Automatic reconnect failed") + contact.ui.dialog.dialog( + "Reconnect failed", + "Contact could not reconnect after 20 seconds. Check the radio connection and retry your message.", + ) + return input_text ui_state.reply_id = None ui_state.reply_context = "" ui_state.reply_id_unavailable = False @@ -1012,6 +1062,23 @@ def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.wind return None +def show_connection_status(stdscr: curses.window, title: str, message: str) -> curses.window | None: + """Display a non-blocking connection-status dialog over the current UI.""" + try: + height, width = stdscr.getmaxyx() + box_width = min(width - 4, max(len(message) + 4, len(title) + 6, 32)) + win = curses.newwin(5, box_width, max(0, (height - 5) // 2), max(0, (width - box_width) // 2)) + win.bkgd(get_color("background")) + win.attrset(get_color("window_frame")) + win.border() + win.addstr(0, 2, f" {title} ", get_color("settings_default")) + win.addstr(2, 2, message[: box_width - 4], get_color("settings_default")) + win.refresh() + return win + except curses.error: + return None + + def update_remote_admin_wait(wait_win: curses.window | None, message: str) -> None: """Append the current remote-admin request to the waiting overlay.""" if wait_win is None: diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index bcd7a4f..7d34d3d 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -26,7 +26,6 @@ from contact.ui.colors import get_color from contact.ui.dialog import dialog from contact.ui.menus import generate_menu_from_protobuf from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window -from contact.ui.splash import draw_splash from contact.ui.user_config import json_editor from contact.utilities.arg_parser import setup_parser from contact.utilities.singleton import interface_state, menu_state @@ -236,9 +235,6 @@ def reconnect_interface_with_splash(stdscr: object, interface: object) -> object except Exception: pass - stdscr.clear() - stdscr.refresh() - draw_splash(stdscr) new_interface = reconnect_interface(setup_parser().parse_args()) interface_state.interface = new_interface redraw_main_ui_after_reconnect(stdscr) diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index 514e4cc..4c29963 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -39,6 +39,7 @@ class ChatUIState: redraw_full_ui: bool = False scroll_messages_to_bottom: bool = False preserve_message_selection: bool = False + reconnect_attempted: bool = False oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict) has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict) message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict) diff --git a/contact/utilities/interfaces.py b/contact/utilities/interfaces.py index bd33ed6..9b6d53d 100644 --- a/contact/utilities/interfaces.py +++ b/contact/utilities/interfaces.py @@ -44,15 +44,21 @@ def initialize_interface(args): logging.critical(f"Fatal error initializing interface: {ex}") -def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0): +def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0): last_error = None for attempt in range(attempts): try: interface = initialize_interface(args) - if interface is not None: + if interface is not None and getattr(interface, "localNode", None) is not None and getattr( + interface.localNode, "localConfig", None + ) is not None and (not hasattr(interface, "stream") or interface.stream is not None): return interface - last_error = RuntimeError("initialize_interface returned None") + last_error = RuntimeError("interface did not complete connection setup") + try: + interface.close() + except Exception: + pass except Exception as ex: last_error = ex