From 68b5d81c1f4e7a1adc7c17482fcebeabde5f01ff Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 15:47:06 -0700 Subject: [PATCH 1/2] Enhance settings menu and remote admin handling - Refactor settings_menu to support remote node configurations. - Add show_remote_admin_wait function for displaying progress during remote admin requests. - Update save_changes to accept a node parameter for better flexibility. - Improve error handling for remote admin rejections in handle_backtick. - Add unit tests for new remote admin functionalities and error scenarios. --- contact/ui/contact_ui.py | 59 +++++++++++++++++++++++++++++- contact/ui/control_ui.py | 37 ++++++++++++------- contact/ui/menus.py | 28 +++++++------- contact/utilities/save_to_radio.py | 6 +-- tests/test_contact_ui.py | 35 ++++++++++++++++++ 5 files changed, 135 insertions(+), 30 deletions(-) diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 4a61d7f..8651931 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -938,7 +938,45 @@ def handle_backtick(stdscr: curses.window) -> None: curses.curs_set(0) previous_window = ui_state.current_window ui_state.current_window = 4 - settings_menu(stdscr, interface_state.interface) + interface = interface_state.interface + if previous_window != 2: + settings_menu(stdscr, interface) + ui_state.current_window = previous_window + ui_state.single_pane_mode = config.single_pane_mode.lower() == "true" + curses.curs_set(1) + get_channels() + refresh_node_list() + handle_resize(stdscr, False) + return + + options = ["Local settings"] + remote_node_num = None + if ui_state.node_list: + remote_node_num = ui_state.node_list[ui_state.selected_node] + options.append(f"Remote admin: {get_name_from_database(remote_node_num)}") + choice = get_list_input("Open settings for", None, options) + if choice == "Local settings": + settings_menu(stdscr, interface) + elif remote_node_num is not 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) + except SystemExit as exc: + logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc) + if wait_win is not None: + wait_win.erase() + wait_win.refresh() + contact.ui.dialog.dialog( + "Remote admin rejected", + "The selected node did not authorize this admin request.", + ) + except Exception as exc: + logging.exception("Remote admin failed for %s", remote_node_num) + if wait_win is not None: + wait_win.erase() + wait_win.refresh() + contact.ui.dialog.dialog("Remote admin failed", str(exc)) ui_state.current_window = previous_window ui_state.single_pane_mode = config.single_pane_mode.lower() == "true" curses.curs_set(1) @@ -947,6 +985,25 @@ def handle_backtick(stdscr: curses.window) -> None: handle_resize(stdscr, False) +def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.window | None: + """Show progress while Meshtastic retrieves remote-admin settings.""" + message = f"Attempting remote admin of {node_name}, waiting for response..." + try: + height, width = stdscr.getmaxyx() + box_width = min(width - 4, max(len(message) + 4, 36)) + box_height = 5 + wait_win = curses.newwin(box_height, box_width, max(0, (height - box_height) // 2), max(0, (width - box_width) // 2)) + wait_win.bkgd(get_color("background")) + wait_win.attrset(get_color("window_frame")) + wait_win.border() + wait_win.addstr(0, 2, " Remote Admin ", get_color("settings_default")) + wait_win.addstr(2, 2, message[: box_width - 4], get_color("settings_default")) + wait_win.refresh() + return wait_win + except curses.error: + return None + + 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 1eb1f14..e20c1e1 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -279,10 +279,21 @@ 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) -> None: +def settings_menu(stdscr: object, interface: object, node: object = None, remote: bool = False) -> None: curses.update_lines_cols() + node = node or interface.localNode - menu = generate_menu_from_protobuf(interface) + 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. + 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() + + menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote) menu_state.current_menu = menu["Main Menu"] menu_state.menu_path = ["Main Menu"] @@ -387,12 +398,12 @@ def settings_menu(stdscr: object, interface: object) -> None: help_win.refresh() if menu_state.show_save_option and menu_state.selected_index == len(options): - reconnect_required = save_changes(interface, modified_settings, menu_state) + reconnect_required = save_changes(interface, modified_settings, menu_state, node=node) modified_settings.clear() logging.info("Changes Saved") if reconnect_required: interface = reconnect_interface_with_splash(stdscr, interface) - menu = generate_menu_from_protobuf(interface) + menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote) if len(menu_state.menu_path) > 1: menu_state.menu_path.pop() @@ -492,7 +503,7 @@ def settings_menu(stdscr: object, interface: object) -> None: continue elif selected_option == "Config URL": - current_value = interface.localNode.getURL() + current_value = node.getURL() new_value = get_text_input( t( "ui.prompt.config_url_current", @@ -510,7 +521,7 @@ def settings_menu(stdscr: object, interface: object) -> None: ["Yes", "No"], ) if overwrite == "Yes": - interface.localNode.setURL(new_value) + node.setURL(new_value) logging.info(f"New Config URL sent to node") menu_state.start_index.pop() continue @@ -521,7 +532,7 @@ def settings_menu(stdscr: object, interface: object) -> None: ) if confirmation == "Yes": interface = reconnect_after_admin_action( - stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu" + stdscr, interface, node.reboot, "Node Reboot Requested by menu" ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() @@ -535,7 +546,7 @@ def settings_menu(stdscr: object, interface: object) -> None: ) if confirmation == "Yes": interface = reconnect_after_admin_action( - stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu" + stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu" ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() @@ -546,7 +557,7 @@ def settings_menu(stdscr: object, interface: object) -> None: t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"] ) if confirmation == "Yes": - interface.localNode.shutdown() + node.shutdown() logging.info(f"Node Shutdown Requested by menu") menu_state.start_index.pop() continue @@ -561,7 +572,7 @@ def settings_menu(stdscr: object, interface: object) -> None: interface = reconnect_after_admin_action( stdscr, interface, - lambda: request_factory_reset(interface.localNode, full=True), + lambda: request_factory_reset(node, full=True), "Factory Reset Requested by menu", ) menu = rebuild_menu_at_current_path(interface, menu_state) @@ -578,7 +589,7 @@ def settings_menu(stdscr: object, interface: object) -> None: interface = reconnect_after_admin_action( stdscr, interface, - lambda: request_factory_reset(interface.localNode, full=False), + lambda: request_factory_reset(node, full=False), "Factory Reset Config Requested by menu", ) menu = rebuild_menu_at_current_path(interface, menu_state) @@ -613,8 +624,8 @@ def settings_menu(stdscr: object, interface: object) -> None: is_ringtone = selected_option == "ringtone" getter_name = "get_ringtone" if is_ringtone else "get_canned_message" setter_name = "set_ringtone" if is_ringtone else "set_canned_message" - getter = getattr(interface.localNode, getter_name, None) - setter = getattr(interface.localNode, setter_name, None) + getter = getattr(node, getter_name, None) + setter = getattr(node, setter_name, None) fetched_value = getter() if callable(getter) else None current_value = fetched_value if fetched_value is not None else current_value new_value = get_text_input( diff --git a/contact/ui/menus.py b/contact/ui/menus.py index 619374a..5ca64f6 100644 --- a/contact/ui/menus.py +++ b/contact/ui/menus.py @@ -57,14 +57,15 @@ def extract_fields( return menu -def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]: +def generate_menu_from_protobuf(interface: object, node: Any = None, include_app_settings: bool = True) -> Dict[str, Any]: """ Builds the full settings menu structure from the protobuf definitions. """ menu_structure = {"Main Menu": {}} # Add User Settings - current_node_info = interface.getMyNodeInfo() if interface else None + 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: current_user_config = current_node_info.get("user", None) @@ -84,23 +85,23 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]: # Add Channels channel = channel_pb2.ChannelSettings() menu_structure["Main Menu"]["Channels"] = {} - if interface: + if node: for i in range(8): - current_channel = interface.localNode.getChannelByChannelIndex(i) + current_channel = node.getChannelByChannelIndex(i) if current_channel: channel_config = extract_fields(channel, current_channel.settings) menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config # Add Radio Settings radio = config_pb2.Config() - current_radio_config = interface.localNode.localConfig if interface else None + current_radio_config = node.localConfig if node else None menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config) # Add Lat/Lon/Alt position_data = { - "latitude": (None, current_node_info["position"].get("latitude", 0.0)), - "longitude": (None, current_node_info["position"].get("longitude", 0.0)), - "altitude": (None, current_node_info["position"].get("altitude", 0)), + "latitude": (None, (current_node_info or {}).get("position", {}).get("latitude", 0.0)), + "longitude": (None, (current_node_info or {}).get("position", {}).get("longitude", 0.0)), + "altitude": (None, (current_node_info or {}).get("position", {}).get("altitude", 0)), } existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {}) @@ -117,23 +118,24 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]: # Add Module Settings module = module_config_pb2.ModuleConfig() - current_module_config = interface.localNode.moduleConfig if interface else None + current_module_config = node.moduleConfig if node else None module_settings = extract_fields(module, current_module_config) - if interface and interface.localNode: + if node: # These values use dedicated admin requests rather than ModuleConfig # fields, so expose them alongside their related module settings. module_settings.setdefault("external_notification", {})["ringtone"] = ( None, - getattr(interface.localNode, "ringtone", "") or "", + getattr(node, "ringtone", "") or "", ) module_settings.setdefault("canned_message", {})["messages"] = ( None, - getattr(interface.localNode, "cannedPluginMessage", "") or "", + getattr(node, "cannedPluginMessage", "") or "", ) menu_structure["Main Menu"]["Module Settings"] = module_settings # Add App Settings - menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"} + if include_app_settings: + menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"} # Additional settings options menu_structure["Main Menu"].update( diff --git a/contact/utilities/save_to_radio.py b/contact/utilities/save_to_radio.py index a123965..f62feb3 100644 --- a/contact/utilities/save_to_radio.py +++ b/contact/utilities/save_to_radio.py @@ -78,7 +78,7 @@ def _requires_reconnect(menu_state, modified_settings) -> bool: return True -def save_changes(interface, modified_settings, menu_state): +def save_changes(interface, modified_settings, menu_state, node=None): """ Save changes to the device based on modified settings. :param interface: Meshtastic interface instance @@ -90,7 +90,7 @@ def save_changes(interface, modified_settings, menu_state): logging.info("No changes to save. modified_settings is empty.") return False - node = interface.getNode("^local") + node = node or interface.getNode("^local") admin_key_backup = None if "admin_key" in modified_settings: # Get reference to security config @@ -134,7 +134,7 @@ def save_changes(interface, modified_settings, menu_state): lon = float(modified_settings.get("longitude", 0.0)) alt = int(modified_settings.get("altitude", 0)) - interface.localNode.setFixedPosition(lat, lon, alt) + node.setFixedPosition(lat, lon, alt) logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") return False diff --git a/tests/test_contact_ui.py b/tests/test_contact_ui.py index 13c8dad..b291708 100644 --- a/tests/test_contact_ui.py +++ b/tests/test_contact_ui.py @@ -39,6 +39,41 @@ class ContactUiTests(unittest.TestCase): self.assertEqual(curs_set.call_args_list[-1].args, (1,)) self.assertEqual(ui_state.current_window, 1) + def test_handle_backtick_shows_error_when_remote_admin_is_rejected(self) -> None: + stdscr = mock.Mock() + ui_state.current_window = 2 + ui_state.node_list = [123] + ui_state.selected_node = 0 + contact_ui.interface_state.interface = mock.Mock() + contact_ui.interface_state.interface.getNode.return_value = mock.Mock() + + with mock.patch.object(contact_ui.curses, "curs_set"): + with mock.patch.object(contact_ui, "get_channels"): + with mock.patch.object(contact_ui, "refresh_node_list"): + with mock.patch.object(contact_ui, "handle_resize"): + with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"): + with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"): + with mock.patch.object(contact_ui, "settings_menu", side_effect=SystemExit(1)): + with mock.patch("contact.ui.dialog.dialog") as dialog: + contact_ui.handle_backtick(stdscr) + + dialog.assert_called_once_with( + "Remote admin rejected", + "The selected node did not authorize this admin request.", + ) + + def test_show_remote_admin_wait_draws_status(self) -> None: + stdscr = mock.Mock() + stdscr.getmaxyx.return_value = (24, 100) + wait_win = mock.Mock() + + with mock.patch.object(contact_ui.curses, "newwin", return_value=wait_win): + result = contact_ui.show_remote_admin_wait(stdscr, "Remote") + + self.assertIs(result, wait_win) + self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2]) + wait_win.refresh.assert_called_once() + def test_process_pending_ui_updates_draws_requested_windows(self) -> None: stdscr = mock.Mock() ui_state.redraw_channels = True From f4bd0c8614c22603b2ff4e771ee72d45114ab4c5 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 22:50:39 -0700 Subject: [PATCH 2/2] add remote admin --- .vscode/launch.json | 8 ++++++ contact/ui/contact_ui.py | 21 ++++++++++++++- contact/ui/control_ui.py | 55 +++++++++++++++++++++++++++++++++------- contact/ui/menus.py | 4 ++- tests/test_contact_ui.py | 8 ++++++ 5 files changed, 85 insertions(+), 11 deletions(-) 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