mirror of
https://github.com/pdxlocations/contact.git
synced 2026-11-01 08:35:46 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dae6a43652 | ||
|
|
22e5e89828 | ||
|
|
c2db76d5ec | ||
|
|
d8dc4876b6 | ||
|
|
0ae644e3fb | ||
|
|
bfd20bfa49 | ||
|
|
fa2dbccf44 | ||
|
|
66256f0a4c | ||
|
|
e570ce4e48 | ||
|
|
d0d9c1dd55 | ||
|
|
043418bfde | ||
|
|
a8a1527a84 | ||
|
|
7d2092e25f | ||
|
|
ac23c2bbbb | ||
|
|
e378aba526 | ||
|
|
ec35cbd3e7 | ||
|
|
7f6fa49385 |
No files matched your search
+7
-1
@@ -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()
|
||||
|
||||
@@ -99,6 +99,7 @@ user, "User"
|
||||
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
|
||||
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
|
||||
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
|
||||
isUnmessagable, "Disable incoming messages", "When enabled, this node advertises that it cannot receive messages."
|
||||
|
||||
[app_settings]
|
||||
title, "App Settings", ""
|
||||
@@ -113,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", ""
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+245
-11
@@ -1,5 +1,7 @@
|
||||
import curses
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
import time
|
||||
import traceback
|
||||
from numbers import Real
|
||||
@@ -14,11 +16,14 @@ from contact.utilities.utils import (
|
||||
build_reply_prefix,
|
||||
)
|
||||
from contact.settings import settings_menu
|
||||
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
|
||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import interface_is_connected
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.emoji_utils import normalize_message_text
|
||||
import contact.ui.default_config as config
|
||||
@@ -40,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,
|
||||
@@ -383,6 +497,62 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
interface = interface_state.interface
|
||||
if not interface_is_connected(interface) and not ui_state.reconnect_attempted:
|
||||
ui_state.reconnect_attempted = True
|
||||
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
|
||||
|
||||
reconnect_interface_with_splash(stdscr, interface)
|
||||
if status_win is not None:
|
||||
status_win.erase()
|
||||
status_win.refresh()
|
||||
ui_state.reconnect_attempted = 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()
|
||||
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",
|
||||
["Retry", "Cancel"],
|
||||
mandatory=True,
|
||||
)
|
||||
if retry == "Retry":
|
||||
ui_state.reconnect_attempted = False
|
||||
handle_resize(stdscr, False)
|
||||
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 ''}"
|
||||
@@ -652,12 +822,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
|
||||
@@ -959,17 +1144,49 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
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:
|
||||
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
|
||||
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=interface.getNode(remote_node_num, False),
|
||||
node=remote_node,
|
||||
remote=True,
|
||||
status_callback=lambda message: update_remote_admin_wait(wait_win, message),
|
||||
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:
|
||||
@@ -993,7 +1210,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:
|
||||
@@ -1012,7 +1229,24 @@ 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:
|
||||
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()
|
||||
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: Optional[curses.window], message: str) -> None:
|
||||
"""Append the current remote-admin request to the waiting overlay."""
|
||||
if wait_win is None:
|
||||
return
|
||||
|
||||
+110
-32
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
from meshtastic.protobuf import admin_pb2
|
||||
|
||||
@@ -26,7 +27,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
|
||||
@@ -41,6 +41,10 @@ help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
|
||||
class RemoteAdminCancelled(Exception):
|
||||
"""Raised when the user dismisses an in-progress remote-admin request."""
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
def get_menu_width() -> int:
|
||||
# Leave at least 2 columns for borders; clamp to >= 20 for usability
|
||||
@@ -236,9 +240,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)
|
||||
@@ -278,12 +279,13 @@ 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)
|
||||
|
||||
|
||||
def _request_remote_with_timeout(request, *args) -> None:
|
||||
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
|
||||
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
|
||||
failure = []
|
||||
|
||||
@@ -295,15 +297,86 @@ def _request_remote_with_timeout(request, *args) -> None:
|
||||
|
||||
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
|
||||
worker.start()
|
||||
worker.join(REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS)
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while worker.is_alive() and time.monotonic() < deadline:
|
||||
worker.join(0.1)
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if worker.is_alive():
|
||||
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
|
||||
if failure:
|
||||
raise failure[0]
|
||||
|
||||
|
||||
def _request_remote_channels_with_timeout(node: object, cancel_callback=None) -> None:
|
||||
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
|
||||
node.requestChannels()
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while getattr(node, "channels", None) is None:
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for the remote node's channel list.")
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def verify_remote_admin(node: object, status_callback=None, cancel_callback=None) -> None:
|
||||
"""Confirm a remote node accepts admin requests before opening its menu.
|
||||
|
||||
A single device-config request provides an authorization check without
|
||||
eagerly downloading every radio and module setting. The successful
|
||||
response also seeds the first radio section the user may open.
|
||||
"""
|
||||
device_field = node.localConfig.DESCRIPTOR.fields_by_name.get("device")
|
||||
if device_field is None:
|
||||
raise RuntimeError("The remote node does not expose a device configuration section.")
|
||||
if status_callback:
|
||||
status_callback("Checking remote-admin permission…")
|
||||
_request_remote_with_timeout(node.requestConfig, device_field, cancel_callback=cancel_callback)
|
||||
|
||||
|
||||
def _request_remote_section(
|
||||
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
|
||||
) -> bool:
|
||||
"""Fetch exactly the remote-admin section the user is opening.
|
||||
|
||||
Meshtastic returns radio and module settings one protobuf section at a
|
||||
time. Asking for all of them on menu entry is both slow and unreliable on
|
||||
long paths, so leave the top-level menu immediately available.
|
||||
"""
|
||||
if menu_path == ["Main Menu"] and selected_option == "Channels":
|
||||
if status_callback:
|
||||
status_callback("Requesting channel list…")
|
||||
_request_remote_channels_with_timeout(node, cancel_callback=cancel_callback)
|
||||
return True
|
||||
|
||||
if len(menu_path) != 2:
|
||||
return False
|
||||
|
||||
category = menu_path[-1]
|
||||
if category == "Radio Settings":
|
||||
config = node.localConfig
|
||||
elif category == "Module Settings":
|
||||
config = node.moduleConfig
|
||||
else:
|
||||
return False
|
||||
|
||||
field = config.DESCRIPTOR.fields_by_name.get(selected_option)
|
||||
if field is None or field.name in {"version", "sessionkey"}:
|
||||
return False
|
||||
if status_callback:
|
||||
status_callback(f"Requesting {selected_option} config…")
|
||||
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
|
||||
return True
|
||||
|
||||
|
||||
def settings_menu(
|
||||
stdscr: object, interface: object, node: object = None, remote: bool = False, status_callback=None
|
||||
stdscr: object,
|
||||
interface: object,
|
||||
node: object = None,
|
||||
remote: bool = False,
|
||||
status_callback=None,
|
||||
cancel_callback=None,
|
||||
) -> None:
|
||||
curses.update_lines_cols()
|
||||
global admin_target_label
|
||||
@@ -313,24 +386,6 @@ def settings_menu(
|
||||
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 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):
|
||||
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…")
|
||||
node.requestChannels()
|
||||
if status_callback:
|
||||
status_callback("Waiting for channel list…")
|
||||
_request_remote_with_timeout(node.waitForConfig, "channels")
|
||||
|
||||
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"]
|
||||
@@ -473,7 +528,7 @@ def settings_menu(
|
||||
filename += ".yaml"
|
||||
|
||||
try:
|
||||
config_text = config_export(interface)
|
||||
config_text = config_export(interface, node=node)
|
||||
yaml_file_path = os.path.join(config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
@@ -538,7 +593,7 @@ def settings_menu(
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
config_import(interface, file_path, node=node)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
@@ -641,12 +696,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)
|
||||
@@ -678,7 +735,7 @@ def settings_menu(
|
||||
menu_state.need_redraw = True
|
||||
continue
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||
if selected_option in ["longName", "shortName", "isLicensed", "isUnmessagable"]:
|
||||
if selected_option in ["longName", "shortName"]:
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
@@ -686,7 +743,7 @@ def settings_menu(
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == "isLicensed":
|
||||
elif selected_option in ["isLicensed", "isUnmessagable"]:
|
||||
new_value = get_list_input(
|
||||
f"{human_readable_name} is currently: {current_value}",
|
||||
str(current_value),
|
||||
@@ -786,7 +843,28 @@ def settings_menu(
|
||||
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
requested_remote_section = False
|
||||
if remote:
|
||||
try:
|
||||
requested_remote_section = _request_remote_section(
|
||||
node, menu_state.menu_path, selected_option, status_callback, cancel_callback
|
||||
)
|
||||
finally:
|
||||
if status_callback:
|
||||
status_callback(None)
|
||||
if requested_remote_section:
|
||||
# The response updates the node's protobufs. Rebuild
|
||||
# the menu before entering the requested section so
|
||||
# the newly received values are what the user sees.
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=False)
|
||||
refreshed_menu = menu["Main Menu"]
|
||||
for path_step in menu_state.menu_path[1:]:
|
||||
refreshed_menu = refreshed_menu[path_step]
|
||||
menu_state.current_menu = refreshed_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
menu_state.menu_path.append(selected_option)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
menu_state.selected_index = 0
|
||||
|
||||
@@ -76,6 +76,7 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
|
||||
"longName": (None, current_user_config.get("longName", "Not Set")),
|
||||
"shortName": (None, current_user_config.get("shortName", "Not Set")),
|
||||
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
|
||||
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
|
||||
}
|
||||
else:
|
||||
logging.info("User settings not found in Node Info")
|
||||
|
||||
@@ -39,6 +39,14 @@ class ChatUIState:
|
||||
redraw_full_ui: bool = False
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def config_import(interface, filename):
|
||||
def config_import(interface, filename, node=None):
|
||||
"""Apply a configuration file to the selected local or remote node."""
|
||||
node = node or interface.localNode
|
||||
with open(filename, encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
|
||||
interface.getNode("^local", False).beginSettingsTransaction()
|
||||
node.beginSettingsTransaction()
|
||||
|
||||
if "owner" in configuration:
|
||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(configuration["owner"])
|
||||
node.setOwner(configuration["owner"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "owner_short" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
node.setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "ownerShort" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channel_url" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||
interface.getNode("^local").setURL(configuration["channel_url"])
|
||||
node.setURL(configuration["channel_url"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channelUrl" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||
interface.getNode("^local").setURL(configuration["channelUrl"])
|
||||
node.setURL(configuration["channelUrl"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "location" in configuration:
|
||||
alt = 0
|
||||
lat = 0.0
|
||||
lon = 0.0
|
||||
localConfig = interface.localNode.localConfig
|
||||
|
||||
if "alt" in configuration["location"]:
|
||||
alt = int(configuration["location"]["alt"] or 0)
|
||||
logging.info(f"Fixing altitude at {alt} meters")
|
||||
@@ -185,39 +185,44 @@ def config_import(interface, filename):
|
||||
lon = float(configuration["location"]["lon"] or 0)
|
||||
logging.info(f"Fixing longitude at {lon} degrees")
|
||||
logging.info("Setting device position")
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
node.setFixedPosition(lat, lon, alt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "config" in configuration:
|
||||
localConfig = interface.getNode("^local").localConfig
|
||||
localConfig = node.localConfig
|
||||
for section in configuration["config"]:
|
||||
traverseConfig(section, configuration["config"][section], localConfig)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
if "module_config" in configuration:
|
||||
moduleConfig = interface.getNode("^local").moduleConfig
|
||||
moduleConfig = node.moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
interface.getNode("^local", False).commitSettingsTransaction()
|
||||
node.commitSettingsTransaction()
|
||||
logging.info("Writing modified configuration to device")
|
||||
|
||||
|
||||
def config_export(interface) -> str:
|
||||
"""used in --export-config"""
|
||||
def config_export(interface, node=None) -> str:
|
||||
"""Export the selected node's configuration as Meshtastic configure YAML."""
|
||||
configObj = {}
|
||||
node = node or interface.localNode
|
||||
|
||||
owner = interface.getLongName()
|
||||
owner_short = interface.getShortName()
|
||||
channel_url = interface.localNode.getURL()
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
if node is interface.localNode:
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
else:
|
||||
myinfo = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None), {})
|
||||
user = myinfo.get("user", {}) if isinstance(myinfo, dict) else {}
|
||||
owner = user.get("longName")
|
||||
owner_short = user.get("shortName")
|
||||
channel_url = node.getURL()
|
||||
pos = myinfo.get("position")
|
||||
lat = None
|
||||
lon = None
|
||||
@@ -242,7 +247,7 @@ def config_export(interface) -> str:
|
||||
if alt:
|
||||
configObj["location"]["alt"] = alt
|
||||
|
||||
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
if config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
@@ -265,7 +270,7 @@ def config_export(interface) -> str:
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
module_config = MessageToDict(interface.localNode.moduleConfig)
|
||||
module_config = MessageToDict(node.moduleConfig)
|
||||
if module_config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
|
||||
@@ -3,6 +3,28 @@ import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
|
||||
def interface_is_connected(interface) -> bool:
|
||||
"""Return the transport-independent connection state for an interface.
|
||||
|
||||
TCPInterface deliberately leaves ``stream`` set to ``None`` and uses its
|
||||
``socket`` instead. Checking only ``stream`` therefore treats every TCP
|
||||
connection (including meshtasticd) as disconnected.
|
||||
"""
|
||||
if interface is None:
|
||||
return False
|
||||
|
||||
connected = getattr(interface, "isConnected", None)
|
||||
if hasattr(connected, "is_set"):
|
||||
return connected.is_set()
|
||||
if isinstance(connected, bool):
|
||||
return connected
|
||||
if hasattr(interface, "socket"):
|
||||
return getattr(interface, "socket", None) is not None
|
||||
if hasattr(interface, "stream"):
|
||||
return getattr(interface, "stream", None) is not None
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
try:
|
||||
|
||||
@@ -44,19 +66,34 @@ 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
|
||||
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 not None:
|
||||
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("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
|
||||
|
||||
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
|
||||
@@ -29,7 +29,7 @@ LORA_REBOOT_KEYS = {
|
||||
"sx126x_rx_boosted_gain",
|
||||
}
|
||||
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
|
||||
|
||||
|
||||
def _collect_changed_keys(modified_settings):
|
||||
@@ -143,12 +143,16 @@ def save_changes(interface, modified_settings, menu_state, node=None):
|
||||
long_name = modified_settings.get("longName")
|
||||
short_name = modified_settings.get("shortName")
|
||||
is_licensed = modified_settings.get("isLicensed")
|
||||
is_unmessagable = modified_settings.get("isUnmessagable")
|
||||
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
|
||||
if is_unmessagable is not None:
|
||||
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
node.setOwner(long_name, short_name, is_licensed, is_unmessagable)
|
||||
|
||||
logging.info(
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
|
||||
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
|
||||
)
|
||||
|
||||
return _requires_reconnect(menu_state, modified_settings)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.6.2"
|
||||
version = "1.6.5"
|
||||
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"}
|
||||
|
||||
+46
-1
@@ -1,6 +1,12 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import Mock
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
|
||||
import yaml
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, config_export, config_import, splitCompoundName
|
||||
|
||||
|
||||
class ConfigIoTests(unittest.TestCase):
|
||||
@@ -19,3 +25,42 @@ class ConfigIoTests(unittest.TestCase):
|
||||
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
|
||||
|
||||
self.assertTrue(_is_repeated_field(field_type()))
|
||||
|
||||
def test_config_export_uses_selected_remote_node(self) -> None:
|
||||
local_node = SimpleNamespace(nodeNum=1)
|
||||
remote_node = SimpleNamespace(
|
||||
nodeNum=2,
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getURL=lambda: "remote-channel-url",
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
nodesByNum={
|
||||
2: {
|
||||
"user": {"longName": "Remote Node", "shortName": "REM"},
|
||||
"position": {"latitude": 1.25, "longitude": 2.5, "altitude": 12},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
exported = yaml.safe_load(config_export(interface, node=remote_node))
|
||||
|
||||
self.assertEqual(exported["owner"], "Remote Node")
|
||||
self.assertEqual(exported["owner_short"], "REM")
|
||||
self.assertEqual(exported["channel_url"], "remote-channel-url")
|
||||
self.assertEqual(exported["location"], {"lat": 1.25, "lon": 2.5, "alt": 12})
|
||||
|
||||
def test_config_import_uses_selected_remote_node(self) -> None:
|
||||
remote_node = Mock()
|
||||
interface = SimpleNamespace(localNode=Mock())
|
||||
with NamedTemporaryFile("w", suffix=".yaml") as config_file:
|
||||
yaml.safe_dump({"owner": "Remote Node"}, config_file)
|
||||
config_file.flush()
|
||||
|
||||
config_import(interface, config_file.name, node=remote_node)
|
||||
|
||||
remote_node.beginSettingsTransaction.assert_called_once_with()
|
||||
remote_node.setOwner.assert_called_once_with("Remote Node")
|
||||
remote_node.commitSettingsTransaction.assert_called_once_with()
|
||||
interface.localNode.setOwner.assert_not_called()
|
||||
@@ -53,14 +53,16 @@ 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_show_remote_admin_wait_draws_status(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
|
||||
@@ -53,6 +53,20 @@ class ControlUiTests(unittest.TestCase):
|
||||
reconnect.assert_called_once_with(stdscr, interface)
|
||||
self.assertIs(result, new_interface)
|
||||
|
||||
def test_verify_remote_admin_requests_only_device_config(self) -> None:
|
||||
device_field = object()
|
||||
node = SimpleNamespace(
|
||||
localConfig=SimpleNamespace(DESCRIPTOR=SimpleNamespace(fields_by_name={"device": device_field})),
|
||||
requestConfig=mock.Mock(),
|
||||
)
|
||||
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.requestConfig, device_field, cancel_callback=None)
|
||||
|
||||
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -44,3 +44,20 @@ class MenusTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
|
||||
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
|
||||
|
||||
def test_user_settings_include_unmessageable_toggle(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {
|
||||
"user": {"longName": "Test User", "shortName": "TU", "isUnmessagable": True},
|
||||
"position": {},
|
||||
},
|
||||
)
|
||||
|
||||
user_settings = generate_menu_from_protobuf(interface)["Main Menu"]["User Settings"]
|
||||
self.assertEqual(user_settings["isUnmessagable"], (None, True))
|
||||
@@ -112,3 +112,12 @@ class SaveToRadioTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
node.setOwner.assert_called_once()
|
||||
|
||||
def test_save_changes_sends_user_unmessageable_setting(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"isUnmessagable": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertEqual(node.setOwner.call_args.args[3], True)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user