mirror of
https://github.com/pdxlocations/contact.git
synced 2026-05-14 21:15:38 +02:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f376edabe | |||
| e5ef87ec19 | |||
| 1b6d269d50 | |||
| 1d95dae536 | |||
| 705b25192c | |||
| 6c5ae3b168 | |||
| 02b4866a38 | |||
| 286b5a531b | |||
| 004868c7fc | |||
| b43e3f4868 | |||
| 2e8e21f5ba | |||
| 09f0d626df | |||
| 8c37f2394b | |||
| 568f29ee29 | |||
| 87127adef1 | |||
| dd0eb1473c | |||
| 7d9703a548 | |||
| 68b8d15181 | |||
| 4ef87871df |
@@ -16,8 +16,7 @@ pip install contact
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />
|
||||||
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
|
|
||||||
|
|
||||||
<br><br>
|
<br><br>
|
||||||
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
|
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
|
||||||
|
|||||||
+68
-11
@@ -19,6 +19,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
# Third-party
|
# Third-party
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
@@ -32,10 +33,11 @@ from contact.ui.contact_ui import main_ui
|
|||||||
from contact.ui.splash import draw_splash
|
from contact.ui.splash import draw_splash
|
||||||
from contact.utilities.arg_parser import setup_parser
|
from contact.utilities.arg_parser import setup_parser
|
||||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||||
|
from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages
|
||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
from contact.ui.dialog import dialog
|
from contact.ui.dialog import dialog
|
||||||
from contact.utilities.interfaces import initialize_interface
|
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||||
|
|
||||||
@@ -59,18 +61,62 @@ app_state.lock = threading.Lock()
|
|||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Main Program Logic
|
# Main Program Logic
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
def prompt_region_if_unset(args: object) -> None:
|
def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> None:
|
||||||
"""Prompt user to set region if it is unset."""
|
"""Prompt user to set region if it is unset."""
|
||||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||||
if confirmation == "Yes":
|
if confirmation == "Yes":
|
||||||
set_region(interface_state.interface)
|
set_region(interface_state.interface)
|
||||||
interface_state.interface.close()
|
close_interface(interface_state.interface)
|
||||||
interface_state.interface = initialize_interface(args)
|
if stdscr is not None:
|
||||||
|
draw_splash(stdscr)
|
||||||
|
interface_state.interface = reconnect_interface(args)
|
||||||
|
|
||||||
|
|
||||||
def initialize_globals() -> None:
|
def close_interface(interface: object) -> None:
|
||||||
|
if interface is None:
|
||||||
|
return
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
interface.close()
|
||||||
|
|
||||||
|
|
||||||
|
def interface_is_ready(interface: object) -> bool:
|
||||||
|
try:
|
||||||
|
return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||||
|
while True:
|
||||||
|
interface = initialize_runtime_interface(args)
|
||||||
|
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||||
|
return interface
|
||||||
|
|
||||||
|
choice = get_list_input(
|
||||||
|
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
|
||||||
|
"Retry",
|
||||||
|
["Retry", "Close"],
|
||||||
|
mandatory=True,
|
||||||
|
)
|
||||||
|
close_interface(interface)
|
||||||
|
if choice == "Close":
|
||||||
|
return None
|
||||||
|
|
||||||
|
draw_splash(stdscr)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_globals(seed_demo: bool = False) -> None:
|
||||||
"""Initializes interface and shared globals."""
|
"""Initializes interface and shared globals."""
|
||||||
|
|
||||||
|
ui_state.channel_list = []
|
||||||
|
ui_state.all_messages = {}
|
||||||
|
ui_state.notifications = []
|
||||||
|
ui_state.packet_buffer = []
|
||||||
|
ui_state.node_list = []
|
||||||
|
ui_state.selected_channel = 0
|
||||||
|
ui_state.selected_message = 0
|
||||||
|
ui_state.selected_node = 0
|
||||||
|
ui_state.start_index = [0, 0, 0]
|
||||||
interface_state.myNodeNum = get_nodeNum()
|
interface_state.myNodeNum = get_nodeNum()
|
||||||
ui_state.channel_list = get_channels()
|
ui_state.channel_list = get_channels()
|
||||||
ui_state.node_list = get_node_list()
|
ui_state.node_list = get_node_list()
|
||||||
@@ -78,9 +124,18 @@ def initialize_globals() -> None:
|
|||||||
pub.subscribe(on_receive, "meshtastic.receive")
|
pub.subscribe(on_receive, "meshtastic.receive")
|
||||||
|
|
||||||
init_nodedb()
|
init_nodedb()
|
||||||
|
if seed_demo:
|
||||||
|
seed_demo_messages()
|
||||||
load_messages_from_db()
|
load_messages_from_db()
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_runtime_interface(args: object):
|
||||||
|
if getattr(args, "demo_screenshot", False):
|
||||||
|
configure_demo_database()
|
||||||
|
return build_demo_interface()
|
||||||
|
return initialize_interface(args)
|
||||||
|
|
||||||
|
|
||||||
def main(stdscr: curses.window) -> None:
|
def main(stdscr: curses.window) -> None:
|
||||||
"""Main entry point for the curses UI."""
|
"""Main entry point for the curses UI."""
|
||||||
|
|
||||||
@@ -98,12 +153,14 @@ def main(stdscr: curses.window) -> None:
|
|||||||
|
|
||||||
logging.info("Initializing interface...")
|
logging.info("Initializing interface...")
|
||||||
with app_state.lock:
|
with app_state.lock:
|
||||||
interface_state.interface = initialize_interface(args)
|
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
|
||||||
|
if interface_state.interface is None:
|
||||||
|
return
|
||||||
|
|
||||||
if interface_state.interface.localNode.localConfig.lora.region == 0:
|
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||||
prompt_region_if_unset(args)
|
prompt_region_if_unset(args, stdscr)
|
||||||
|
|
||||||
initialize_globals()
|
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
|
||||||
logging.info("Starting main UI")
|
logging.info("Starting main UI")
|
||||||
|
|
||||||
stdscr.clear()
|
stdscr.clear()
|
||||||
@@ -150,10 +207,10 @@ def start() -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
curses.wrapper(main)
|
curses.wrapper(main)
|
||||||
interface_state.interface.close()
|
close_interface(interface_state.interface)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logging.info("User exited with Ctrl+C")
|
logging.info("User exited with Ctrl+C")
|
||||||
interface_state.interface.close()
|
close_interface(interface_state.interface)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.critical("Fatal error", exc_info=True)
|
logging.critical("Fatal error", exc_info=True)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ help.node_info, "F5 = Full node info", ""
|
|||||||
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
||||||
help.favorite, "Ctrl+F = Favorite", ""
|
help.favorite, "Ctrl+F = Favorite", ""
|
||||||
help.ignore, "Ctrl+G = Ignore", ""
|
help.ignore, "Ctrl+G = Ignore", ""
|
||||||
help.search, "Ctrl+/ = Search", ""
|
help.search, "Ctrl+/ or / = Search", ""
|
||||||
help.help, "Ctrl+K = Help", ""
|
help.help, "Ctrl+K = Help", ""
|
||||||
help.no_help, "No help available.", ""
|
help.no_help, "No help available.", ""
|
||||||
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
|
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ help.node_info, "F5 = Полная информация об узле", ""
|
|||||||
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
||||||
help.favorite, "Ctrl+F = Избранное", ""
|
help.favorite, "Ctrl+F = Избранное", ""
|
||||||
help.ignore, "Ctrl+G = Игнорировать", ""
|
help.ignore, "Ctrl+G = Игнорировать", ""
|
||||||
help.search, "Ctrl+/ = Поиск", ""
|
help.search, "Ctrl+/ или / = Поиск", ""
|
||||||
help.help, "Ctrl+K = Справка", ""
|
help.help, "Ctrl+K = Справка", ""
|
||||||
help.no_help, "Нет справки.", ""
|
help.no_help, "Нет справки.", ""
|
||||||
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
|
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
|
||||||
|
|||||||
+3
-2
@@ -14,7 +14,7 @@ from contact.ui.colors import setup_colors
|
|||||||
from contact.ui.splash import draw_splash
|
from contact.ui.splash import draw_splash
|
||||||
from contact.ui.control_ui import set_region, settings_menu
|
from contact.ui.control_ui import set_region, settings_menu
|
||||||
from contact.utilities.arg_parser import setup_parser
|
from contact.utilities.arg_parser import setup_parser
|
||||||
from contact.utilities.interfaces import initialize_interface
|
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||||
|
|
||||||
|
|
||||||
def main(stdscr: curses.window) -> None:
|
def main(stdscr: curses.window) -> None:
|
||||||
@@ -40,7 +40,8 @@ def main(stdscr: curses.window) -> None:
|
|||||||
if confirmation == "Yes":
|
if confirmation == "Yes":
|
||||||
set_region(interface)
|
set_region(interface)
|
||||||
interface.close()
|
interface.close()
|
||||||
interface = initialize_interface(args)
|
draw_splash(stdscr)
|
||||||
|
interface = reconnect_interface(args)
|
||||||
stdscr.clear()
|
stdscr.clear()
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
settings_menu(stdscr, interface)
|
settings_menu(stdscr, interface)
|
||||||
|
|||||||
+236
-108
@@ -12,15 +12,17 @@ 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
|
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
|
from contact.utilities.emoji_utils import normalize_message_text
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
import contact.ui.dialog
|
import contact.ui.dialog
|
||||||
from contact.ui.nav_utils import draw_main_arrows, fit_text, get_msg_window_lines, move_main_highlight, wrap_text
|
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
||||||
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
||||||
|
|
||||||
|
|
||||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||||
RESIZE_DEBOUNCE_MS = 250
|
RESIZE_DEBOUNCE_MS = 250
|
||||||
root_win = None
|
root_win = None
|
||||||
|
nodes_pad = None
|
||||||
|
|
||||||
|
|
||||||
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
||||||
@@ -63,6 +65,72 @@ def paint_frame(win, selected: bool) -> None:
|
|||||||
win.refresh()
|
win.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_row_color(index: int) -> int:
|
||||||
|
if index == ui_state.selected_channel:
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
return get_color("channel_list", reverse=True)
|
||||||
|
return get_color("channel_selected")
|
||||||
|
return get_color("channel_list")
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||||
|
node_num = ui_state.node_list[index]
|
||||||
|
node = interface_state.interface.nodesByNum.get(node_num, {})
|
||||||
|
color = "node_list"
|
||||||
|
if node.get("isFavorite"):
|
||||||
|
color = "node_favorite"
|
||||||
|
if node.get("isIgnored"):
|
||||||
|
color = "node_ignored"
|
||||||
|
reverse = index == ui_state.selected_node and (ui_state.current_window == 2 or highlight)
|
||||||
|
return get_color(color, reverse=reverse)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||||
|
if nodes_pad is None or not ui_state.node_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||||
|
|
||||||
|
if 0 <= old_index < len(ui_state.node_list):
|
||||||
|
try:
|
||||||
|
nodes_pad.chgat(old_index, 1, width, get_node_row_color(old_index, highlight=highlight))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 0 <= ui_state.selected_node < len(ui_state.node_list):
|
||||||
|
try:
|
||||||
|
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node, highlight=highlight))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||||
|
refresh_pad(2)
|
||||||
|
draw_window_arrows(2)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||||
|
if window_id == 0:
|
||||||
|
paint_frame(channel_win, selected=selected)
|
||||||
|
if ui_state.channel_list:
|
||||||
|
width = max(0, channel_pad.getmaxyx()[1] - 4)
|
||||||
|
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
|
||||||
|
refresh_pad(0)
|
||||||
|
elif window_id == 1:
|
||||||
|
paint_frame(messages_win, selected=selected)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif window_id == 2:
|
||||||
|
paint_frame(nodes_win, selected=selected)
|
||||||
|
if ui_state.node_list and nodes_pad is not None:
|
||||||
|
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||||
|
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_display_name(node_num: int, node: dict) -> str:
|
||||||
|
user = node.get("user") or {}
|
||||||
|
return user.get("longName") or get_name_from_database(node_num, "long")
|
||||||
|
|
||||||
|
|
||||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||||
"""Handle terminal resize events and redraw the UI accordingly."""
|
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||||
@@ -255,7 +323,9 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
elif char == chr(4): # Ctrl + D to delete current channel or node
|
elif char == chr(4): # Ctrl + D to delete current channel or node
|
||||||
handle_ctrl_d()
|
handle_ctrl_d()
|
||||||
|
|
||||||
elif char == chr(31): # Ctrl + / to search
|
elif char == chr(31) or (
|
||||||
|
char == "/" and not input_text and ui_state.current_window in (0, 2)
|
||||||
|
): # Ctrl + / or / to search in channel/node lists
|
||||||
handle_ctrl_fslash()
|
handle_ctrl_fslash()
|
||||||
|
|
||||||
elif char == chr(11): # Ctrl + K for Help
|
elif char == chr(11): # Ctrl + K for Help
|
||||||
@@ -359,31 +429,16 @@ def handle_leftright(char: int) -> None:
|
|||||||
delta = -1 if char == curses.KEY_LEFT else 1
|
delta = -1 if char == curses.KEY_LEFT else 1
|
||||||
old_window = ui_state.current_window
|
old_window = ui_state.current_window
|
||||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||||
handle_resize(root_win, False)
|
if ui_state.single_pane_mode:
|
||||||
|
handle_resize(root_win, False)
|
||||||
|
return
|
||||||
|
|
||||||
if old_window == 0:
|
refresh_main_window(old_window, selected=False)
|
||||||
paint_frame(channel_win, selected=False)
|
|
||||||
refresh_pad(0)
|
|
||||||
if old_window == 1:
|
|
||||||
paint_frame(messages_win, selected=False)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif old_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=False)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
if ui_state.current_window == 0:
|
refresh_main_window(ui_state.current_window, selected=True)
|
||||||
paint_frame(channel_win, selected=True)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
paint_frame(messages_win, selected=True)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=True)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -404,31 +459,16 @@ def handle_function_keys(char: int) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
ui_state.current_window = target
|
ui_state.current_window = target
|
||||||
handle_resize(root_win, False)
|
if ui_state.single_pane_mode:
|
||||||
|
handle_resize(root_win, False)
|
||||||
|
return
|
||||||
|
|
||||||
if old_window == 0:
|
refresh_main_window(old_window, selected=False)
|
||||||
paint_frame(channel_win, selected=False)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif old_window == 1:
|
|
||||||
paint_frame(messages_win, selected=False)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif old_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=False)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
if ui_state.current_window == 0:
|
refresh_main_window(ui_state.current_window, selected=True)
|
||||||
paint_frame(channel_win, selected=True)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
paint_frame(messages_win, selected=True)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=True)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -480,34 +520,34 @@ def handle_enter(input_text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def handle_f5_key(stdscr: curses.window) -> None:
|
def handle_f5_key(stdscr: curses.window) -> None:
|
||||||
node = None
|
if not ui_state.node_list:
|
||||||
try:
|
return
|
||||||
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
|
||||||
|
|
||||||
|
def build_node_details() -> tuple[str, list[str]]:
|
||||||
|
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||||
message_parts = []
|
message_parts = []
|
||||||
|
|
||||||
message_parts.append("**📋 Basic Information:**")
|
message_parts.append("**📋 Basic Information:**")
|
||||||
message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}")
|
message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}")
|
||||||
message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}")
|
message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}")
|
||||||
message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
|
message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
|
||||||
|
message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}")
|
||||||
role = f"{node.get('user', {}).get('role', 'Unknown')}"
|
message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}")
|
||||||
message_parts.append(f"• Role: {role}")
|
|
||||||
|
|
||||||
pk = f"{node.get('user', {}).get('publicKey')}"
|
|
||||||
message_parts.append(f"Public key: {pk}")
|
|
||||||
|
|
||||||
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
|
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
|
||||||
|
|
||||||
if "position" in node:
|
if "position" in node:
|
||||||
pos = node["position"]
|
pos = node["position"]
|
||||||
if pos.get("latitude") and pos.get("longitude"):
|
has_coords = pos.get("latitude") and pos.get("longitude")
|
||||||
|
if has_coords:
|
||||||
message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}")
|
message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}")
|
||||||
if pos.get("altitude"):
|
if pos.get("altitude"):
|
||||||
message_parts.append(f"• Altitude: {pos['altitude']}m")
|
message_parts.append(f"• Altitude: {pos['altitude']}m")
|
||||||
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
if has_coords:
|
||||||
|
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
||||||
|
|
||||||
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
|
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
|
||||||
message_parts.append("\n**🌐 Network Metrics:**")
|
message_parts.append("")
|
||||||
|
message_parts.append("**🌐 Network Metrics:**")
|
||||||
|
|
||||||
if "snr" in node:
|
if "snr" in node:
|
||||||
snr = node["snr"]
|
snr = node["snr"]
|
||||||
@@ -527,12 +567,13 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
|||||||
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
||||||
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
||||||
|
|
||||||
if "lastHeard" in node and node["lastHeard"]:
|
if node.get("lastHeard"):
|
||||||
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
||||||
|
|
||||||
if node.get("deviceMetrics"):
|
if node.get("deviceMetrics"):
|
||||||
metrics = node["deviceMetrics"]
|
metrics = node["deviceMetrics"]
|
||||||
message_parts.append("\n**📊 Device Metrics:**")
|
message_parts.append("")
|
||||||
|
message_parts.append("**📊 Device Metrics:**")
|
||||||
|
|
||||||
if "batteryLevel" in metrics:
|
if "batteryLevel" in metrics:
|
||||||
battery = metrics["batteryLevel"]
|
battery = metrics["batteryLevel"]
|
||||||
@@ -553,21 +594,128 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
|||||||
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
||||||
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
||||||
|
|
||||||
message = "\n".join(message_parts)
|
title = t(
|
||||||
|
"ui.dialog.node_details_title",
|
||||||
contact.ui.dialog.dialog(
|
default="📡 Node Details: {name}",
|
||||||
t(
|
name=node.get("user", {}).get("shortName", "Unknown"),
|
||||||
"ui.dialog.node_details_title",
|
|
||||||
default="📡 Node Details: {name}",
|
|
||||||
name=node.get("user", {}).get("shortName", "Unknown"),
|
|
||||||
),
|
|
||||||
message,
|
|
||||||
)
|
)
|
||||||
curses.curs_set(1) # Show cursor again
|
return title, message_parts
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
previous_window = ui_state.current_window
|
||||||
|
ui_state.current_window = 4
|
||||||
|
scroll_offset = 0
|
||||||
|
dialog_win = None
|
||||||
|
|
||||||
|
curses.curs_set(0)
|
||||||
|
refresh_node_selection(highlight=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
curses.update_lines_cols()
|
||||||
|
height, width = curses.LINES, curses.COLS
|
||||||
|
title, message_lines = build_node_details()
|
||||||
|
|
||||||
|
max_line_length = max(len(title), *(len(line) for line in message_lines))
|
||||||
|
dialog_width = min(max(max_line_length + 4, 20), max(10, width - 2))
|
||||||
|
dialog_height = min(max(len(message_lines) + 4, 6), max(6, height - 2))
|
||||||
|
x = max(0, (width - dialog_width) // 2)
|
||||||
|
y = max(0, (height - dialog_height) // 2)
|
||||||
|
viewport_h = max(1, dialog_height - 4)
|
||||||
|
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||||
|
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||||
|
|
||||||
|
if dialog_win is None:
|
||||||
|
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||||
|
else:
|
||||||
|
dialog_win.erase()
|
||||||
|
dialog_win.refresh()
|
||||||
|
dialog_win.resize(dialog_height, dialog_width)
|
||||||
|
dialog_win.mvwin(y, x)
|
||||||
|
|
||||||
|
dialog_win.keypad(True)
|
||||||
|
dialog_win.bkgd(get_color("background"))
|
||||||
|
dialog_win.attrset(get_color("window_frame"))
|
||||||
|
dialog_win.border(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dialog_win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
|
||||||
|
hint = f" {ui_state.selected_node + 1}/{len(ui_state.node_list)} "
|
||||||
|
dialog_win.addstr(0, max(2, dialog_width - len(hint) - 2), hint, get_color("commands"))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg_win = dialog_win.derwin(viewport_h + 2, dialog_width - 2, 1, 1)
|
||||||
|
msg_win.erase()
|
||||||
|
|
||||||
|
for row, line in enumerate(message_lines[scroll_offset : scroll_offset + viewport_h], start=1):
|
||||||
|
trimmed = line[: max(0, dialog_width - 6)]
|
||||||
|
try:
|
||||||
|
msg_win.addstr(row, 1, trimmed, get_color("settings_default"))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(message_lines) > viewport_h:
|
||||||
|
old_index = ui_state.start_index[4] if len(ui_state.start_index) > 4 else 0
|
||||||
|
while len(ui_state.start_index) <= 4:
|
||||||
|
ui_state.start_index.append(0)
|
||||||
|
ui_state.start_index[4] = scroll_offset
|
||||||
|
draw_main_arrows(msg_win, len(message_lines) - 1, window=4)
|
||||||
|
ui_state.start_index[4] = old_index
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok_text = " Up/Down: Nodes PgUp/PgDn: Scroll Esc: Close "
|
||||||
|
dialog_win.addstr(
|
||||||
|
dialog_height - 2,
|
||||||
|
max(1, (dialog_width - len(ok_text)) // 2),
|
||||||
|
ok_text[: max(0, dialog_width - 2)],
|
||||||
|
get_color("settings_default", reverse=True),
|
||||||
|
)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dialog_win.refresh()
|
||||||
|
msg_win.noutrefresh()
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
dialog_win.timeout(200)
|
||||||
|
char = dialog_win.getch()
|
||||||
|
|
||||||
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char in (27, curses.KEY_LEFT, curses.KEY_ENTER, 10, 13, 32):
|
||||||
|
break
|
||||||
|
if char == curses.KEY_UP:
|
||||||
|
old_selected_node = ui_state.selected_node
|
||||||
|
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
|
||||||
|
scroll_offset = 0
|
||||||
|
refresh_node_selection(old_selected_node, highlight=True)
|
||||||
|
elif char == curses.KEY_DOWN:
|
||||||
|
old_selected_node = ui_state.selected_node
|
||||||
|
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
|
||||||
|
scroll_offset = 0
|
||||||
|
refresh_node_selection(old_selected_node, highlight=True)
|
||||||
|
elif char == curses.KEY_PPAGE:
|
||||||
|
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||||
|
elif char == curses.KEY_NPAGE:
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + viewport_h)
|
||||||
|
elif char == curses.KEY_HOME:
|
||||||
|
scroll_offset = 0
|
||||||
|
elif char == curses.KEY_END:
|
||||||
|
scroll_offset = max_scroll
|
||||||
|
elif char == curses.KEY_RESIZE:
|
||||||
|
continue
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
if dialog_win is not None:
|
||||||
|
dialog_win.erase()
|
||||||
|
dialog_win.refresh()
|
||||||
|
ui_state.current_window = previous_window
|
||||||
|
curses.curs_set(1)
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
def handle_ctrl_t(stdscr: curses.window) -> None:
|
def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||||
@@ -624,7 +772,9 @@ def handle_backtick(stdscr: curses.window) -> None:
|
|||||||
ui_state.current_window = 4
|
ui_state.current_window = 4
|
||||||
settings_menu(stdscr, interface_state.interface)
|
settings_menu(stdscr, interface_state.interface)
|
||||||
ui_state.current_window = previous_window
|
ui_state.current_window = previous_window
|
||||||
|
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
|
get_channels()
|
||||||
refresh_node_list()
|
refresh_node_list()
|
||||||
handle_resize(stdscr, False)
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
@@ -828,7 +978,9 @@ def draw_channel_list() -> None:
|
|||||||
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
||||||
|
|
||||||
# Truncate the channel name if it's too long to fit in the window
|
# Truncate the channel name if it's too long to fit in the window
|
||||||
truncated_channel = fit_text(f"{channel}{notification}", win_width - 3, suffix="-")
|
truncated_channel = (
|
||||||
|
(channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification
|
||||||
|
).ljust(win_width - 3)
|
||||||
|
|
||||||
color = get_color("channel_list")
|
color = get_color("channel_list")
|
||||||
if idx == ui_state.selected_channel:
|
if idx == ui_state.selected_channel:
|
||||||
@@ -863,7 +1015,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
|||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
for prefix, message in messages:
|
for prefix, message in messages:
|
||||||
full_message = f"{prefix}{message}"
|
full_message = normalize_message_text(f"{prefix}{message}")
|
||||||
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
||||||
msg_line_count += len(wrapped_lines)
|
msg_line_count += len(wrapped_lines)
|
||||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||||
@@ -905,10 +1057,8 @@ def draw_node_list() -> None:
|
|||||||
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
if nodes_pad is None:
|
||||||
# if nodes_pad is None:
|
nodes_pad = curses.newpad(1, 1)
|
||||||
# nodes_pad = curses.newpad(1, 1)
|
|
||||||
nodes_pad = curses.newpad(1, 1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes_pad.erase()
|
nodes_pad.erase()
|
||||||
@@ -922,27 +1072,12 @@ def draw_node_list() -> None:
|
|||||||
node = interface_state.interface.nodesByNum[node_num]
|
node = interface_state.interface.nodesByNum[node_num]
|
||||||
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
||||||
status_icon = "🔐" if secure else "🔓"
|
status_icon = "🔐" if secure else "🔓"
|
||||||
node_name = get_name_from_database(node_num, "long")
|
node_name = get_node_display_name(node_num, node)
|
||||||
user_name = node["user"]["shortName"]
|
|
||||||
|
|
||||||
uptime_str = ""
|
|
||||||
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]:
|
|
||||||
uptime_str = f" / Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
|
|
||||||
|
|
||||||
last_heard_str = f" ■ {get_time_ago(node['lastHeard'])}" if node.get("lastHeard") else ""
|
|
||||||
hops_str = f" ■ Hops: {node['hopsAway']}" if "hopsAway" in node else ""
|
|
||||||
snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else ""
|
|
||||||
|
|
||||||
# Future node name custom formatting possible
|
# Future node name custom formatting possible
|
||||||
node_str = fit_text(f"{status_icon} {node_name}", box_width - 2)
|
node_str = f"{status_icon} {node_name}"
|
||||||
color = "node_list"
|
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
|
||||||
if "isFavorite" in node and node["isFavorite"]:
|
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
||||||
color = "node_favorite"
|
|
||||||
if "isIgnored" in node and node["isIgnored"]:
|
|
||||||
color = "node_ignored"
|
|
||||||
nodes_pad.addstr(
|
|
||||||
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||||
nodes_win.refresh()
|
nodes_win.refresh()
|
||||||
@@ -1063,16 +1198,9 @@ def draw_packetlog_win() -> None:
|
|||||||
span += column
|
span += column
|
||||||
|
|
||||||
# Add headers
|
# Add headers
|
||||||
headers = " ".join(
|
headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}"
|
||||||
[
|
|
||||||
fit_text("From", columns[0]),
|
|
||||||
fit_text("To", columns[1]),
|
|
||||||
fit_text("Port", columns[2]),
|
|
||||||
fit_text("Payload", max(1, width - span - 3)),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
packetlog_win.addstr(
|
packetlog_win.addstr(
|
||||||
1, 1, fit_text(headers, width - 2), get_color("log_header", underline=True)
|
1, 1, headers[: width - 2], get_color("log_header", underline=True)
|
||||||
) # Truncate headers if they exceed window width
|
) # Truncate headers if they exceed window width
|
||||||
|
|
||||||
for i, packet in enumerate(reversed(ui_state.packet_buffer)):
|
for i, packet in enumerate(reversed(ui_state.packet_buffer)):
|
||||||
@@ -1080,22 +1208,22 @@ def draw_packetlog_win() -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Format each field
|
# Format each field
|
||||||
from_id = fit_text(get_name_from_database(packet["from"], "short"), columns[0])
|
from_id = get_name_from_database(packet["from"], "short").ljust(columns[0])
|
||||||
to_id = (
|
to_id = (
|
||||||
fit_text("BROADCAST", columns[1])
|
"BROADCAST".ljust(columns[1])
|
||||||
if str(packet["to"]) == "4294967295"
|
if str(packet["to"]) == "4294967295"
|
||||||
else fit_text(get_name_from_database(packet["to"], "short"), columns[1])
|
else get_name_from_database(packet["to"], "short").ljust(columns[1])
|
||||||
)
|
)
|
||||||
if "decoded" in packet:
|
if "decoded" in packet:
|
||||||
port = fit_text(str(packet["decoded"].get("portnum", "")), columns[2])
|
port = str(packet["decoded"].get("portnum", "")).ljust(columns[2])
|
||||||
parsed_payload = parse_protobuf(packet)
|
parsed_payload = parse_protobuf(packet)
|
||||||
else:
|
else:
|
||||||
port = fit_text("NO KEY", columns[2])
|
port = "NO KEY".ljust(columns[2])
|
||||||
parsed_payload = "NO KEY"
|
parsed_payload = "NO KEY"
|
||||||
|
|
||||||
# Combine and truncate if necessary
|
# Combine and truncate if necessary
|
||||||
logString = f"{from_id} {to_id} {port} {parsed_payload}"
|
logString = f"{from_id} {to_id} {port} {parsed_payload}"
|
||||||
logString = fit_text(logString, width - 3)
|
logString = logString[: width - 3]
|
||||||
|
|
||||||
# Add to the window
|
# Add to the window
|
||||||
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import List
|
|||||||
from contact.utilities.save_to_radio import save_changes
|
from contact.utilities.save_to_radio import save_changes
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
from contact.utilities.config_io import config_export, config_import
|
from contact.utilities.config_io import config_export, config_import
|
||||||
|
from contact.utilities.interfaces import reconnect_interface
|
||||||
from contact.utilities.control_utils import transform_menu_path
|
from contact.utilities.control_utils import transform_menu_path
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
from contact.utilities.ini_utils import parse_ini_file
|
from contact.utilities.ini_utils import parse_ini_file
|
||||||
@@ -23,8 +24,10 @@ from contact.ui.colors import get_color
|
|||||||
from contact.ui.dialog import dialog
|
from contact.ui.dialog import dialog
|
||||||
from contact.ui.menus import generate_menu_from_protobuf
|
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.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.ui.user_config import json_editor
|
||||||
from contact.utilities.singleton import menu_state
|
from contact.utilities.arg_parser import setup_parser
|
||||||
|
from contact.utilities.singleton import interface_state, menu_state
|
||||||
|
|
||||||
# Setup Variables
|
# Setup Variables
|
||||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||||
@@ -222,6 +225,39 @@ def get_input_type_for_field(field) -> type:
|
|||||||
return str
|
return str
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_interface_with_splash(stdscr: object, interface: object) -> object:
|
||||||
|
try:
|
||||||
|
interface.close()
|
||||||
|
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)
|
||||||
|
return new_interface
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_after_admin_action(stdscr: object, interface: object, action, log_message: str) -> object:
|
||||||
|
action()
|
||||||
|
logging.info(log_message)
|
||||||
|
return reconnect_interface_with_splash(stdscr, interface)
|
||||||
|
|
||||||
|
|
||||||
|
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
|
||||||
|
try:
|
||||||
|
from contact.ui import contact_ui
|
||||||
|
from contact.utilities.utils import get_channels, refresh_node_list
|
||||||
|
|
||||||
|
get_channels()
|
||||||
|
refresh_node_list()
|
||||||
|
contact_ui.handle_resize(stdscr, False)
|
||||||
|
except Exception:
|
||||||
|
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def settings_menu(stdscr: object, interface: object) -> None:
|
def settings_menu(stdscr: object, interface: object) -> None:
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
|
|
||||||
@@ -330,9 +366,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
help_win.refresh()
|
help_win.refresh()
|
||||||
|
|
||||||
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
||||||
save_changes(interface, modified_settings, menu_state)
|
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||||
modified_settings.clear()
|
modified_settings.clear()
|
||||||
logging.info("Changes Saved")
|
logging.info("Changes Saved")
|
||||||
|
if reconnect_required:
|
||||||
|
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||||
|
menu = generate_menu_from_protobuf(interface)
|
||||||
|
|
||||||
if len(menu_state.menu_path) > 1:
|
if len(menu_state.menu_path) > 1:
|
||||||
menu_state.menu_path.pop()
|
menu_state.menu_path.pop()
|
||||||
@@ -460,8 +499,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
|
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
|
||||||
)
|
)
|
||||||
if confirmation == "Yes":
|
if confirmation == "Yes":
|
||||||
interface.localNode.reboot()
|
interface = reconnect_after_admin_action(
|
||||||
logging.info(f"Node Reboot Requested by menu")
|
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
|
||||||
|
)
|
||||||
|
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -472,8 +513,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
["Yes", "No"],
|
["Yes", "No"],
|
||||||
)
|
)
|
||||||
if confirmation == "Yes":
|
if confirmation == "Yes":
|
||||||
interface.localNode.resetNodeDb()
|
interface = reconnect_after_admin_action(
|
||||||
logging.info(f"Node DB Reset Requested by menu")
|
stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu"
|
||||||
|
)
|
||||||
|
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -494,8 +537,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
["Yes", "No"],
|
["Yes", "No"],
|
||||||
)
|
)
|
||||||
if confirmation == "Yes":
|
if confirmation == "Yes":
|
||||||
interface.localNode.factoryReset()
|
interface = reconnect_after_admin_action(
|
||||||
logging.info(f"Factory Reset Requested by menu")
|
stdscr, interface, interface.localNode.factoryReset, "Factory Reset Requested by menu"
|
||||||
|
)
|
||||||
|
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -655,8 +700,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
if save_prompt == "Cancel":
|
if save_prompt == "Cancel":
|
||||||
continue # Stay in the menu without doing anything
|
continue # Stay in the menu without doing anything
|
||||||
elif save_prompt == "Yes":
|
elif save_prompt == "Yes":
|
||||||
save_changes(interface, modified_settings, menu_state)
|
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||||
logging.info("Changes Saved")
|
logging.info("Changes Saved")
|
||||||
|
if reconnect_required:
|
||||||
|
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||||
|
|
||||||
modified_settings.clear()
|
modified_settings.clear()
|
||||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||||
|
|||||||
+16
-69
@@ -1,12 +1,11 @@
|
|||||||
import curses
|
import curses
|
||||||
import re
|
import re
|
||||||
from typing import Any, Optional, List, Dict
|
from unicodedata import east_asian_width
|
||||||
|
|
||||||
from wcwidth import wcwidth, wcswidth
|
|
||||||
|
|
||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
from contact.utilities.control_utils import transform_menu_path
|
from contact.utilities.control_utils import transform_menu_path
|
||||||
|
from typing import Any, Optional, List, Dict
|
||||||
from contact.utilities.singleton import interface_state, ui_state
|
from contact.utilities.singleton import interface_state, ui_state
|
||||||
|
|
||||||
|
|
||||||
@@ -329,65 +328,7 @@ def get_wrapped_help_text(
|
|||||||
|
|
||||||
|
|
||||||
def text_width(text: str) -> int:
|
def text_width(text: str) -> int:
|
||||||
width = wcswidth(text)
|
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
|
||||||
if width >= 0:
|
|
||||||
return width
|
|
||||||
return sum(max(wcwidth(char), 0) for char in text)
|
|
||||||
|
|
||||||
|
|
||||||
def slice_text_to_width(text: str, max_width: int) -> str:
|
|
||||||
"""Return the longest prefix that fits within max_width terminal cells."""
|
|
||||||
if max_width <= 0:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
chunk = ""
|
|
||||||
for char in text:
|
|
||||||
candidate = chunk + char
|
|
||||||
if text_width(candidate) > max_width:
|
|
||||||
break
|
|
||||||
chunk = candidate
|
|
||||||
|
|
||||||
return chunk
|
|
||||||
|
|
||||||
|
|
||||||
def fit_text(text: str, width: int, suffix: str = "") -> str:
|
|
||||||
"""Trim and pad text so its terminal display width fits exactly."""
|
|
||||||
if width <= 0:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if text_width(text) > width:
|
|
||||||
suffix = slice_text_to_width(suffix, width)
|
|
||||||
available = max(0, width - text_width(suffix))
|
|
||||||
text = slice_text_to_width(text, available).rstrip() + suffix
|
|
||||||
|
|
||||||
padding = max(0, width - text_width(text))
|
|
||||||
return text + (" " * padding)
|
|
||||||
|
|
||||||
|
|
||||||
def split_text_to_width(text: str, max_width: int) -> List[str]:
|
|
||||||
"""Split text into chunks that each fit within max_width terminal cells."""
|
|
||||||
if max_width <= 0:
|
|
||||||
return [""]
|
|
||||||
|
|
||||||
chunks: List[str] = []
|
|
||||||
chunk = ""
|
|
||||||
|
|
||||||
for char in text:
|
|
||||||
candidate = chunk + char
|
|
||||||
if chunk and text_width(candidate) > max_width:
|
|
||||||
chunks.append(chunk)
|
|
||||||
chunk = char
|
|
||||||
else:
|
|
||||||
chunk = candidate
|
|
||||||
|
|
||||||
if text_width(chunk) > max_width:
|
|
||||||
chunks.append(slice_text_to_width(chunk, max_width))
|
|
||||||
chunk = ""
|
|
||||||
|
|
||||||
if chunk:
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
return chunks or [""]
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||||
@@ -405,18 +346,24 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
|
|||||||
wrap_width -= margin
|
wrap_width -= margin
|
||||||
|
|
||||||
for word in words:
|
for word in words:
|
||||||
word_chunks = split_text_to_width(word, wrap_width) if text_width(word) > wrap_width else [word]
|
word_length = text_width(word)
|
||||||
|
|
||||||
for chunk in word_chunks:
|
if word_length > wrap_width: # Break long words
|
||||||
chunk_length = text_width(chunk)
|
if line_buffer:
|
||||||
|
|
||||||
if line_length + chunk_length > wrap_width and chunk.strip():
|
|
||||||
wrapped_lines.append(line_buffer.strip())
|
wrapped_lines.append(line_buffer.strip())
|
||||||
line_buffer = ""
|
line_buffer = ""
|
||||||
line_length = 0
|
line_length = 0
|
||||||
|
for i in range(0, word_length, wrap_width):
|
||||||
|
wrapped_lines.append(word[i : i + wrap_width])
|
||||||
|
continue
|
||||||
|
|
||||||
line_buffer += chunk
|
if line_length + word_length > wrap_width and word.strip():
|
||||||
line_length += chunk_length
|
wrapped_lines.append(line_buffer.strip())
|
||||||
|
line_buffer = ""
|
||||||
|
line_length = 0
|
||||||
|
|
||||||
|
line_buffer += word
|
||||||
|
line_length += word_length
|
||||||
|
|
||||||
if line_buffer:
|
if line_buffer:
|
||||||
wrapped_lines.append(line_buffer.strip())
|
wrapped_lines.append(line_buffer.strip())
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
|||||||
formatted_json = config.format_json_single_line_arrays(data)
|
formatted_json = config.format_json_single_line_arrays(data)
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(formatted_json)
|
f.write(formatted_json)
|
||||||
setup_colors(reinit=True)
|
config.reload_config()
|
||||||
reload_translations(data.get("language"))
|
reload_translations(data.get("language"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,5 +35,10 @@ def setup_parser() -> ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
|
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--demo-screenshot",
|
||||||
|
help="Launch with a fake interface and seeded demo data for screenshots/testing.",
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, List, Tuple, Union
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.utilities.db_handler import get_table_name
|
||||||
|
from contact.utilities.singleton import interface_state
|
||||||
|
|
||||||
|
|
||||||
|
DEMO_DB_FILENAME = "contact_demo_client.db"
|
||||||
|
DEMO_LOCAL_NODE_NUM = 0xC0DEC0DE
|
||||||
|
DEMO_BASE_TIMESTAMP = 1738717200 # 2025-02-04 17:00:00 UTC
|
||||||
|
DEMO_CHANNELS = ["MediumFast", "Another Channel"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemoChannelSettings:
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemoChannel:
|
||||||
|
role: bool
|
||||||
|
settings: DemoChannelSettings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemoLoRaConfig:
|
||||||
|
region: int = 1
|
||||||
|
modem_preset: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemoLocalConfig:
|
||||||
|
lora: DemoLoRaConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DemoLocalNode:
|
||||||
|
def __init__(self, interface: "DemoInterface", channels: List[DemoChannel]) -> None:
|
||||||
|
self._interface = interface
|
||||||
|
self.channels = channels
|
||||||
|
self.localConfig = DemoLocalConfig(lora=DemoLoRaConfig())
|
||||||
|
|
||||||
|
def setFavorite(self, node_num: int) -> None:
|
||||||
|
self._interface.nodesByNum[node_num]["isFavorite"] = True
|
||||||
|
|
||||||
|
def removeFavorite(self, node_num: int) -> None:
|
||||||
|
self._interface.nodesByNum[node_num]["isFavorite"] = False
|
||||||
|
|
||||||
|
def setIgnored(self, node_num: int) -> None:
|
||||||
|
self._interface.nodesByNum[node_num]["isIgnored"] = True
|
||||||
|
|
||||||
|
def removeIgnored(self, node_num: int) -> None:
|
||||||
|
self._interface.nodesByNum[node_num]["isIgnored"] = False
|
||||||
|
|
||||||
|
def removeNode(self, node_num: int) -> None:
|
||||||
|
self._interface.nodesByNum.pop(node_num, None)
|
||||||
|
|
||||||
|
|
||||||
|
class DemoInterface:
|
||||||
|
def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List[DemoChannel]) -> None:
|
||||||
|
self.nodesByNum = nodes
|
||||||
|
self.nodes = self.nodesByNum
|
||||||
|
self.localNode = DemoLocalNode(self, channels)
|
||||||
|
|
||||||
|
def getMyNodeInfo(self) -> Dict[str, int]:
|
||||||
|
return {"num": DEMO_LOCAL_NODE_NUM}
|
||||||
|
|
||||||
|
def getNode(self, selector: str) -> DemoLocalNode:
|
||||||
|
if selector != "^local":
|
||||||
|
raise KeyError(selector)
|
||||||
|
return self.localNode
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_interface() -> DemoInterface:
|
||||||
|
channels = [DemoChannel(role=True, settings=DemoChannelSettings(name=name)) for name in DEMO_CHANNELS]
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
DEMO_LOCAL_NODE_NUM: _build_node(
|
||||||
|
DEMO_LOCAL_NODE_NUM,
|
||||||
|
"Meshtastic fb3c",
|
||||||
|
"fb3c",
|
||||||
|
hops=0,
|
||||||
|
snr=13.7,
|
||||||
|
last_heard_offset=5,
|
||||||
|
battery=88,
|
||||||
|
voltage=4.1,
|
||||||
|
favorite=True,
|
||||||
|
),
|
||||||
|
0xA1000001: _build_node(0xA1000001, "KG7NDX-N2", "N2", hops=1, last_heard_offset=18, battery=79, voltage=4.0),
|
||||||
|
0xA1000002: _build_node(0xA1000002, "Satellite II Repeater", "SAT2", hops=2, last_heard_offset=31),
|
||||||
|
0xA1000003: _build_node(0xA1000003, "Search for Discord/Meshtastic", "DISC", hops=1, last_heard_offset=46),
|
||||||
|
0xA1000004: _build_node(0xA1000004, "K7EOK Mobile", "MOBL", hops=1, last_heard_offset=63, battery=52),
|
||||||
|
0xA1000005: _build_node(0xA1000005, "Turtle", "TRTL", hops=3, last_heard_offset=87),
|
||||||
|
0xA1000006: _build_node(0xA1000006, "CARS Trewvilliger Plaza", "CARS", hops=2, last_heard_offset=121),
|
||||||
|
0xA1000007: _build_node(0xA1000007, "No Hands!", "NHDS", hops=1, last_heard_offset=155),
|
||||||
|
0xA1000008: _build_node(0xA1000008, "McCutie", "MCCU", hops=2, last_heard_offset=211, ignored=True),
|
||||||
|
0xA1000009: _build_node(0xA1000009, "K1PDX", "K1PX", hops=2, last_heard_offset=267),
|
||||||
|
0xA100000A: _build_node(0xA100000A, "Arnold Creek", "ARND", hops=1, last_heard_offset=301),
|
||||||
|
0xA100000B: _build_node(0xA100000B, "Nansen", "NANS", hops=1, last_heard_offset=355),
|
||||||
|
0xA100000C: _build_node(0xA100000C, "Kodin 1", "KOD1", hops=2, last_heard_offset=402),
|
||||||
|
0xA100000D: _build_node(0xA100000D, "PH1", "PH1", hops=3, last_heard_offset=470),
|
||||||
|
0xA100000E: _build_node(0xA100000E, "Luna", "LUNA", hops=1, last_heard_offset=501),
|
||||||
|
0xA100000F: _build_node(0xA100000F, "sputnik1", "SPUT", hops=1, last_heard_offset=550),
|
||||||
|
0xA1000010: _build_node(0xA1000010, "K7EOK Maplewood West", "MAPL", hops=2, last_heard_offset=602),
|
||||||
|
0xA1000011: _build_node(0xA1000011, "KE7YVU 2", "YVU2", hops=2, last_heard_offset=655),
|
||||||
|
0xA1000012: _build_node(0xA1000012, "DNET", "DNET", hops=1, last_heard_offset=702),
|
||||||
|
0xA1000013: _build_node(0xA1000013, "Green Bluff", "GBLF", hops=1, last_heard_offset=780),
|
||||||
|
0xA1000014: _build_node(0xA1000014, "Council Crest Solar", "CCST", hops=2, last_heard_offset=830),
|
||||||
|
0xA1000015: _build_node(0xA1000015, "Meshtastic 61c7", "61c7", hops=1, last_heard_offset=901),
|
||||||
|
0xA1000016: _build_node(0xA1000016, "Bella", "BELA", hops=2, last_heard_offset=950),
|
||||||
|
0xA1000017: _build_node(0xA1000017, "Mojo Solar Base 4f12", "MOJO", hops=1, last_heard_offset=1010, favorite=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
return DemoInterface(nodes=nodes, channels=channels)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_demo_database(base_dir: str = "") -> str:
|
||||||
|
if not base_dir:
|
||||||
|
base_dir = tempfile.mkdtemp(prefix="contact_demo_")
|
||||||
|
os.makedirs(base_dir, exist_ok=True)
|
||||||
|
|
||||||
|
db_path = os.path.join(base_dir, DEMO_DB_FILENAME)
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
os.remove(db_path)
|
||||||
|
|
||||||
|
config.db_file_path = db_path
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def seed_demo_messages() -> None:
|
||||||
|
schema = """
|
||||||
|
user_id TEXT,
|
||||||
|
message_text TEXT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
ack_type TEXT
|
||||||
|
"""
|
||||||
|
|
||||||
|
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||||
|
cursor = db_connection.cursor()
|
||||||
|
|
||||||
|
for channel_name, rows in _demo_messages().items():
|
||||||
|
table_name = get_table_name(channel_name)
|
||||||
|
cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})")
|
||||||
|
cursor.executemany(
|
||||||
|
f"""
|
||||||
|
INSERT INTO {table_name} (user_id, message_text, timestamp, ack_type)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_node(
|
||||||
|
node_num: int,
|
||||||
|
long_name: str,
|
||||||
|
short_name: str,
|
||||||
|
*,
|
||||||
|
hops: int,
|
||||||
|
last_heard_offset: int,
|
||||||
|
snr: float = 0.0,
|
||||||
|
battery: int = 0,
|
||||||
|
voltage: float = 0.0,
|
||||||
|
favorite: bool = False,
|
||||||
|
ignored: bool = False,
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
node = {
|
||||||
|
"num": node_num,
|
||||||
|
"user": {
|
||||||
|
"longName": long_name,
|
||||||
|
"shortName": short_name,
|
||||||
|
"hwModel": "TBEAM",
|
||||||
|
"role": "CLIENT",
|
||||||
|
"publicKey": f"pk-{node_num:08x}",
|
||||||
|
"isLicensed": True,
|
||||||
|
},
|
||||||
|
"lastHeard": DEMO_BASE_TIMESTAMP + 3600 - last_heard_offset,
|
||||||
|
"hopsAway": hops,
|
||||||
|
"isFavorite": favorite,
|
||||||
|
"isIgnored": ignored,
|
||||||
|
}
|
||||||
|
|
||||||
|
if snr:
|
||||||
|
node["snr"] = snr
|
||||||
|
if battery:
|
||||||
|
node["deviceMetrics"] = {
|
||||||
|
"batteryLevel": battery,
|
||||||
|
"voltage": voltage or 4.0,
|
||||||
|
"uptimeSeconds": 86400 + node_num % 10000,
|
||||||
|
"channelUtilization": 12.5 + (node_num % 7),
|
||||||
|
"airUtilTx": 4.5 + (node_num % 5),
|
||||||
|
}
|
||||||
|
|
||||||
|
if node_num % 3 == 0:
|
||||||
|
node["position"] = {
|
||||||
|
"latitude": 45.5231 + ((node_num % 50) * 0.0001),
|
||||||
|
"longitude": -122.6765 - ((node_num % 50) * 0.0001),
|
||||||
|
"altitude": 85 + (node_num % 20),
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, Union[str, None]]]]:
|
||||||
|
return {
|
||||||
|
"MediumFast": [
|
||||||
|
(str(DEMO_LOCAL_NODE_NUM), "Help, I'm stuck in a ditch!", DEMO_BASE_TIMESTAMP + 45, "Ack"),
|
||||||
|
("2701131778", "Do you require a alpinist?", DEMO_BASE_TIMESTAMP + 80, None),
|
||||||
|
(str(DEMO_LOCAL_NODE_NUM), "I don't know what that is.", DEMO_BASE_TIMESTAMP + 104, "Implicit"),
|
||||||
|
],
|
||||||
|
"Another Channel": [
|
||||||
|
("2701131788", "Weather is holding for the summit push.", DEMO_BASE_TIMESTAMP + 220, None),
|
||||||
|
(str(DEMO_LOCAL_NODE_NUM), "Copy that. Keep me posted.", DEMO_BASE_TIMESTAMP + 260, "Ack"),
|
||||||
|
],
|
||||||
|
2701131788: [
|
||||||
|
("2701131788", "Ping me when you are back at the trailhead.", DEMO_BASE_TIMESTAMP + 330, None),
|
||||||
|
(str(DEMO_LOCAL_NODE_NUM), "Will do.", DEMO_BASE_TIMESTAMP + 350, "Ack"),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
|
||||||
|
|
||||||
|
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
|
||||||
|
EMOJI_MODIFIER_REPLACEMENTS = {
|
||||||
|
"\u200d": "",
|
||||||
|
"\u20e3": "",
|
||||||
|
"\ufe0e": "",
|
||||||
|
"\ufe0f": "",
|
||||||
|
"\U0001F3FB": "",
|
||||||
|
"\U0001F3FC": "",
|
||||||
|
"\U0001F3FD": "",
|
||||||
|
"\U0001F3FE": "",
|
||||||
|
"\U0001F3FF": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
|
||||||
|
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
|
||||||
|
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
|
||||||
|
|
||||||
|
|
||||||
|
def _regional_indicator_to_letter(char: str) -> str:
|
||||||
|
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_flag_emoji(text: str) -> str:
|
||||||
|
"""Convert flag emoji built from regional indicators into ASCII country codes."""
|
||||||
|
normalized = []
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < len(text):
|
||||||
|
current = text[index]
|
||||||
|
current_ord = ord(current)
|
||||||
|
|
||||||
|
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
|
||||||
|
next_char = text[index + 1]
|
||||||
|
next_ord = ord(next_char)
|
||||||
|
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
|
||||||
|
normalized.append(_regional_indicator_to_letter(current))
|
||||||
|
normalized.append(_regional_indicator_to_letter(next_char))
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized.append(current)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return "".join(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message_text(text: str) -> str:
|
||||||
|
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||||
|
|
||||||
|
|
||||||
@@ -41,3 +42,21 @@ def initialize_interface(args):
|
|||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0):
|
||||||
|
last_error = None
|
||||||
|
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
interface = initialize_interface(args)
|
||||||
|
if interface is not None:
|
||||||
|
return interface
|
||||||
|
last_error = RuntimeError("initialize_interface returned None")
|
||||||
|
except Exception as ex:
|
||||||
|
last_error = ex
|
||||||
|
|
||||||
|
if attempt < attempts - 1:
|
||||||
|
time.sleep(delay_seconds)
|
||||||
|
|
||||||
|
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
|
||||||
|
|||||||
@@ -4,6 +4,79 @@ import logging
|
|||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
|
||||||
|
POWER_REBOOT_KEYS = {
|
||||||
|
"device_battery_ina_address",
|
||||||
|
"is_power_saving",
|
||||||
|
"ls_secs",
|
||||||
|
"min_wake_secs",
|
||||||
|
"on_battery_shutdown_after_secs",
|
||||||
|
"sds_secs",
|
||||||
|
"wait_bluetooth_secs",
|
||||||
|
}
|
||||||
|
DISPLAY_REBOOT_KEYS = {"screen_on_secs", "flip_screen", "oled", "displaymode"}
|
||||||
|
LORA_REBOOT_KEYS = {
|
||||||
|
"use_preset",
|
||||||
|
"region",
|
||||||
|
"modem_preset",
|
||||||
|
"bandwidth",
|
||||||
|
"spread_factor",
|
||||||
|
"coding_rate",
|
||||||
|
"tx_power",
|
||||||
|
"frequency_offset",
|
||||||
|
"override_frequency",
|
||||||
|
"channel_num",
|
||||||
|
"sx126x_rx_boosted_gain",
|
||||||
|
}
|
||||||
|
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
|
||||||
|
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_changed_keys(modified_settings):
|
||||||
|
changed = set()
|
||||||
|
for key, value in modified_settings.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
changed.update(_collect_changed_keys(value))
|
||||||
|
else:
|
||||||
|
changed.add(key)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_reconnect(menu_state, modified_settings) -> bool:
|
||||||
|
if not modified_settings or len(menu_state.menu_path) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
section = menu_state.menu_path[1]
|
||||||
|
changed_keys = _collect_changed_keys(modified_settings)
|
||||||
|
|
||||||
|
if section == "Module Settings":
|
||||||
|
return True
|
||||||
|
if section == "User Settings":
|
||||||
|
return bool(changed_keys & USER_RECONNECT_KEYS)
|
||||||
|
if section == "Channels":
|
||||||
|
return False
|
||||||
|
if section != "Radio Settings" or len(menu_state.menu_path) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
config_category = menu_state.menu_path[2].lower()
|
||||||
|
|
||||||
|
if config_category in {"network", "bluetooth"}:
|
||||||
|
return True
|
||||||
|
if config_category == "security":
|
||||||
|
return not changed_keys.issubset(SECURITY_NON_REBOOT_KEYS)
|
||||||
|
if config_category == "device":
|
||||||
|
return bool(changed_keys & DEVICE_REBOOT_KEYS)
|
||||||
|
if config_category == "power":
|
||||||
|
return bool(changed_keys & POWER_REBOOT_KEYS)
|
||||||
|
if config_category == "display":
|
||||||
|
return bool(changed_keys & DISPLAY_REBOOT_KEYS)
|
||||||
|
if config_category == "lora":
|
||||||
|
return bool(changed_keys & LORA_REBOOT_KEYS)
|
||||||
|
|
||||||
|
# Firmware defaults most config writes to reboot-required unless a handler
|
||||||
|
# explicitly clears that flag.
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def save_changes(interface, modified_settings, menu_state):
|
def save_changes(interface, modified_settings, menu_state):
|
||||||
"""
|
"""
|
||||||
@@ -15,7 +88,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
try:
|
try:
|
||||||
if not modified_settings:
|
if not modified_settings:
|
||||||
logging.info("No changes to save. modified_settings is empty.")
|
logging.info("No changes to save. modified_settings is empty.")
|
||||||
return
|
return False
|
||||||
|
|
||||||
node = interface.getNode("^local")
|
node = interface.getNode("^local")
|
||||||
admin_key_backup = None
|
admin_key_backup = None
|
||||||
@@ -51,7 +124,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
|
|
||||||
# Return early if there are no other settings left to process
|
# Return early if there are no other settings left to process
|
||||||
if not modified_settings:
|
if not modified_settings:
|
||||||
return
|
return _requires_reconnect(menu_state, {"admin_key": admin_key_backup})
|
||||||
|
|
||||||
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
|
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
|
||||||
config_category = menu_state.menu_path[2].lower() # for radio and module configs
|
config_category = menu_state.menu_path[2].lower() # for radio and module configs
|
||||||
@@ -63,7 +136,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
|
|
||||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
elif menu_state.menu_path[1] == "User Settings": # for user configs
|
elif menu_state.menu_path[1] == "User Settings": # for user configs
|
||||||
config_category = "User Settings"
|
config_category = "User Settings"
|
||||||
@@ -78,7 +151,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
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}, Licensed Mode: {is_licensed}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return _requires_reconnect(menu_state, modified_settings)
|
||||||
|
|
||||||
elif menu_state.menu_path[1] == "Channels": # for channel configs
|
elif menu_state.menu_path[1] == "Channels": # for channel configs
|
||||||
config_category = "Channels"
|
config_category = "Channels"
|
||||||
@@ -107,7 +180,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
|
|
||||||
logging.info(f"Updated Channel {channel_num} in {config_category}")
|
logging.info(f"Updated Channel {channel_num} in {config_category}")
|
||||||
logging.info(node.channels)
|
logging.info(node.channels)
|
||||||
return
|
return False
|
||||||
|
|
||||||
else:
|
else:
|
||||||
config_category = None
|
config_category = None
|
||||||
@@ -120,7 +193,7 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
config_container = getattr(node.moduleConfig, config_category)
|
config_container = getattr(node.moduleConfig, config_category)
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Config category '{config_category}' not found in config.")
|
logging.warning(f"Config category '{config_category}' not found in config.")
|
||||||
return
|
return False
|
||||||
|
|
||||||
if len(menu_state.menu_path) >= 4:
|
if len(menu_state.menu_path) >= 4:
|
||||||
nested_key = menu_state.menu_path[3]
|
nested_key = menu_state.menu_path[3]
|
||||||
@@ -164,8 +237,11 @@ def save_changes(interface, modified_settings, menu_state):
|
|||||||
|
|
||||||
if admin_key_backup is not None:
|
if admin_key_backup is not None:
|
||||||
modified_settings["admin_key"] = admin_key_backup
|
modified_settings["admin_key"] = admin_key_backup
|
||||||
|
return _requires_reconnect(menu_state, modified_settings)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
|
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error saving changes: {e}")
|
logging.error(f"Error saving changes: {e}")
|
||||||
|
return False
|
||||||
|
|||||||
+35
-20
@@ -10,35 +10,50 @@ from contact.utilities.singleton import ui_state, interface_state
|
|||||||
import contact.utilities.telemetry_beautifier as tb
|
import contact.utilities.telemetry_beautifier as tb
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
return convert_to_camel_case(modem_preset_string)
|
||||||
|
|
||||||
|
|
||||||
def get_channels():
|
def get_channels():
|
||||||
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
|
"""Retrieve channels from the node and rebuild named channel state."""
|
||||||
node = interface_state.interface.getNode("^local")
|
node = interface_state.interface.getNode("^local")
|
||||||
device_channels = node.channels
|
device_channels = node.channels
|
||||||
|
previous_channel_list = list(ui_state.channel_list)
|
||||||
|
previous_messages = dict(ui_state.all_messages)
|
||||||
|
|
||||||
# Clear and rebuild channel list
|
named_channels = []
|
||||||
# ui_state.channel_list = []
|
|
||||||
|
|
||||||
for device_channel in device_channels:
|
for device_channel in device_channels:
|
||||||
if device_channel.role:
|
if device_channel.role:
|
||||||
# Use the channel name if available, otherwise use the modem preset
|
named_channels.append(_get_channel_name(device_channel, node))
|
||||||
if device_channel.settings.name:
|
|
||||||
channel_name = device_channel.settings.name
|
|
||||||
else:
|
|
||||||
# If channel name is blank, use the modem preset
|
|
||||||
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
|
|
||||||
channel_name = convert_to_camel_case(modem_preset_string)
|
|
||||||
|
|
||||||
# Add channel to ui_state.channel_list if not already present
|
previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)]
|
||||||
if channel_name not in ui_state.channel_list:
|
preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)]
|
||||||
ui_state.channel_list.append(channel_name)
|
rebuilt_messages = {}
|
||||||
|
|
||||||
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
|
for index, channel_name in enumerate(named_channels):
|
||||||
if channel_name not in ui_state.all_messages:
|
previous_name = previous_named_channels[index] if index < len(previous_named_channels) else channel_name
|
||||||
ui_state.all_messages[channel_name] = []
|
if previous_name in previous_messages:
|
||||||
|
rebuilt_messages[channel_name] = previous_messages[previous_name]
|
||||||
|
elif channel_name in previous_messages:
|
||||||
|
rebuilt_messages[channel_name] = previous_messages[channel_name]
|
||||||
|
else:
|
||||||
|
rebuilt_messages[channel_name] = []
|
||||||
|
|
||||||
|
for channel in preserved_direct_channels:
|
||||||
|
if channel in previous_messages:
|
||||||
|
rebuilt_messages[channel] = previous_messages[channel]
|
||||||
|
|
||||||
|
ui_state.channel_list = named_channels + preserved_direct_channels
|
||||||
|
ui_state.all_messages = rebuilt_messages
|
||||||
|
if ui_state.channel_list:
|
||||||
|
ui_state.selected_channel = max(0, min(ui_state.selected_channel, len(ui_state.channel_list) - 1))
|
||||||
|
|
||||||
return ui_state.channel_list
|
return ui_state.channel_list
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "contact"
|
name = "contact"
|
||||||
version = "1.4.19"
|
version = "1.5.1"
|
||||||
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."
|
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 = [
|
authors = [
|
||||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||||
@@ -9,8 +9,7 @@ license = "GPL-3.0-only"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9,<3.15"
|
requires-python = ">=3.9,<3.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"meshtastic (>=2.7.5,<3.0.0)",
|
"meshtastic (>=2.7.5,<3.0.0)"
|
||||||
"wcwidth (>=0.2.13,<1.0.0)"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
meshtastic
|
meshtastic
|
||||||
wcwidth>=0.2.13,<1.0.0
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.utilities.arg_parser import setup_parser
|
||||||
|
|
||||||
|
|
||||||
|
class ArgParserTests(unittest.TestCase):
|
||||||
|
def test_demo_screenshot_flag_is_supported(self) -> None:
|
||||||
|
args = setup_parser().parse_args(["--demo-screenshot"])
|
||||||
|
self.assertTrue(args.demo_screenshot)
|
||||||
|
|
||||||
|
def test_demo_screenshot_defaults_to_false(self) -> None:
|
||||||
|
args = setup_parser().parse_args([])
|
||||||
|
self.assertFalse(args.demo_screenshot)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIoTests(unittest.TestCase):
|
||||||
|
def test_split_compound_name_preserves_multi_part_values(self) -> None:
|
||||||
|
self.assertEqual(splitCompoundName("config.device.role"), ["config", "device", "role"])
|
||||||
|
|
||||||
|
def test_split_compound_name_duplicates_single_part_values(self) -> None:
|
||||||
|
self.assertEqual(splitCompoundName("owner"), ["owner", "owner"])
|
||||||
|
|
||||||
|
def test_is_repeated_field_prefers_new_style_attribute(self) -> None:
|
||||||
|
field = type("Field", (), {"is_repeated": True})()
|
||||||
|
|
||||||
|
self.assertTrue(_is_repeated_field(field))
|
||||||
|
|
||||||
|
def test_is_repeated_field_falls_back_to_label_comparison(self) -> None:
|
||||||
|
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
|
||||||
|
|
||||||
|
self.assertTrue(_is_repeated_field(field_type()))
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.ui import contact_ui
|
||||||
|
from contact.utilities.singleton import ui_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config("single_pane_mode")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
ui_state.current_window = 1
|
||||||
|
config.single_pane_mode = "False"
|
||||||
|
|
||||||
|
with mock.patch.object(contact_ui.curses, "curs_set") as curs_set:
|
||||||
|
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
|
||||||
|
with mock.patch.object(contact_ui, "get_channels") as get_channels:
|
||||||
|
with mock.patch.object(contact_ui, "refresh_node_list") as refresh_node_list:
|
||||||
|
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
|
||||||
|
contact_ui.handle_backtick(stdscr)
|
||||||
|
|
||||||
|
settings_menu.assert_called_once()
|
||||||
|
get_channels.assert_called_once_with()
|
||||||
|
refresh_node_list.assert_called_once_with()
|
||||||
|
handle_resize.assert_called_once_with(stdscr, False)
|
||||||
|
self.assertEqual(curs_set.call_args_list[0].args, (0,))
|
||||||
|
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
|
||||||
|
self.assertEqual(ui_state.current_window, 1)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from argparse import Namespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from contact.ui import control_ui
|
||||||
|
from contact.utilities.singleton import interface_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons
|
||||||
|
|
||||||
|
|
||||||
|
class ControlUiTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
|
||||||
|
old_interface = mock.Mock()
|
||||||
|
new_interface = mock.Mock()
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
parser = mock.Mock()
|
||||||
|
parser.parse_args.return_value = Namespace()
|
||||||
|
|
||||||
|
with mock.patch.object(control_ui, "setup_parser", return_value=parser):
|
||||||
|
with mock.patch.object(control_ui, "draw_splash") as draw_splash:
|
||||||
|
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||||
|
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||||
|
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||||
|
|
||||||
|
old_interface.close.assert_called_once_with()
|
||||||
|
stdscr.clear.assert_called_once_with()
|
||||||
|
stdscr.refresh.assert_called_once_with()
|
||||||
|
draw_splash.assert_called_once_with(stdscr)
|
||||||
|
reconnect.assert_called_once_with(parser.parse_args.return_value)
|
||||||
|
redraw.assert_called_once_with(stdscr)
|
||||||
|
self.assertIs(result, new_interface)
|
||||||
|
self.assertIs(interface_state.interface, new_interface)
|
||||||
|
|
||||||
|
def test_reconnect_after_admin_action_runs_action_then_reconnects(self) -> None:
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
interface = mock.Mock()
|
||||||
|
new_interface = mock.Mock()
|
||||||
|
action = mock.Mock()
|
||||||
|
|
||||||
|
with mock.patch.object(control_ui, "reconnect_interface_with_splash", return_value=new_interface) as reconnect:
|
||||||
|
result = control_ui.reconnect_after_admin_action(
|
||||||
|
stdscr, interface, action, "Factory Reset Requested by menu"
|
||||||
|
)
|
||||||
|
|
||||||
|
action.assert_called_once_with()
|
||||||
|
reconnect.assert_called_once_with(stdscr, interface)
|
||||||
|
self.assertIs(result, new_interface)
|
||||||
|
|
||||||
|
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
|
||||||
|
with mock.patch("contact.utilities.utils.get_channels") as get_channels:
|
||||||
|
with mock.patch("contact.utilities.utils.refresh_node_list") as refresh_node_list:
|
||||||
|
with mock.patch("contact.ui.contact_ui.handle_resize") as handle_resize:
|
||||||
|
control_ui.redraw_main_ui_after_reconnect(stdscr)
|
||||||
|
|
||||||
|
get_channels.assert_called_once_with()
|
||||||
|
refresh_node_list.assert_called_once_with()
|
||||||
|
handle_resize.assert_called_once_with(stdscr, False)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.utilities.control_utils import transform_menu_path
|
||||||
|
|
||||||
|
|
||||||
|
class ControlUtilsTests(unittest.TestCase):
|
||||||
|
def test_transform_menu_path_applies_replacements_and_normalization(self) -> None:
|
||||||
|
transformed = transform_menu_path(["Main Menu", "Radio Settings", "Channel 2", "Detail"])
|
||||||
|
|
||||||
|
self.assertEqual(transformed, ["config", "channel", "Detail"])
|
||||||
|
|
||||||
|
def test_transform_menu_path_preserves_unmatched_entries(self) -> None:
|
||||||
|
transformed = transform_menu_path(["Main Menu", "Module Settings", "WiFi"])
|
||||||
|
|
||||||
|
self.assertEqual(transformed, ["module", "WiFi"])
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.utilities import db_handler
|
||||||
|
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 decimal_to_hex
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class DbHandlerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config(
|
||||||
|
"db_file_path",
|
||||||
|
"message_prefix",
|
||||||
|
"sent_message_prefix",
|
||||||
|
"ack_str",
|
||||||
|
"ack_implicit_str",
|
||||||
|
"ack_unknown_str",
|
||||||
|
"nak_str",
|
||||||
|
)
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
config.db_file_path = os.path.join(self.tempdir.name, "client.db")
|
||||||
|
interface_state.myNodeNum = 123
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_save_message_to_db_and_update_ack_roundtrip(self) -> None:
|
||||||
|
timestamp = db_handler.save_message_to_db("Primary", "123", "hello")
|
||||||
|
|
||||||
|
self.assertIsInstance(timestamp, int)
|
||||||
|
|
||||||
|
db_handler.update_ack_nak("Primary", timestamp, "hello", "Ack")
|
||||||
|
|
||||||
|
with sqlite3.connect(config.db_file_path) as conn:
|
||||||
|
row = conn.execute('SELECT user_id, message_text, ack_type FROM "123_Primary_messages"').fetchone()
|
||||||
|
|
||||||
|
self.assertEqual(row, ("123", "hello", "Ack"))
|
||||||
|
|
||||||
|
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
|
||||||
|
db_handler.update_node_info_in_db(999, short_name="ABCD")
|
||||||
|
|
||||||
|
original_long_name = db_handler.get_name_from_database(999, "long")
|
||||||
|
self.assertTrue(original_long_name.startswith("Meshtastic "))
|
||||||
|
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
|
||||||
|
self.assertEqual(db_handler.is_chat_archived(999), 0)
|
||||||
|
|
||||||
|
db_handler.update_node_info_in_db(999, chat_archived=1)
|
||||||
|
|
||||||
|
self.assertEqual(db_handler.get_name_from_database(999, "long"), original_long_name)
|
||||||
|
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
|
||||||
|
self.assertEqual(db_handler.is_chat_archived(999), 1)
|
||||||
|
|
||||||
|
def test_get_name_from_database_returns_hex_when_user_is_missing(self) -> None:
|
||||||
|
user_id = 0x1234ABCD
|
||||||
|
db_handler.ensure_node_table_exists()
|
||||||
|
|
||||||
|
self.assertEqual(db_handler.get_name_from_database(user_id, "short"), decimal_to_hex(user_id))
|
||||||
|
self.assertEqual(db_handler.is_chat_archived(user_id), 0)
|
||||||
|
|
||||||
|
def test_load_messages_from_db_populates_channels_and_messages(self) -> None:
|
||||||
|
db_handler.update_node_info_in_db(123, long_name="Local Node", short_name="ME")
|
||||||
|
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
|
||||||
|
db_handler.update_node_info_in_db(789, long_name="Archived", short_name="AR", chat_archived=1)
|
||||||
|
|
||||||
|
db_handler.ensure_table_exists(
|
||||||
|
'"123_Primary_messages"',
|
||||||
|
"""
|
||||||
|
user_id TEXT,
|
||||||
|
message_text TEXT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
ack_type TEXT
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
db_handler.ensure_table_exists(
|
||||||
|
'"123_789_messages"',
|
||||||
|
"""
|
||||||
|
user_id TEXT,
|
||||||
|
message_text TEXT,
|
||||||
|
timestamp INTEGER,
|
||||||
|
ack_type TEXT
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
with sqlite3.connect(config.db_file_path) as conn:
|
||||||
|
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("123", "sent", 1700000000, "Ack"))
|
||||||
|
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("456", "reply", 1700000001, None))
|
||||||
|
conn.execute('INSERT INTO "123_789_messages" VALUES (?, ?, ?, ?)', ("789", "hidden", 1700000002, None))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
ui_state.channel_list = []
|
||||||
|
ui_state.all_messages = {}
|
||||||
|
|
||||||
|
db_handler.load_messages_from_db()
|
||||||
|
|
||||||
|
self.assertIn("Primary", ui_state.channel_list)
|
||||||
|
self.assertNotIn(789, ui_state.channel_list)
|
||||||
|
self.assertIn("Primary", ui_state.all_messages)
|
||||||
|
self.assertIn(789, ui_state.all_messages)
|
||||||
|
|
||||||
|
messages = ui_state.all_messages["Primary"]
|
||||||
|
self.assertTrue(messages[0][0].startswith("-- "))
|
||||||
|
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in messages))
|
||||||
|
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
|
||||||
|
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
|
||||||
|
|
||||||
|
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
|
||||||
|
interface_state.interface = build_demo_interface()
|
||||||
|
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
|
||||||
|
|
||||||
|
db_handler.init_nodedb()
|
||||||
|
|
||||||
|
self.assertEqual(db_handler.get_name_from_database(2701131778, "short"), "SAT2")
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.ui import default_config
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultConfigTests(unittest.TestCase):
|
||||||
|
def test_get_localisation_options_filters_hidden_and_non_ini_files(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("en.ini", "ru.ini", ".hidden.ini", "notes.txt"):
|
||||||
|
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("")
|
||||||
|
|
||||||
|
self.assertEqual(default_config.get_localisation_options(tmpdir), ["en", "ru"])
|
||||||
|
|
||||||
|
def test_get_localisation_file_normalizes_extensions_and_falls_back_to_english(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for filename in ("en.ini", "ru.ini"):
|
||||||
|
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("")
|
||||||
|
|
||||||
|
self.assertTrue(default_config.get_localisation_file("RU.ini", tmpdir).endswith("/ru.ini"))
|
||||||
|
self.assertTrue(default_config.get_localisation_file("missing", tmpdir).endswith("/en.ini"))
|
||||||
|
|
||||||
|
def test_update_dict_only_adds_missing_values(self) -> None:
|
||||||
|
default = {"theme": "dark", "nested": {"language": "en", "sound": True}}
|
||||||
|
actual = {"nested": {"language": "ru"}}
|
||||||
|
|
||||||
|
updated = default_config.update_dict(default, actual)
|
||||||
|
|
||||||
|
self.assertTrue(updated)
|
||||||
|
self.assertEqual(actual, {"theme": "dark", "nested": {"language": "ru", "sound": True}})
|
||||||
|
|
||||||
|
def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> None:
|
||||||
|
rendered = default_config.format_json_single_line_arrays({"items": [1, 2], "nested": {"flags": ["a", "b"]}})
|
||||||
|
|
||||||
|
self.assertIn('"items": [1, 2]', rendered)
|
||||||
|
self.assertIn('"flags": ["a", "b"]', rendered)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import contact.__main__ as entrypoint
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.utilities.db_handler import get_name_from_database
|
||||||
|
from contact.utilities.demo_data import DEMO_CHANNELS, DEMO_LOCAL_NODE_NUM, build_demo_interface, configure_demo_database
|
||||||
|
from contact.utilities.singleton import interface_state, ui_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class DemoDataTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config("db_file_path", "node_sort", "single_pane_mode")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_build_demo_interface_exposes_expected_shape(self) -> None:
|
||||||
|
interface = build_demo_interface()
|
||||||
|
|
||||||
|
self.assertEqual(interface.getMyNodeInfo()["num"], DEMO_LOCAL_NODE_NUM)
|
||||||
|
self.assertEqual([channel.settings.name for channel in interface.getNode("^local").channels], DEMO_CHANNELS)
|
||||||
|
self.assertIn(DEMO_LOCAL_NODE_NUM, interface.nodesByNum)
|
||||||
|
|
||||||
|
def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) -> None:
|
||||||
|
interface_state.interface = build_demo_interface()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
demo_db_path = configure_demo_database(tmpdir)
|
||||||
|
with mock.patch.object(entrypoint.pub, "subscribe"):
|
||||||
|
entrypoint.initialize_globals(seed_demo=True)
|
||||||
|
|
||||||
|
self.assertEqual(config.db_file_path, demo_db_path)
|
||||||
|
self.assertIn("MediumFast", ui_state.channel_list)
|
||||||
|
self.assertIn("Another Channel", ui_state.channel_list)
|
||||||
|
self.assertIn(2701131788, ui_state.channel_list)
|
||||||
|
self.assertEqual(ui_state.node_list[0], DEMO_LOCAL_NODE_NUM)
|
||||||
|
self.assertEqual(get_name_from_database(2701131778, "short"), "SAT2")
|
||||||
|
|
||||||
|
medium_fast = ui_state.all_messages["MediumFast"]
|
||||||
|
self.assertTrue(medium_fast[0][0].startswith("-- "))
|
||||||
|
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in medium_fast))
|
||||||
|
self.assertTrue(any("SAT2:" in prefix for prefix, _ in medium_fast))
|
||||||
|
|
||||||
|
direct_messages = ui_state.all_messages[2701131788]
|
||||||
|
self.assertEqual(len(direct_messages), 3)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.utilities.emoji_utils import normalize_message_text
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiUtilsTests(unittest.TestCase):
|
||||||
|
def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None:
|
||||||
|
self.assertEqual(normalize_message_text("👍🏽 7️⃣"), "👍 7")
|
||||||
|
|
||||||
|
def test_rewrites_flag_emoji_to_country_codes(self) -> None:
|
||||||
|
self.assertEqual(normalize_message_text("🇺🇸 hello 🇩🇪"), "US hello DE")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.utilities import i18n
|
||||||
|
|
||||||
|
from tests.test_support import restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class I18nTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.saved_config = snapshot_config("language")
|
||||||
|
i18n._translations = {}
|
||||||
|
i18n._language = None
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
i18n._translations = {}
|
||||||
|
i18n._language = None
|
||||||
|
|
||||||
|
def test_t_loads_translation_file_and_formats_placeholders(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
translation_file = os.path.join(tmpdir, "xx.ini")
|
||||||
|
with open(translation_file, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write('[ui]\n')
|
||||||
|
handle.write('greeting,"Hello {name}"\n')
|
||||||
|
|
||||||
|
config.language = "xx"
|
||||||
|
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
|
||||||
|
self.assertEqual(i18n.t("ui.greeting", name="Ben"), "Hello Ben")
|
||||||
|
|
||||||
|
def test_t_falls_back_to_default_and_returns_unformatted_text_on_error(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
translation_file = os.path.join(tmpdir, "xx.ini")
|
||||||
|
with open(translation_file, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write('[ui]\n')
|
||||||
|
handle.write('greeting,"Hello {name}"\n')
|
||||||
|
|
||||||
|
config.language = "xx"
|
||||||
|
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
|
||||||
|
self.assertEqual(i18n.t("ui.greeting"), "Hello {name}")
|
||||||
|
self.assertEqual(i18n.t("ui.missing", default="Fallback"), "Fallback")
|
||||||
|
self.assertEqual(i18n.t_text("Literal {value}", value=7), "Literal 7")
|
||||||
|
|
||||||
|
def test_loader_cache_is_reused_until_language_changes(self) -> None:
|
||||||
|
config.language = "en"
|
||||||
|
|
||||||
|
with mock.patch.object(i18n, "parse_ini_file", return_value=({"key": "value"}, {})) as parse_ini_file:
|
||||||
|
self.assertEqual(i18n.t("key"), "value")
|
||||||
|
self.assertEqual(i18n.t("key"), "value")
|
||||||
|
self.assertEqual(parse_ini_file.call_count, 1)
|
||||||
|
|
||||||
|
config.language = "ru"
|
||||||
|
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
|
||||||
|
self.assertEqual(parse_ini_file.call_count, 2)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from contact.utilities.ini_utils import parse_ini_file
|
||||||
|
|
||||||
|
|
||||||
|
class IniUtilsTests(unittest.TestCase):
|
||||||
|
def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ini_path = os.path.join(tmpdir, "settings.ini")
|
||||||
|
with open(ini_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write('; comment\n')
|
||||||
|
handle.write('[config.device]\n')
|
||||||
|
handle.write('title,"Device","Device help"\n')
|
||||||
|
handle.write('name,"Node Name","Node help"\n')
|
||||||
|
handle.write('empty_help,"Fallback",""\n')
|
||||||
|
|
||||||
|
with mock.patch("contact.utilities.ini_utils.i18n.t", return_value="No help available."):
|
||||||
|
mapping, help_text = parse_ini_file(ini_path)
|
||||||
|
|
||||||
|
self.assertEqual(mapping["config.device"], "Device")
|
||||||
|
self.assertEqual(help_text["config.device"], "Device help")
|
||||||
|
self.assertEqual(mapping["config.device.name"], "Node Name")
|
||||||
|
self.assertEqual(help_text["config.device.name"], "Node help")
|
||||||
|
self.assertEqual(help_text["config.device.empty_help"], "No help available.")
|
||||||
|
|
||||||
|
def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
ini_path = os.path.join(tmpdir, "settings.ini")
|
||||||
|
with open(ini_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write('[section]\n')
|
||||||
|
handle.write('name,"Name"\n')
|
||||||
|
|
||||||
|
with mock.patch("contact.utilities.ini_utils.i18n.t", side_effect=RuntimeError("boom")):
|
||||||
|
mapping, help_text = parse_ini_file(ini_path)
|
||||||
|
|
||||||
|
self.assertEqual(mapping["section.name"], "Name")
|
||||||
|
self.assertEqual(help_text["section.name"], "No help available.")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from argparse import Namespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from contact.utilities.interfaces import reconnect_interface
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
|
||||||
|
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
|
||||||
|
|
||||||
|
self.assertEqual(result, "iface")
|
||||||
|
self.assertEqual(initialize.call_count, 3)
|
||||||
|
self.assertEqual(sleep.call_count, 2)
|
||||||
|
|
||||||
|
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
|
||||||
|
args = Namespace()
|
||||||
|
|
||||||
|
with mock.patch("contact.utilities.interfaces.initialize_interface", return_value=None):
|
||||||
|
with mock.patch("contact.utilities.interfaces.time.sleep"):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
reconnect_interface(args, attempts=2, delay_seconds=0)
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
from argparse import Namespace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import contact.__main__ as entrypoint
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.utilities.singleton import interface_state, ui_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class MainRuntimeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config("single_pane_mode")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
|
||||||
|
args = Namespace(demo_screenshot=True)
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "configure_demo_database") as configure_demo_database:
|
||||||
|
with mock.patch.object(entrypoint, "build_demo_interface", return_value="demo-interface") as build_demo:
|
||||||
|
with mock.patch.object(entrypoint, "initialize_interface") as initialize_interface:
|
||||||
|
result = entrypoint.initialize_runtime_interface(args)
|
||||||
|
|
||||||
|
self.assertEqual(result, "demo-interface")
|
||||||
|
configure_demo_database.assert_called_once_with()
|
||||||
|
build_demo.assert_called_once_with()
|
||||||
|
initialize_interface.assert_not_called()
|
||||||
|
|
||||||
|
def test_initialize_runtime_interface_uses_live_branch_without_demo_flag(self) -> None:
|
||||||
|
args = Namespace(demo_screenshot=False)
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "initialize_interface", return_value="live-interface") as initialize_interface:
|
||||||
|
result = entrypoint.initialize_runtime_interface(args)
|
||||||
|
|
||||||
|
self.assertEqual(result, "live-interface")
|
||||||
|
initialize_interface.assert_called_once_with(args)
|
||||||
|
|
||||||
|
def test_interface_is_ready_detects_missing_local_node(self) -> None:
|
||||||
|
self.assertFalse(entrypoint.interface_is_ready(object()))
|
||||||
|
self.assertTrue(entrypoint.interface_is_ready(SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))))
|
||||||
|
|
||||||
|
def test_initialize_runtime_interface_with_retry_retries_until_node_is_ready(self) -> None:
|
||||||
|
args = Namespace(demo_screenshot=False)
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
bad_interface = mock.Mock(spec=["close"])
|
||||||
|
good_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "initialize_runtime_interface", side_effect=[bad_interface, good_interface]):
|
||||||
|
with mock.patch.object(entrypoint, "get_list_input", return_value="Retry") as get_list_input:
|
||||||
|
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||||
|
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
|
||||||
|
|
||||||
|
self.assertIs(result, good_interface)
|
||||||
|
get_list_input.assert_called_once()
|
||||||
|
bad_interface.close.assert_called_once_with()
|
||||||
|
draw_splash.assert_called_once_with(stdscr)
|
||||||
|
|
||||||
|
def test_initialize_runtime_interface_with_retry_returns_none_when_user_closes(self) -> None:
|
||||||
|
args = Namespace(demo_screenshot=False)
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
bad_interface = mock.Mock(spec=["close"])
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "initialize_runtime_interface", return_value=bad_interface):
|
||||||
|
with mock.patch.object(entrypoint, "get_list_input", return_value="Close") as get_list_input:
|
||||||
|
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||||
|
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
get_list_input.assert_called_once()
|
||||||
|
bad_interface.close.assert_called_once_with()
|
||||||
|
draw_splash.assert_not_called()
|
||||||
|
|
||||||
|
def test_prompt_region_if_unset_reinitializes_interface_after_confirmation(self) -> None:
|
||||||
|
args = Namespace()
|
||||||
|
old_interface = mock.Mock()
|
||||||
|
new_interface = mock.Mock()
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
interface_state.interface = old_interface
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "get_list_input", return_value="Yes"):
|
||||||
|
with mock.patch.object(entrypoint, "set_region") as set_region:
|
||||||
|
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
|
||||||
|
with mock.patch.object(entrypoint, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||||
|
entrypoint.prompt_region_if_unset(args, stdscr)
|
||||||
|
|
||||||
|
set_region.assert_called_once_with(old_interface)
|
||||||
|
old_interface.close.assert_called_once_with()
|
||||||
|
draw_splash.assert_called_once_with(stdscr)
|
||||||
|
reconnect.assert_called_once_with(args)
|
||||||
|
self.assertIs(interface_state.interface, new_interface)
|
||||||
|
|
||||||
|
def test_prompt_region_if_unset_leaves_interface_unchanged_when_declined(self) -> None:
|
||||||
|
args = Namespace()
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface_state.interface = interface
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "get_list_input", return_value="No"):
|
||||||
|
with mock.patch.object(entrypoint, "set_region") as set_region:
|
||||||
|
with mock.patch.object(entrypoint, "reconnect_interface") as reconnect:
|
||||||
|
entrypoint.prompt_region_if_unset(args)
|
||||||
|
|
||||||
|
set_region.assert_not_called()
|
||||||
|
reconnect.assert_not_called()
|
||||||
|
interface.close.assert_not_called()
|
||||||
|
self.assertIs(interface_state.interface, interface)
|
||||||
|
|
||||||
|
def test_initialize_globals_resets_and_populates_runtime_state(self) -> None:
|
||||||
|
ui_state.channel_list = ["stale"]
|
||||||
|
ui_state.all_messages = {"stale": [("old", "message")]}
|
||||||
|
ui_state.notifications = [1]
|
||||||
|
ui_state.packet_buffer = ["packet"]
|
||||||
|
ui_state.node_list = [99]
|
||||||
|
ui_state.selected_channel = 3
|
||||||
|
ui_state.selected_message = 4
|
||||||
|
ui_state.selected_node = 5
|
||||||
|
ui_state.start_index = [9, 9, 9]
|
||||||
|
config.single_pane_mode = "True"
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "get_nodeNum", return_value=123):
|
||||||
|
with mock.patch.object(entrypoint, "get_channels", return_value=["Primary"]) as get_channels:
|
||||||
|
with mock.patch.object(entrypoint, "get_node_list", return_value=[123, 456]) as get_node_list:
|
||||||
|
with mock.patch.object(entrypoint.pub, "subscribe") as subscribe:
|
||||||
|
with mock.patch.object(entrypoint, "init_nodedb") as init_nodedb:
|
||||||
|
with mock.patch.object(entrypoint, "seed_demo_messages") as seed_demo_messages:
|
||||||
|
with mock.patch.object(entrypoint, "load_messages_from_db") as load_messages:
|
||||||
|
entrypoint.initialize_globals(seed_demo=True)
|
||||||
|
|
||||||
|
self.assertEqual(ui_state.channel_list, ["Primary"])
|
||||||
|
self.assertEqual(ui_state.all_messages, {})
|
||||||
|
self.assertEqual(ui_state.notifications, [])
|
||||||
|
self.assertEqual(ui_state.packet_buffer, [])
|
||||||
|
self.assertEqual(ui_state.node_list, [123, 456])
|
||||||
|
self.assertEqual(ui_state.selected_channel, 0)
|
||||||
|
self.assertEqual(ui_state.selected_message, 0)
|
||||||
|
self.assertEqual(ui_state.selected_node, 0)
|
||||||
|
self.assertEqual(ui_state.start_index, [0, 0, 0])
|
||||||
|
self.assertTrue(ui_state.single_pane_mode)
|
||||||
|
self.assertEqual(interface_state.myNodeNum, 123)
|
||||||
|
get_channels.assert_called_once_with()
|
||||||
|
get_node_list.assert_called_once_with()
|
||||||
|
subscribe.assert_called_once_with(entrypoint.on_receive, "meshtastic.receive")
|
||||||
|
init_nodedb.assert_called_once_with()
|
||||||
|
seed_demo_messages.assert_called_once_with()
|
||||||
|
load_messages.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) -> None:
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
stdscr.getmaxyx.side_effect = [(10, 80), (11, 80)]
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "dialog") as dialog:
|
||||||
|
with mock.patch.object(entrypoint.curses, "update_lines_cols") as update_lines_cols:
|
||||||
|
entrypoint.ensure_min_rows(stdscr, min_rows=11)
|
||||||
|
|
||||||
|
dialog.assert_called_once()
|
||||||
|
update_lines_cols.assert_called_once_with()
|
||||||
|
stdscr.clear.assert_called_once_with()
|
||||||
|
stdscr.refresh.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_start_prints_help_and_exits_zero(self) -> None:
|
||||||
|
parser = mock.Mock()
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint.sys, "argv", ["contact", "--help"]):
|
||||||
|
with mock.patch.object(entrypoint, "setup_parser", return_value=parser):
|
||||||
|
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||||
|
with self.assertRaises(SystemExit) as raised:
|
||||||
|
entrypoint.start()
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.code, 0)
|
||||||
|
parser.print_help.assert_called_once_with()
|
||||||
|
exit_mock.assert_called_once_with(0)
|
||||||
|
|
||||||
|
def test_start_runs_curses_wrapper_and_closes_interface(self) -> None:
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface_state.interface = interface
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||||
|
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
|
||||||
|
entrypoint.start()
|
||||||
|
|
||||||
|
wrapper.assert_called_once_with(entrypoint.main)
|
||||||
|
interface.close.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
|
||||||
|
stdscr = mock.Mock()
|
||||||
|
args = Namespace(settings=False, demo_screenshot=False)
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint, "setup_colors"):
|
||||||
|
with mock.patch.object(entrypoint, "ensure_min_rows"):
|
||||||
|
with mock.patch.object(entrypoint, "draw_splash"):
|
||||||
|
with mock.patch.object(entrypoint, "setup_parser") as setup_parser:
|
||||||
|
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
|
||||||
|
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
|
||||||
|
setup_parser.return_value.parse_args.return_value = args
|
||||||
|
entrypoint.main(stdscr)
|
||||||
|
|
||||||
|
initialize_globals.assert_not_called()
|
||||||
|
|
||||||
|
def test_start_handles_keyboard_interrupt(self) -> None:
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface_state.interface = interface
|
||||||
|
|
||||||
|
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||||
|
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
|
||||||
|
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||||
|
with self.assertRaises(SystemExit) as raised:
|
||||||
|
entrypoint.start()
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.code, 0)
|
||||||
|
interface.close.assert_called_once_with()
|
||||||
|
exit_mock.assert_called_once_with(0)
|
||||||
|
|
||||||
|
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
|
||||||
|
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("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:
|
||||||
|
entrypoint.start()
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.code, 1)
|
||||||
|
endwin.assert_called_once_with()
|
||||||
|
print_exc.assert_called_once_with()
|
||||||
|
print_mock.assert_any_call("Fatal error:", mock.ANY)
|
||||||
|
exit_mock.assert_called_once_with(1)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.message_handlers import rx_handler
|
||||||
|
from contact.utilities.singleton import interface_state, menu_state, ui_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class RxHandlerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config("notification_sound", "message_prefix")
|
||||||
|
config.notification_sound = "False"
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = ["Primary"]
|
||||||
|
ui_state.all_messages = {"Primary": []}
|
||||||
|
ui_state.selected_channel = 0
|
||||||
|
|
||||||
|
packet = {
|
||||||
|
"from": 222,
|
||||||
|
"to": 999,
|
||||||
|
"channel": 0,
|
||||||
|
"hopStart": 3,
|
||||||
|
"hopLimit": 1,
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch.object(rx_handler, "refresh_node_list", return_value=True):
|
||||||
|
with mock.patch.object(rx_handler, "draw_node_list") as draw_node_list:
|
||||||
|
with mock.patch.object(rx_handler, "draw_messages_window") as draw_messages_window:
|
||||||
|
with mock.patch.object(rx_handler, "draw_channel_list") as draw_channel_list:
|
||||||
|
with mock.patch.object(rx_handler, "add_notification") as add_notification:
|
||||||
|
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
|
||||||
|
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||||
|
rx_handler.on_receive(packet, interface=None)
|
||||||
|
|
||||||
|
draw_node_list.assert_called_once_with()
|
||||||
|
draw_messages_window.assert_called_once_with(True)
|
||||||
|
draw_channel_list.assert_not_called()
|
||||||
|
add_notification.assert_not_called()
|
||||||
|
save_message_to_db.assert_called_once_with("Primary", 222, "hello")
|
||||||
|
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||||
|
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
|
||||||
|
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
|
||||||
|
|
||||||
|
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = ["Primary"]
|
||||||
|
ui_state.all_messages = {"Primary": []}
|
||||||
|
ui_state.selected_channel = 0
|
||||||
|
|
||||||
|
packet = {
|
||||||
|
"from": 222,
|
||||||
|
"to": 111,
|
||||||
|
"hopStart": 1,
|
||||||
|
"hopLimit": 1,
|
||||||
|
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"dm"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
|
||||||
|
with mock.patch.object(rx_handler, "draw_messages_window") as draw_messages_window:
|
||||||
|
with mock.patch.object(rx_handler, "draw_channel_list") as draw_channel_list:
|
||||||
|
with mock.patch.object(rx_handler, "add_notification") as add_notification:
|
||||||
|
with mock.patch.object(rx_handler, "update_node_info_in_db") as update_node_info_in_db:
|
||||||
|
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
|
||||||
|
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
|
||||||
|
rx_handler.on_receive(packet, interface=None)
|
||||||
|
|
||||||
|
self.assertIn(222, ui_state.channel_list)
|
||||||
|
self.assertIn(222, ui_state.all_messages)
|
||||||
|
draw_messages_window.assert_not_called()
|
||||||
|
draw_channel_list.assert_called_once_with()
|
||||||
|
add_notification.assert_called_once_with(1)
|
||||||
|
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
|
||||||
|
save_message_to_db.assert_called_once_with(222, 222, "dm")
|
||||||
|
|
||||||
|
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
|
||||||
|
ui_state.packet_buffer = list(range(25))
|
||||||
|
ui_state.display_log = True
|
||||||
|
ui_state.current_window = 4
|
||||||
|
|
||||||
|
with mock.patch.object(rx_handler, "draw_packetlog_win") as draw_packetlog_win:
|
||||||
|
rx_handler.on_receive({"id": "new"}, interface=None)
|
||||||
|
|
||||||
|
draw_packetlog_win.assert_called_once_with()
|
||||||
|
self.assertEqual(len(ui_state.packet_buffer), 20)
|
||||||
|
self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"})
|
||||||
|
self.assertTrue(menu_state.need_redraw)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from contact.utilities.save_to_radio import save_changes
|
||||||
|
|
||||||
|
|
||||||
|
class SaveToRadioTests(unittest.TestCase):
|
||||||
|
def build_interface(self):
|
||||||
|
node = mock.Mock()
|
||||||
|
node.localConfig = SimpleNamespace(
|
||||||
|
lora=SimpleNamespace(region=0, serial_enabled=False),
|
||||||
|
device=SimpleNamespace(role="CLIENT", name="node"),
|
||||||
|
security=SimpleNamespace(debug_log_api_enabled=False, serial_enabled=False, admin_key=[]),
|
||||||
|
display=SimpleNamespace(flip_screen=False, units=0),
|
||||||
|
power=SimpleNamespace(is_power_saving=False, adc_enabled=False),
|
||||||
|
network=SimpleNamespace(wifi_enabled=False),
|
||||||
|
bluetooth=SimpleNamespace(enabled=False),
|
||||||
|
)
|
||||||
|
node.moduleConfig = SimpleNamespace(mqtt=SimpleNamespace(enabled=False))
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface.getNode.return_value = node
|
||||||
|
return interface, node
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_for_lora_writes_that_require_reconnect(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"region": 7}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
self.assertEqual(node.localConfig.lora.region, 7)
|
||||||
|
node.writeConfig.assert_called_once_with("lora")
|
||||||
|
|
||||||
|
def test_save_changes_returns_false_when_nothing_changed(self) -> None:
|
||||||
|
interface = mock.Mock()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
|
||||||
|
|
||||||
|
self.assertFalse(save_changes(interface, {}, menu_state))
|
||||||
|
|
||||||
|
def test_save_changes_returns_false_for_non_rebooting_security_fields(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"serial_enabled": True}, menu_state)
|
||||||
|
|
||||||
|
self.assertFalse(reconnect_required)
|
||||||
|
self.assertTrue(node.localConfig.security.serial_enabled)
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_for_rebooting_security_fields(self) -> None:
|
||||||
|
interface, _node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"admin_key": [b"12345678"]}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
self.assertFalse(save_changes(interface, {"name": "renamed"}, menu_state))
|
||||||
|
self.assertEqual(node.localConfig.device.name, "renamed")
|
||||||
|
|
||||||
|
node.writeConfig.reset_mock()
|
||||||
|
self.assertTrue(save_changes(interface, {"role": "ROUTER"}, menu_state))
|
||||||
|
self.assertEqual(node.localConfig.device.role, "ROUTER")
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_for_network_settings(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Network"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"wifi_enabled": True}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
self.assertTrue(node.localConfig.network.wifi_enabled)
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_only_for_rebooting_power_fields(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Power"])
|
||||||
|
|
||||||
|
self.assertFalse(save_changes(interface, {"adc_enabled": True}, menu_state))
|
||||||
|
self.assertTrue(node.localConfig.power.adc_enabled)
|
||||||
|
|
||||||
|
node.writeConfig.reset_mock()
|
||||||
|
self.assertTrue(save_changes(interface, {"is_power_saving": True}, menu_state))
|
||||||
|
self.assertTrue(node.localConfig.power.is_power_saving)
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_for_module_settings(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "Module Settings", "Mqtt"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"enabled": True}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
self.assertTrue(node.moduleConfig.mqtt.enabled)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"longName": "Node"}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
node.setOwner.assert_called_once()
|
||||||
|
|
||||||
|
def test_save_changes_returns_true_for_user_license_changes(self) -> None:
|
||||||
|
interface, node = self.build_interface()
|
||||||
|
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||||
|
|
||||||
|
reconnect_required = save_changes(interface, {"isLicensed": True}, menu_state)
|
||||||
|
|
||||||
|
self.assertTrue(reconnect_required)
|
||||||
|
node.setOwner.assert_called_once()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import threading
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.ui.ui_state import AppState, ChatUIState, InterfaceState, MenuState
|
||||||
|
from contact.utilities.singleton import app_state, interface_state, menu_state, ui_state
|
||||||
|
|
||||||
|
|
||||||
|
def reset_singletons() -> None:
|
||||||
|
_reset_instance(ui_state, ChatUIState())
|
||||||
|
_reset_instance(interface_state, InterfaceState())
|
||||||
|
_reset_instance(menu_state, MenuState())
|
||||||
|
_reset_instance(app_state, AppState())
|
||||||
|
app_state.lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def restore_config(saved: dict) -> None:
|
||||||
|
for key, value in saved.items():
|
||||||
|
setattr(config, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_config(*keys: str) -> dict:
|
||||||
|
return {key: getattr(config, key) for key in keys}
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_instance(target: object, replacement: object) -> None:
|
||||||
|
target.__dict__.clear()
|
||||||
|
target.__dict__.update(replacement.__dict__)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_direction
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryBeautifierTests(unittest.TestCase):
|
||||||
|
def test_humanize_wind_direction_handles_boundaries(self) -> None:
|
||||||
|
self.assertEqual(humanize_wind_direction(0), "N")
|
||||||
|
self.assertEqual(humanize_wind_direction(90), "E")
|
||||||
|
self.assertEqual(humanize_wind_direction(225), "SW")
|
||||||
|
self.assertIsNone(humanize_wind_direction(-1))
|
||||||
|
|
||||||
|
def test_get_chunks_formats_known_and_unknown_values(self) -> None:
|
||||||
|
rendered = get_chunks("uptime_seconds:7200\nwind_direction:90\nlatitude_i:123456789\nunknown:abc\n")
|
||||||
|
|
||||||
|
self.assertIn("🆙 2.0h", rendered)
|
||||||
|
self.assertIn("⮆ E", rendered)
|
||||||
|
self.assertIn("🌍 12.345679", rendered)
|
||||||
|
self.assertIn("unknown:abc", rendered)
|
||||||
|
|
||||||
|
def test_get_chunks_formats_time_values(self) -> None:
|
||||||
|
with mock.patch("contact.utilities.telemetry_beautifier.datetime.datetime") as mocked_datetime:
|
||||||
|
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "01.01.1970 00:00"
|
||||||
|
rendered = get_chunks("time:0\n")
|
||||||
|
|
||||||
|
self.assertIn("🕔 01.01.1970 00:00", rendered)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from meshtastic import BROADCAST_NUM
|
||||||
|
|
||||||
|
import contact.ui.default_config as config
|
||||||
|
from contact.message_handlers import tx_handler
|
||||||
|
from contact.utilities.singleton import interface_state, ui_state
|
||||||
|
|
||||||
|
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class TxHandlerTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
tx_handler.ack_naks.clear()
|
||||||
|
self.saved_config = snapshot_config("sent_message_prefix", "ack_str", "ack_implicit_str", "nak_str", "ack_unknown_str")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
tx_handler.ack_naks.clear()
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface.sendText.return_value = SimpleNamespace(id="req-1")
|
||||||
|
interface_state.interface = interface
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = ["Primary"]
|
||||||
|
ui_state.all_messages = {"Primary": []}
|
||||||
|
|
||||||
|
with mock.patch.object(tx_handler, "save_message_to_db", return_value=999) as save_message_to_db:
|
||||||
|
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
|
||||||
|
tx_handler.send_message("hello", channel=0)
|
||||||
|
|
||||||
|
interface.sendText.assert_called_once_with(
|
||||||
|
text="hello",
|
||||||
|
destinationId=BROADCAST_NUM,
|
||||||
|
wantAck=True,
|
||||||
|
wantResponse=False,
|
||||||
|
onResponse=tx_handler.onAckNak,
|
||||||
|
channelIndex=0,
|
||||||
|
)
|
||||||
|
save_message_to_db.assert_called_once_with("Primary", 111, "hello")
|
||||||
|
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
|
||||||
|
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
|
||||||
|
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
|
||||||
|
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||||
|
|
||||||
|
def test_send_message_to_direct_node_uses_node_as_destination(self) -> None:
|
||||||
|
interface = mock.Mock()
|
||||||
|
interface.sendText.return_value = SimpleNamespace(id="req-2")
|
||||||
|
interface_state.interface = interface
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = [222]
|
||||||
|
ui_state.all_messages = {222: []}
|
||||||
|
|
||||||
|
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
|
||||||
|
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
|
||||||
|
tx_handler.send_message("dm", channel=0)
|
||||||
|
|
||||||
|
interface.sendText.assert_called_once_with(
|
||||||
|
text="dm",
|
||||||
|
destinationId=222,
|
||||||
|
wantAck=True,
|
||||||
|
wantResponse=False,
|
||||||
|
onResponse=tx_handler.onAckNak,
|
||||||
|
channelIndex=0,
|
||||||
|
)
|
||||||
|
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
|
||||||
|
|
||||||
|
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = ["Primary"]
|
||||||
|
ui_state.selected_channel = 0
|
||||||
|
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||||
|
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||||
|
|
||||||
|
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||||
|
|
||||||
|
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
|
||||||
|
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
|
||||||
|
with mock.patch("contact.ui.contact_ui.draw_messages_window") as draw_messages_window:
|
||||||
|
tx_handler.onAckNak(packet)
|
||||||
|
|
||||||
|
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack")
|
||||||
|
draw_messages_window.assert_called_once_with()
|
||||||
|
self.assertIn(config.sent_message_prefix, ui_state.all_messages["Primary"][0][0])
|
||||||
|
self.assertIn(config.ack_str, ui_state.all_messages["Primary"][0][0])
|
||||||
|
|
||||||
|
def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None:
|
||||||
|
interface_state.myNodeNum = 111
|
||||||
|
ui_state.channel_list = ["Primary"]
|
||||||
|
ui_state.selected_channel = 0
|
||||||
|
ui_state.all_messages = {"Primary": [("pending", "hello")]}
|
||||||
|
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55}
|
||||||
|
|
||||||
|
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
|
||||||
|
|
||||||
|
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
|
||||||
|
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
|
||||||
|
with mock.patch("contact.ui.contact_ui.draw_messages_window"):
|
||||||
|
tx_handler.onAckNak(packet)
|
||||||
|
|
||||||
|
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Implicit")
|
||||||
|
self.assertIn(config.ack_implicit_str, ui_state.all_messages["Primary"][0][0])
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
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 tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
reset_singletons()
|
||||||
|
self.saved_config = snapshot_config("node_sort")
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
restore_config(self.saved_config)
|
||||||
|
reset_singletons()
|
||||||
|
|
||||||
|
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
|
||||||
|
config.node_sort = "lastHeard"
|
||||||
|
interface = build_demo_interface()
|
||||||
|
interface_state.interface = interface
|
||||||
|
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
|
||||||
|
|
||||||
|
node_list = get_node_list()
|
||||||
|
|
||||||
|
self.assertEqual(node_list[0], DEMO_LOCAL_NODE_NUM)
|
||||||
|
self.assertEqual(node_list[-1], 0xA1000008)
|
||||||
|
|
||||||
|
def test_add_new_message_groups_messages_by_hour(self) -> None:
|
||||||
|
ui_state.all_messages = {"MediumFast": []}
|
||||||
|
|
||||||
|
with mock.patch("contact.utilities.utils.time.time", side_effect=[1000, 1000]):
|
||||||
|
with mock.patch("contact.utilities.utils.time.strftime", return_value="[00:16:40] "):
|
||||||
|
with mock.patch("contact.utilities.utils.datetime.datetime") as mocked_datetime:
|
||||||
|
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "2025-02-04 17:00"
|
||||||
|
add_new_message("MediumFast", ">> Test: ", "First")
|
||||||
|
add_new_message("MediumFast", ">> Test: ", "Second")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
ui_state.all_messages["MediumFast"],
|
||||||
|
[
|
||||||
|
("-- 2025-02-04 17:00 --", ""),
|
||||||
|
("[00:16:40] >> Test: ", "First"),
|
||||||
|
("[00:16:40] >> Test: ", "Second"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_channels_populates_message_buckets_for_device_channels(self) -> None:
|
||||||
|
interface_state.interface = build_demo_interface()
|
||||||
|
ui_state.channel_list = []
|
||||||
|
ui_state.all_messages = {}
|
||||||
|
|
||||||
|
channels = get_channels()
|
||||||
|
|
||||||
|
self.assertIn("MediumFast", channels)
|
||||||
|
self.assertIn("Another Channel", channels)
|
||||||
|
self.assertIn("MediumFast", ui_state.all_messages)
|
||||||
|
self.assertIn("Another Channel", ui_state.all_messages)
|
||||||
|
|
||||||
|
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
|
||||||
|
interface = build_demo_interface()
|
||||||
|
interface.localNode.channels[0].settings.name = "Renamed Channel"
|
||||||
|
interface_state.interface = interface
|
||||||
|
ui_state.channel_list = ["MediumFast", "Another Channel", 2701131788]
|
||||||
|
ui_state.all_messages = {
|
||||||
|
"MediumFast": [("prefix", "first")],
|
||||||
|
"Another Channel": [("prefix", "second")],
|
||||||
|
2701131788: [("prefix", "dm")],
|
||||||
|
}
|
||||||
|
ui_state.selected_channel = 2
|
||||||
|
|
||||||
|
channels = get_channels()
|
||||||
|
|
||||||
|
self.assertEqual(channels[0], "Renamed Channel")
|
||||||
|
self.assertEqual(channels[1], "Another Channel")
|
||||||
|
self.assertEqual(channels[2], 2701131788)
|
||||||
|
self.assertEqual(ui_state.all_messages["Renamed Channel"], [("prefix", "first")])
|
||||||
|
self.assertEqual(ui_state.all_messages["Another Channel"], [("prefix", "second")])
|
||||||
|
self.assertEqual(ui_state.all_messages[2701131788], [("prefix", "dm")])
|
||||||
|
self.assertNotIn("MediumFast", ui_state.all_messages)
|
||||||
|
|
||||||
|
def test_parse_protobuf_returns_string_payload_unchanged(self) -> None:
|
||||||
|
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": "hello"}}
|
||||||
|
|
||||||
|
self.assertEqual(parse_protobuf(packet), "hello")
|
||||||
|
|
||||||
|
def test_parse_protobuf_returns_placeholder_for_text_messages(self) -> None:
|
||||||
|
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}}
|
||||||
|
|
||||||
|
self.assertEqual(parse_protobuf(packet), "✉️")
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from contact.utilities.validation_rules import get_validation_for
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationRulesTests(unittest.TestCase):
|
||||||
|
def test_get_validation_for_matches_exact_keys(self) -> None:
|
||||||
|
self.assertEqual(get_validation_for("shortName"), {"max_length": 4})
|
||||||
|
|
||||||
|
def test_get_validation_for_matches_substrings(self) -> None:
|
||||||
|
self.assertEqual(get_validation_for("config.position.latitude"), {"min_value": -90, "max_value": 90})
|
||||||
|
|
||||||
|
def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -> None:
|
||||||
|
self.assertEqual(get_validation_for("totally_unknown"), {})
|
||||||
Reference in New Issue
Block a user