Compare commits

..
14 Commits
Author SHA1 Message Date
pdxlocations 07b9ed22d8 Bump version to 1.6.7 in pyproject.toml 2026-07-30 22:26:46 -07:00
pdxlocations bdba69677b Refactor error handling in start function to use print_exception for better traceback output and enhance tests for channel name resolution with new modem presets 2026-07-30 22:18:25 -07:00
pdxlocations 1f88a37ac1 Refactor save_changes to streamline admin key updates and enhance test coverage for key replacement 2026-07-26 20:34:55 -07:00
pdxlocations 8f33cbc22d Refactor handle_backtick to improve local settings selection 2026-07-26 20:32:21 -07:00
pdxlocations 40665ff076 Bump version to 1.6.6 in pyproject.toml 2026-07-26 14:26:01 -07:00
pdxlocations d1b6da6f26 Refactor channel settings to rename 'is_client_muted' to 'is_muted' and update related save functionality and tests 2026-07-26 14:23:11 -07:00
pdxlocations ac90b4af3e Enhance remote admin verification to establish authorized sessions and update related tests 2026-07-26 14:07:09 -07:00
pdxlocations 7af693ddea Enhance bot response to include reply context in message sending 2026-07-26 13:47:51 -07:00
pdxlocations dae6a43652 Bump version to 1.6.5 in pyproject.toml 2026-07-26 11:09:26 -07:00
pdxlocations 22e5e89828 Bump version to 1.6.4 in pyproject.toml 2026-07-26 11:08:57 -07:00
pdxlocations c2db76d5ec Merge pull request #298 from pdxlocations:log-viewer
Add log viewer functionality and enhance reconnect handling
2026-07-26 11:08:27 -07:00
pdxlocations d8dc4876b6 Add log viewer functionality and enhance reconnect handling
- Introduced a log viewer in the UI to display live logs.
- Added functionality to load the last few lines of the log file.
- Enhanced reconnect logic to handle UI state during connection attempts.
- Updated UI state management to track log viewer status.
- Added tests for log loading functionality.
2026-07-26 11:07:33 -07:00
pdxlocations 0ae644e3fb Keep Meshtastic logs out of the curses terminal 2026-07-26 10:44:02 -07:00
pdxlocations bfd20bfa49 Verify remote admin access before opening settings 2026-07-26 10:31:00 -07:00
21 changed files with 534 additions and 119 deletions

No files matched your search

+8 -2
View File
@@ -52,7 +52,13 @@ if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
logging.basicConfig(
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
filename=config.log_file_path,
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
# Meshtastic or another dependency may have installed a stderr handler
# before Contact starts curses. Replace it so background warnings cannot
# overwrite the terminal UI.
force=True,
)
app_state.lock = threading.Lock()
@@ -252,7 +258,7 @@ def start() -> None:
if fatal_error is not None:
print("Fatal error:", fatal_error)
traceback.print_exc()
traceback.print_exception(type(fatal_error), fatal_error, fatal_error.__traceback__)
sys.exit(1)
if interrupted:
+2 -1
View File
@@ -114,6 +114,7 @@ message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
view_log, "View Log", "Open a scrollable live view of Contact's log file."
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
@@ -167,7 +168,7 @@ uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT s
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
module_settings, "Module settings", "Position precision and Client Mute."
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
module_settings.is_muted, "Is muted", "Controls whether or not the client or device should mute this channel. Useful for noisy public channels you do not necessarily want to disable."
[config.device]
title, "Device"
+8 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict
import contact.ui.default_config as config
from contact.utilities.singleton import app_state, interface_state
from contact.utilities.utils import get_reply_context
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
@@ -81,7 +82,13 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
# Use the triggering packet's ID so Meshtastic clients render this
# automatic response as a native reply when that ID is available.
send_message(response_data_string, channel=send_channel, reply_id=packet_id)
reply_context = get_reply_context(packet_id) if packet_id is not None else ""
send_message(
response_data_string,
channel=send_channel,
reply_id=packet_id,
reply_context=reply_context,
)
# Import locally to avoid circular import at module import time.
from contact.ui.contact_ui import request_ui_redraw
+2 -2
View File
@@ -1,6 +1,6 @@
import time
from typing import Any, Dict
from typing import Any, Dict, Optional
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
@@ -168,7 +168,7 @@ def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: int | None = None,
reply_id: Optional[int] = None,
reply_context: str = "",
) -> None:
"""
+1
View File
@@ -85,6 +85,7 @@ logging.basicConfig( # Run `tail -f client.log` in another terminal to view liv
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
force=True,
)
if __name__ == "__main__":
+215 -63
View File
@@ -1,5 +1,6 @@
import curses
import logging
import os
from typing import Optional
import time
import traceback
@@ -15,7 +16,8 @@ from contact.utilities.utils import (
build_reply_prefix,
)
from contact.settings import settings_menu
from contact.ui.control_ui import RemoteAdminCancelled
from contact.ui.control_ui import RemoteAdminCancelled, verify_remote_admin
from contact.ui.user_config import load_log_tail
from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color
@@ -43,6 +45,115 @@ root_win = None
nodes_pad = None
def refresh_log_viewer() -> None:
"""Refresh the in-app log overlay only when the log file changes."""
try:
stat = os.stat(config.log_file_path)
signature = (stat.st_mtime_ns, stat.st_size)
except OSError:
signature = None
if not ui_state.log_viewer_loaded or signature != ui_state.log_viewer_signature:
ui_state.log_viewer_lines = load_log_tail(config.log_file_path)
ui_state.log_viewer_signature = signature
ui_state.log_viewer_loaded = True
def flush_log_handlers() -> None:
"""Ensure a just-written status event is visible to the log tailer."""
for handler in logging.getLogger().handlers:
try:
handler.flush()
except Exception:
pass
def draw_log_viewer(stdscr: curses.window) -> int:
"""Draw the log overlay and return the number of visible log rows."""
refresh_log_viewer()
height, width = stdscr.getmaxyx()
content_height = max(1, height - 4)
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
if ui_state.log_viewer_follow:
ui_state.log_viewer_start_line = max_start
else:
ui_state.log_viewer_start_line = min(ui_state.log_viewer_start_line, max_start)
try:
stdscr.erase()
stdscr.bkgd(get_color("background"))
stdscr.attrset(get_color("window_frame"))
stdscr.border()
state = "Following" if ui_state.log_viewer_follow else "Paused"
title = " View Log — {} ".format(state)
stdscr.addstr(0, 2, title[: max(0, width - 4)], get_color("settings_breadcrumbs", bold=True))
for row, line in enumerate(
ui_state.log_viewer_lines[ui_state.log_viewer_start_line : ui_state.log_viewer_start_line + content_height],
start=1,
):
stdscr.addstr(row, 1, line[: max(0, width - 2)], get_color("settings_default"))
hint = " Esc Close Up/Down Scroll PgUp/PgDn Page F Follow "
stdscr.addstr(height - 2, 1, hint[: max(0, width - 2)], get_color("commands"))
stdscr.refresh()
except curses.error:
pass
return content_height
def handle_log_viewer_key(key: int, content_height: int) -> bool:
"""Handle a log-overlay key. Returns True when the overlay closes."""
if ui_state.reconnect_prompt_open:
if key in (ord("r"), ord("R")):
ui_state.reconnect_prompt_open = False
ui_state.reconnect_attempted = False
elif key in (ord("c"), ord("C"), 27):
ui_state.reconnect_prompt_open = False
return False
if key == 27:
ui_state.log_viewer_open = False
return True
if key in (ord("f"), ord("F")):
ui_state.log_viewer_follow = not ui_state.log_viewer_follow
return False
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
if key == curses.KEY_UP:
ui_state.log_viewer_follow = False
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - 1)
elif key == curses.KEY_DOWN:
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + 1)
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
elif key == curses.KEY_PPAGE:
ui_state.log_viewer_follow = False
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - content_height)
elif key == curses.KEY_NPAGE:
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + content_height)
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
return False
def draw_log_reconnect_prompt(stdscr: curses.window) -> None:
"""Draw a non-blocking reconnect choice over the live log overlay."""
if not ui_state.reconnect_prompt_open:
return
try:
height, width = stdscr.getmaxyx()
message = "Could not reconnect after 20 seconds."
hint = "R Retry C Cancel"
box_width = min(width - 4, max(len(message) + 4, len(hint) + 4, 42))
win = curses.newwin(6, box_width, max(0, (height - 6) // 2), max(0, (width - box_width) // 2))
win.bkgd(get_color("background"))
win.attrset(get_color("window_frame"))
win.border()
win.addstr(0, 2, " Disconnected ", get_color("settings_default"))
win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
win.addstr(4, 2, hint[: box_width - 4], get_color("commands"))
win.refresh()
except curses.error:
pass
def request_ui_redraw(
*,
channels: bool = False,
@@ -389,7 +500,15 @@ def main_ui(stdscr: curses.window) -> None:
interface = interface_state.interface
if not interface_is_connected(interface) and not ui_state.reconnect_attempted:
ui_state.reconnect_attempted = True
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
logging.warning("Meshtastic connection disconnected; attempting automatic reconnect")
flush_log_handlers()
if ui_state.log_viewer_open:
# Reconnect is synchronous. Repaint once before entering it so
# the disconnect event remains visible during the attempt.
draw_log_viewer(stdscr)
status_win = None
if not ui_state.log_viewer_open:
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
try:
from contact.ui.control_ui import reconnect_interface_with_splash
@@ -398,13 +517,18 @@ def main_ui(stdscr: curses.window) -> None:
status_win.erase()
status_win.refresh()
ui_state.reconnect_attempted = False
handle_resize(stdscr, False)
if not ui_state.log_viewer_open:
handle_resize(stdscr, False)
except Exception:
if status_win is not None:
status_win.erase()
status_win.refresh()
handle_resize(stdscr, False)
if not ui_state.log_viewer_open:
handle_resize(stdscr, False)
logging.exception("Automatic reconnect after transport disconnect failed")
if ui_state.log_viewer_open:
ui_state.reconnect_prompt_open = True
continue
retry = get_list_input(
"Contact could not reconnect after 20 seconds.",
"Retry",
@@ -417,6 +541,18 @@ def main_ui(stdscr: curses.window) -> None:
continue
handle_resize(stdscr, False)
if ui_state.log_viewer_open:
content_height = draw_log_viewer(stdscr)
draw_log_reconnect_prompt(stdscr)
stdscr.timeout(200)
try:
key = stdscr.getch()
except curses.error:
continue
if key != -1 and handle_log_viewer_key(key, content_height):
handle_resize(stdscr, False)
continue
with app_state.lock:
process_pending_ui_updates(stdscr)
entry_display = f"{ui_state.reply_context}{input_text or ''}"
@@ -998,67 +1134,83 @@ def handle_backtick(stdscr: curses.window) -> None:
handle_resize(stdscr, False)
return
options = ["Local settings"]
remote_node_num = None
selected_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
selected_node_num = ui_state.node_list[ui_state.selected_node]
def remote_status(message: Optional[str]) -> None:
nonlocal wait_win
if message is None:
# The local node is already the target for normal settings; do not offer a
# misleading remote-admin choice when it is selected in the node pane.
if selected_node_num == interface_state.myNodeNum:
settings_menu(stdscr, interface)
else:
options = ["Local settings"]
remote_node_num = None
if selected_node_num is not None:
remote_node_num = selected_node_num
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
def remote_status(message: Optional[str]) -> None:
nonlocal wait_win
if message is None:
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
wait_win = None
return
if wait_win is None:
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
update_remote_admin_wait(wait_win, message)
def remote_cancel_requested() -> bool:
if wait_win is None:
return False
try:
wait_win.timeout(0)
return wait_win.getch() == 27
finally:
wait_win.timeout(-1)
try:
remote_node = interface.getNode(remote_node_num, False)
verify_remote_admin(
remote_node,
status_callback=remote_status,
cancel_callback=remote_cancel_requested,
)
remote_status(None)
settings_menu(
stdscr,
interface,
# Settings requests configs explicitly; avoid getNode's
# implicit channel request racing that setup.
node=remote_node,
remote=True,
status_callback=remote_status,
cancel_callback=remote_cancel_requested,
)
except RemoteAdminCancelled:
logging.info("Remote admin request cancelled for %s", remote_node_num)
remote_status(None)
except (SystemExit, PermissionError) 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()
wait_win = None
return
if wait_win is None:
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
update_remote_admin_wait(wait_win, message)
def remote_cancel_requested() -> bool:
if wait_win is None:
return False
try:
wait_win.timeout(0)
return wait_win.getch() == 27
finally:
wait_win.timeout(-1)
try:
settings_menu(
stdscr,
interface,
# Settings requests configs explicitly; avoid getNode's
# implicit channel request racing that setup.
node=interface.getNode(remote_node_num, False),
remote=True,
status_callback=remote_status,
cancel_callback=remote_cancel_requested,
)
except RemoteAdminCancelled:
logging.info("Remote admin request cancelled for %s", remote_node_num)
remote_status(None)
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))
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)
@@ -1067,7 +1219,7 @@ 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:
def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> Optional[curses.window]:
"""Show progress while Meshtastic retrieves remote-admin settings."""
message = f"Attempting remote admin of {node_name}, waiting for response..."
try:
@@ -1086,7 +1238,7 @@ 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:
def show_connection_status(stdscr: curses.window, title: str, message: str) -> Optional[curses.window]:
"""Display a non-blocking connection-status dialog over the current UI."""
try:
height, width = stdscr.getmaxyx()
@@ -1103,7 +1255,7 @@ def show_connection_status(stdscr: curses.window, title: str, message: str) -> c
return None
def update_remote_admin_wait(wait_win: curses.window | None, message: str) -> None:
def update_remote_admin_wait(wait_win: Optional[curses.window], message: str) -> None:
"""Append the current remote-admin request to the waiting overlay."""
if wait_win is None:
return
+24 -2
View File
@@ -279,7 +279,8 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None:
get_channels()
refresh_node_list()
contact_ui.handle_resize(stdscr, False)
if not contact_ui.ui_state.log_viewer_open:
contact_ui.handle_resize(stdscr, False)
except Exception:
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
@@ -319,6 +320,25 @@ def _request_remote_channels_with_timeout(node: object, cancel_callback=None) ->
time.sleep(0.1)
def verify_remote_admin(node: object, status_callback=None, cancel_callback=None) -> None:
"""Establish and verify an authorized remote-admin session.
Config reads alone are not a sufficient authorization check. The
Meshtastic admin-session exchange only provides a session passkey when
the remote node accepts this client's administrative credentials.
"""
if status_callback:
status_callback("Checking remote-admin permission…")
_request_remote_with_timeout(node.ensureSessionKey, cancel_callback=cancel_callback)
try:
remote_session = node.iface._getOrCreateByNum(node.nodeNum).get("adminSessionPassKey")
except (AttributeError, TypeError):
remote_session = None
if not remote_session:
raise PermissionError("The selected node did not authorize this admin request.")
def _request_remote_section(
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
) -> bool:
@@ -680,12 +700,14 @@ def settings_menu(
menu_win.refresh()
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu
open_log_viewer = json_editor(stdscr, menu_state) # Open the App Settings menu
reload_translations()
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
menu_state.start_index.pop()
menu_state.selected_index = 4
if open_log_viewer:
break
continue
field_info = menu_state.current_menu.get(selected_option)
+7
View File
@@ -40,6 +40,13 @@ class ChatUIState:
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
reconnect_attempted: bool = False
reconnect_prompt_open: bool = False
log_viewer_open: bool = False
log_viewer_follow: bool = True
log_viewer_start_line: int = 0
log_viewer_lines: List[str] = field(default_factory=list)
log_viewer_signature: Any = None
log_viewer_loaded: 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)
+48 -5
View File
@@ -9,12 +9,14 @@ from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
from contact.utilities.singleton import menu_state, ui_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
save_option = "Save Changes"
VIEW_LOG_OPTION = "__view_log__"
LOG_VIEWER_LINE_LIMIT = 500
translation_file = config.get_localisation_file(config.language)
field_mapping, help_text = parse_ini_file(translation_file)
translation_language = config.language
@@ -86,6 +88,32 @@ def get_effective_width() -> int:
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def load_log_tail(path: str, max_lines: int = LOG_VIEWER_LINE_LIMIT) -> List[str]:
"""Read the final log lines without loading an arbitrarily large log file."""
if max_lines <= 0:
return []
try:
with open(path, "rb") as log_file:
log_file.seek(0, os.SEEK_END)
position = log_file.tell()
chunks = []
newline_count = 0
while position > 0 and newline_count <= max_lines:
read_size = min(8192, position)
position -= read_size
log_file.seek(position)
chunk = log_file.read(read_size)
chunks.append(chunk)
newline_count += chunk.count(b"\n")
data = b"".join(reversed(chunks)).decode("utf-8", errors="replace")
lines = data.splitlines()
if position > 0 and lines:
lines = lines[1:]
return lines[-max_lines:]
except OSError as exc:
return [f"Unable to read log: {exc}"]
def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
@@ -273,6 +301,8 @@ def display_menu() -> tuple[Any, Any, List[str]]:
# Determine menu items based on the type of current_menu
if isinstance(menu_state.current_menu, dict):
options = list(menu_state.current_menu.keys())
if len(menu_state.menu_path) <= 2:
options.insert(0, VIEW_LOG_OPTION)
elif isinstance(menu_state.current_menu, list):
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
else:
@@ -311,10 +341,13 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_state.current_menu[key]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
) if key != VIEW_LOG_OPTION else ""
if isinstance(menu_state.current_menu, dict):
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
if key == VIEW_LOG_OPTION:
display_key = field_mapping.get("app_settings.view_log", "View Log")
else:
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
@@ -391,7 +424,7 @@ def update_app_settings_help(menu_win: curses.window, options: List[str]) -> Non
)
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
@@ -470,6 +503,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
if selected_key == VIEW_LOG_OPTION:
ui_state.log_viewer_open = True
ui_state.log_viewer_follow = True
ui_state.log_viewer_start_line = 0
ui_state.log_viewer_lines = []
ui_state.log_viewer_signature = None
ui_state.log_viewer_loaded = False
return True
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index)
@@ -573,6 +614,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
break
return False
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
+9
View File
@@ -68,13 +68,19 @@ def initialize_interface(args):
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
last_error = None
target = getattr(args, "host", None) or getattr(args, "port", None) or getattr(args, "ble", None) or "auto"
for attempt in range(attempts):
attempt_number = attempt + 1
logging.info("Reconnect attempt %d/%d to %s", attempt_number, attempts, target)
try:
interface = initialize_interface(args)
if interface_is_connected(interface) and getattr(interface, "localNode", None) is not None and getattr(
interface.localNode, "localConfig", None
) is not None:
node_num = getattr(interface.localNode, "nodeNum", None)
node_label = f" !{node_num:08x}" if isinstance(node_num, int) else ""
logging.info("Reconnected to Meshtastic node%s on attempt %d/%d", node_label, attempt_number, attempts)
return interface
last_error = RuntimeError("interface did not complete connection setup")
try:
@@ -84,7 +90,10 @@ def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
except Exception as ex:
last_error = ex
logging.warning("Reconnect attempt %d/%d failed: %s", attempt_number, attempts, last_error)
if attempt < attempts - 1:
time.sleep(delay_seconds)
logging.error("Unable to reconnect to Meshtastic node after %d attempts", attempts)
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
+14 -20
View File
@@ -2,7 +2,6 @@ from meshtastic.protobuf import channel_pb2
from google.protobuf.message import Message
import logging
import base64
import time
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
POWER_REBOOT_KEYS = {
@@ -100,22 +99,14 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.")
else:
# Clear existing keys if needed
if security_config.admin_key:
logging.info("Clearing existing admin keys...")
del security_config.admin_key[:]
node.writeConfig("security")
time.sleep(2) # Give time for device to process
# Append new keys
for key in valid_keys:
logging.info(f"Adding admin key: {key}")
security_config.admin_key.append(key)
node.writeConfig("security")
logging.info("Admin keys updated successfully!")
# Update the repeated field in memory, then send one complete
# security config. Sending an empty list first can revoke the key
# authorizing this remote-admin session before a second write can
# restore the remaining keys.
del security_config.admin_key[:]
security_config.admin_key.extend(valid_keys)
node.writeConfig("security")
logging.info("Admin keys updated successfully (%d key(s)).", len(valid_keys))
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
@@ -161,7 +152,8 @@ def save_changes(interface, modified_settings, menu_state, node=None):
config_category = "Channels"
try:
channel = menu_state.menu_path[-1]
channels_index = menu_state.menu_path.index("Channels")
channel = menu_state.menu_path[channels_index + 1]
channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e:
channel_num = None
@@ -170,8 +162,10 @@ def save_changes(interface, modified_settings, menu_state, node=None):
for key, value in modified_settings.items():
if key == "psk": # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == "position_precision": # Special case: module_settings
channel.settings.module_settings.position_precision = value
elif key in {"position_precision", "is_muted"}:
# These fields belong to ChannelSettings.module_settings,
# not ChannelSettings itself.
setattr(channel.settings.module_settings, key, value)
else:
setattr(channel.settings, key, value) # Use setattr for other fields
+32 -3
View File
@@ -1,6 +1,8 @@
import datetime
import logging
import re
import time
from functools import lru_cache
from typing import Optional, Union
from google.protobuf.message import DecodeError
@@ -10,14 +12,41 @@ import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
import contact.utilities.telemetry_beautifier as tb
NEW_MODEM_PRESET_NAMES = {
14: "TINY_FAST",
15: "TINY_SLOW",
16: "MEDIUM_TURBO",
}
@lru_cache(maxsize=None)
def _get_modem_preset_name(modem_preset_enum: int) -> str:
try:
return config_pb2.Config.LoRaConfig.ModemPreset.Name(modem_preset_enum)
except ValueError:
compatibility_name = NEW_MODEM_PRESET_NAMES.get(modem_preset_enum)
if compatibility_name is not None:
logging.warning(
"Modem preset %s is newer than the installed Meshtastic protobuf schema; "
"using compatibility name %s",
modem_preset_enum,
compatibility_name,
)
return compatibility_name
logging.warning(
"Modem preset %s is not recognized by the installed Meshtastic protobuf schema",
modem_preset_enum,
)
return f"UNKNOWN_PRESET_{modem_preset_enum}"
def _get_channel_name(device_channel, node):
if device_channel.settings.name:
return device_channel.settings.name
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_enum = node.localConfig.lora.modem_preset
modem_preset_string = _get_modem_preset_name(modem_preset_enum)
return convert_to_camel_case(modem_preset_string)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.6.3"
version = "1.6.7"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+11 -10
View File
@@ -40,18 +40,19 @@ class BotHandlerTests(unittest.TestCase):
packet = {"id": 900, "from": 222, "decoded": {}}
with mock.patch.object(config, "ping_bot_enabled", "True"):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
with mock.patch.object(self.bot_handler, "get_reply_context", return_value="<Re: NODE: ping> "):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
self.bot_handler.send_message.assert_called_once_with(
"Pong!", channel=0, reply_id=900
"Pong!", channel=0, reply_id=900, reply_context="<Re: NODE: ping> "
)
def test_bot_response_requires_enabled_app_setting(self) -> None:
+24 -3
View File
@@ -53,14 +53,35 @@ class ContactUiTests(unittest.TestCase):
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)
with mock.patch.object(contact_ui, "verify_remote_admin", side_effect=SystemExit(1)):
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
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.",
)
settings_menu.assert_not_called()
def test_handle_backtick_opens_local_settings_for_selected_local_node(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 2
ui_state.node_list = [123]
ui_state.selected_node = 0
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "get_list_input") as chooser:
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
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"):
contact_ui.handle_backtick(stdscr)
chooser.assert_not_called()
settings_menu.assert_called_once_with(stdscr, contact_ui.interface_state.interface)
def test_show_remote_admin_wait_draws_status(self) -> None:
stdscr = mock.Mock()
+26
View File
@@ -53,6 +53,32 @@ class ControlUiTests(unittest.TestCase):
reconnect.assert_called_once_with(stdscr, interface)
self.assertIs(result, new_interface)
def test_verify_remote_admin_establishes_an_authorized_session(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={"adminSessionPassKey": b"key"})),
)
status_callback = mock.Mock()
with mock.patch.object(control_ui, "_request_remote_with_timeout") as request:
control_ui.verify_remote_admin(node, status_callback=status_callback)
status_callback.assert_called_once_with("Checking remote-admin permission…")
request.assert_called_once_with(node.ensureSessionKey, cancel_callback=None)
node.iface._getOrCreateByNum.assert_called_once_with(123)
def test_verify_remote_admin_rejects_missing_session_key(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={})),
)
with mock.patch.object(control_ui, "_request_remote_with_timeout"):
with self.assertRaises(PermissionError):
control_ui.verify_remote_admin(node)
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
stdscr = mock.Mock()
+9 -3
View File
@@ -1,4 +1,5 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
@@ -9,13 +10,18 @@ class InterfacesTests(unittest.TestCase):
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
args = Namespace()
with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize:
ready_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=object(), nodeNum=123))
with mock.patch(
"contact.utilities.interfaces.initialize_interface", side_effect=[None, None, ready_interface]
) as initialize:
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
with mock.patch("contact.utilities.interfaces.logging.info") as info:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
self.assertEqual(result, "iface")
self.assertIs(result, ready_interface)
self.assertEqual(initialize.call_count, 3)
self.assertEqual(sleep.call_count, 2)
self.assertTrue(any("Reconnected to Meshtastic node" in call.args[0] for call in info.call_args_list))
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
args = Namespace()
+5 -2
View File
@@ -240,7 +240,7 @@ class MainRuntimeTests(unittest.TestCase):
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
with mock.patch.object(entrypoint.traceback, "print_exception") as print_exception:
with mock.patch("builtins.print") as print_mock:
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
@@ -248,6 +248,9 @@ class MainRuntimeTests(unittest.TestCase):
self.assertEqual(raised.exception.code, 1)
endwin.assert_called_once_with()
print_exc.assert_called_once_with()
exception_type, exception, exception_traceback = print_exception.call_args_list[-1].args
self.assertIs(exception_type, RuntimeError)
self.assertEqual(str(exception), "boom")
self.assertIsNotNone(exception_traceback)
print_mock.assert_any_call("Fatal error:", mock.ANY)
exit_mock.assert_called_once_with(1)
+26
View File
@@ -55,6 +55,20 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
def test_save_changes_replaces_remote_admin_keys_in_one_write(self) -> None:
interface, node = self.build_interface()
key_one = b"1" * 32
key_two = b"2" * 32
key_three = b"3" * 32
node.localConfig.security.admin_key = [key_one, key_two, key_three]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"admin_key": [key_one, key_two]}, menu_state, node=node)
self.assertTrue(reconnect_required)
self.assertEqual(node.localConfig.security.admin_key, [key_one, key_two])
node.writeConfig.assert_called_once_with("security")
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
@@ -95,6 +109,18 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
self.assertTrue(node.moduleConfig.mqtt.enabled)
def test_save_changes_writes_channel_is_muted_to_module_settings(self) -> None:
interface, node = self.build_interface()
channel = SimpleNamespace(settings=SimpleNamespace(module_settings=SimpleNamespace(is_muted=False)), role=None)
node.channels = [channel]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Channels", "Channel 1", "module_settings"])
reconnect_required = save_changes(interface, {"is_muted": True}, menu_state)
self.assertFalse(reconnect_required)
self.assertTrue(channel.settings.module_settings.is_muted)
node.writeChannel.assert_called_once_with(0)
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
+12
View File
@@ -1,4 +1,5 @@
import unittest
from tempfile import NamedTemporaryFile
from unittest import mock
import contact.ui.default_config # Initialize config before the color helpers.
@@ -13,6 +14,17 @@ class UserConfigTests(unittest.TestCase):
self.assertEqual(result, "True")
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
def test_load_log_tail_returns_only_requested_recent_lines(self) -> None:
with NamedTemporaryFile("w") as log_file:
log_file.write("".join(f"line {index}\n" for index in range(600)))
log_file.flush()
lines = user_config.load_log_tail(log_file.name, max_lines=500)
self.assertEqual(len(lines), 500)
self.assertEqual(lines[0], "line 100")
self.assertEqual(lines[-1], "line 599")
if __name__ == "__main__":
unittest.main()
+50 -1
View File
@@ -1,10 +1,19 @@
import unittest
from unittest import mock
from meshtastic.protobuf import config_pb2
import contact.ui.default_config as config
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
from contact.utilities.utils import (
_get_channel_name,
_get_modem_preset_name,
add_new_message,
get_channels,
get_node_list,
parse_protobuf,
)
from tests.test_support import reset_singletons, restore_config, snapshot_config
@@ -12,6 +21,7 @@ from tests.test_support import reset_singletons, restore_config, snapshot_config
class UtilsTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
_get_modem_preset_name.cache_clear()
self.saved_config = snapshot_config("node_sort")
def tearDown(self) -> None:
@@ -60,6 +70,45 @@ class UtilsTests(unittest.TestCase):
self.assertIn("MediumFast", ui_state.all_messages)
self.assertIn("Another Channel", ui_state.all_messages)
def test_get_channel_name_handles_newer_known_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
warning.assert_called_once()
def test_get_channel_name_handles_unknown_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 99
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "UnknownPreset99")
warning.assert_called_once()
def test_get_channel_name_prefers_public_protobuf_enum_over_compatibility_map(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch.object(
config_pb2.Config.LoRaConfig.ModemPreset,
"Name",
return_value="TINY_FAST",
) as enum_name:
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
enum_name.assert_called_once_with(14)
warning.assert_not_called()
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
interface = build_demo_interface()
interface.localNode.channels[0].settings.name = "Renamed Channel"