diff --git a/.vscode/launch.json b/.vscode/launch.json index d0b7a9c..bf5197b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -17,6 +17,14 @@ "cwd": "${workspaceFolder}", "module": "contact.__main__", "args": ["--host","192.168.86.69"] + }, + { + "name": "Python Debugger: tcp-remote", + "type": "debugpy", + "request": "launch", + "cwd": "${workspaceFolder}", + "module": "contact.__main__", + "args": ["--host","100.89.187.29"] } ] } diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 8651931..e9dd321 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -961,7 +961,13 @@ def handle_backtick(stdscr: curses.window) -> None: wait_win = None try: wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num)) - settings_menu(stdscr, interface, node=interface.getNode(remote_node_num), remote=True) + settings_menu( + stdscr, + interface, + node=interface.getNode(remote_node_num), + remote=True, + status_callback=lambda message: update_remote_admin_wait(wait_win, message), + ) except SystemExit as exc: logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc) if wait_win is not None: @@ -1004,6 +1010,19 @@ def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.wind 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: + return + try: + _, width = wait_win.getmaxyx() + wait_win.addstr(3, 2, " " * (width - 4), get_color("settings_default")) + wait_win.addstr(3, 2, message[: width - 4], get_color("settings_default")) + wait_win.refresh() + except curses.error: + pass + + def handle_ctrl_p() -> None: """Handle Ctrl + P key events to toggle the packet log display.""" # Display packet log diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index e20c1e1..7684a89 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -4,6 +4,7 @@ import ipaddress import logging import os import sys +import threading from typing import List from meshtastic.protobuf import admin_pb2 @@ -34,6 +35,8 @@ from contact.utilities.singleton import interface_state, menu_state MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals save_option = "Save Changes" max_help_lines = 0 +REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS = 15 +admin_target_label = "" help_win = None sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"] @@ -86,7 +89,8 @@ def get_translated_header(menu_path: List[str]) -> str: continue full_key = ".".join(transformed_path[:idx]) translated_parts.append(field_mapping.get(full_key, part)) - return " > ".join(translated_parts) + breadcrumb = " > ".join(translated_parts) + return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb def display_menu() -> tuple[object, object]: @@ -279,19 +283,50 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None: logging.debug("Skipping main UI redraw after reconnect", exc_info=True) -def settings_menu(stdscr: object, interface: object, node: object = None, remote: bool = False) -> None: +def _request_remote_with_timeout(request, *args) -> None: + """Run a Meshtastic remote request without allowing its ACK wait to hang the UI.""" + failure = [] + + def run_request() -> None: + try: + request(*args) + except BaseException as exc: + failure.append(exc) + + worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True) + worker.start() + worker.join(REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS) + if worker.is_alive(): + raise TimeoutError("Timed out waiting for the remote node to answer the admin request.") + if failure: + raise failure[0] + + +def settings_menu( + stdscr: object, interface: object, node: object = None, remote: bool = False, status_callback=None +) -> None: curses.update_lines_cols() + global admin_target_label node = node or interface.localNode + node_num = getattr(node, "nodeNum", 0) + node_info = interface.getMyNodeInfo() if not remote else getattr(interface, "nodesByNum", {}).get(node_num, {}) + node_name = node_info.get("user", {}).get("longName") if isinstance(node_info, dict) else None + admin_target_label = node_name or f"!{node_num:08x}" if remote: - # Remote nodes do not populate config automatically; request every - # section before building the menu. Authorization failures surface as - # Meshtastic admin errors and are handled by the caller. + # Remote nodes do not populate radio config automatically. Request + # the standard sections before building the menu. Module requests are + # intentionally deferred: firmware may omit modules and never answer + # those requests, which would otherwise block the entire UI. for config_type in list(node.localConfig.DESCRIPTOR.fields): - node.requestConfig(config_type) - for config_type in list(node.moduleConfig.DESCRIPTOR.fields): - node.requestConfig(config_type) - node.requestChannels() + if config_type.name in {"version", "sessionkey"}: + continue + if status_callback: + status_callback(f"Requesting {config_type.name} config…") + _request_remote_with_timeout(node.requestConfig, config_type) + if status_callback: + status_callback("Requesting channels…") + _request_remote_with_timeout(node.requestChannels) menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote) menu_state.current_menu = menu["Main Menu"] @@ -401,6 +436,8 @@ def settings_menu(stdscr: object, interface: object, node: object = None, remote reconnect_required = save_changes(interface, modified_settings, menu_state, node=node) modified_settings.clear() logging.info("Changes Saved") + if remote: + break if reconnect_required: interface = reconnect_interface_with_splash(stdscr, interface) menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote) diff --git a/contact/ui/menus.py b/contact/ui/menus.py index 5ca64f6..070f81b 100644 --- a/contact/ui/menus.py +++ b/contact/ui/menus.py @@ -66,6 +66,8 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app # Add User Settings node = node or (interface.localNode if interface else None) current_node_info = interface.getMyNodeInfo() if interface and node is interface.localNode else None + if current_node_info is None and interface and node is not None: + current_node_info = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None)) if current_node_info: current_user_config = current_node_info.get("user", None) @@ -80,7 +82,7 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app menu_structure["Main Menu"]["User Settings"] = "No user settings available" else: logging.info("Node Info not available") - menu_structure["Main Menu"]["User Settings"] = "Node Info not available" + menu_structure["Main Menu"]["User Settings"] = {} # Add Channels channel = channel_pb2.ChannelSettings() diff --git a/tests/test_contact_ui.py b/tests/test_contact_ui.py index b291708..298f1f8 100644 --- a/tests/test_contact_ui.py +++ b/tests/test_contact_ui.py @@ -74,6 +74,14 @@ class ContactUiTests(unittest.TestCase): self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2]) wait_win.refresh.assert_called_once() + def test_update_remote_admin_wait_replaces_status_line(self) -> None: + wait_win = mock.Mock() + wait_win.getmaxyx.return_value = (5, 40) + + contact_ui.update_remote_admin_wait(wait_win, "Requesting lora config…") + + self.assertIn("Requesting lora config", wait_win.addstr.call_args_list[-1].args[2]) + def test_process_pending_ui_updates_draws_requested_windows(self) -> None: stdscr = mock.Mock() ui_state.redraw_channels = True