add remote admin

This commit is contained in:
pdxlocations
2026-07-24 22:50:39 -07:00
parent 68b5d81c1f
commit f4bd0c8614
5 changed files with 85 additions and 11 deletions
+8
View File
@@ -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"]
}
]
}
+20 -1
View File
@@ -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
+46 -9
View File
@@ -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)
+3 -1
View File
@@ -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()
+8
View File
@@ -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