Compare commits

...

43 Commits

Author SHA1 Message Date
pdxlocations
b53dab1840 Add factory reset config option and tryfix factory reset 2026-03-21 21:28:15 -07:00
pdxlocations
f2904eb550 Merge pull request #259 from pdxlocations:fix-shutdown
Fix interface shutdown handling
2026-03-21 21:14:38 -07:00
pdxlocations
480c32ba56 try fix shutdown 2026-03-21 21:13:53 -07:00
pdxlocations
b4b084b627 bump version to 1.5.4 in pyproject.toml 2026-03-19 15:47:51 -07:00
pdxlocations
5940c9b02b fix content margins 2026-03-19 15:19:24 -07:00
pdxlocations
c492c96685 bump version to 1.5.3 in pyproject.toml 2026-03-19 14:37:29 -07:00
pdxlocations
90376d35f3 Single pane mode fix 2026-03-19 14:37:14 -07:00
pdxlocations
4b35a74e2c bump version to 1.5.2 in pyproject.toml 2026-03-19 14:07:16 -07:00
pdxlocations
ecc5308dad Refactor UI redraw handling and improve message drawing logic 2026-03-19 14:06:01 -07:00
pdxlocations
8f376edabe bump version to 1.5.1 in pyproject.toml 2026-03-19 11:41:08 -07:00
pdxlocations
e5ef87ec19 Merge pull request #258 from pdxlocations:reconnect
Reconnect after config changes
2026-03-19 11:40:41 -07:00
pdxlocations
1b6d269d50 Reconnect after config changes 2026-03-19 11:40:24 -07:00
pdxlocations
1d95dae536 Merge pull request #257 from pdxlocations:tests
Add tests
2026-03-19 11:05:03 -07:00
pdxlocations
705b25192c full test suite 2026-03-19 09:49:11 -07:00
pdxlocations
6c5ae3b168 Add tests and demo screenshot 2026-03-18 23:00:49 -07:00
pdxlocations
02b4866a38 Update README.md 2026-03-18 22:58:41 -07:00
pdxlocations
286b5a531b Bump version to 1.5.0 in pyproject.toml 2026-03-18 22:37:31 -07:00
pdxlocations
004868c7fc Merge pull request #256 from pdxlocations:fix-search
Fix ctrl / on mac terminal
2026-03-18 22:36:39 -07:00
pdxlocations
b43e3f4868 Fix ctrl / on mac terminal 2026-03-18 22:36:23 -07:00
pdxlocations
2e8e21f5ba Merge pull request #255 from pdxlocations:info-window
scroll nodes in info window
2026-03-18 22:33:38 -07:00
pdxlocations
09f0d626df scroll nodes in info window 2026-03-18 22:33:07 -07:00
pdxlocations
8c37f2394b Merge pull request #254 from pdxlocations:single-pane-reload
single pane reload configuration on save
2026-03-18 22:21:53 -07:00
pdxlocations
568f29ee29 single pane reload configuration on save 2026-03-18 22:21:36 -07:00
pdxlocations
87127adef1 Merge pull request #253 from pdxlocations:UI
Enhance UI color handling for channel and node rows, and refactor refresh logic
2026-03-18 22:17:27 -07:00
pdxlocations
dd0eb1473c Enhance UI color handling for channel and node rows, and refactor refresh logic 2026-03-18 22:17:08 -07:00
pdxlocations
7d9703a548 Merge pull request #252 from pdxlocations:normalize-emojis
Add emoji normalization utility and integrate into message rendering
2026-03-18 21:37:02 -07:00
pdxlocations
68b8d15181 Add emoji normalization utility and integrate into message rendering 2026-03-18 21:36:46 -07:00
pdxlocations
4ef87871df bump version 2026-03-12 19:37:21 -07:00
pdxlocations
18df7d326a Merge pull request #250 from pdxlocations:resize-faster
Add resize event handling
2026-03-12 16:21:15 -07:00
pdxlocations
c7b54caf45 Add resize event handling and update version to 1.4.18 2026-03-12 15:30:03 -07:00
pdxlocations
773f43edd8 Merge pull request #248 from pdxlocations:fix-protubuf-depend
Fix-protubuf-depend
2026-03-02 16:31:55 -08:00
pdxlocations
6af1c46bd3 bump version to 1.4.17 2026-03-02 16:31:33 -08:00
pdxlocations
7e3e44df24 Refactor repeated field handling in protobuf utilities 2026-03-02 16:31:09 -08:00
pdxlocations
45626f5e83 bump version 2026-02-28 10:32:43 -08:00
pdxlocations
e9181972b2 bump version 2026-02-28 10:32:16 -08:00
pdxlocations
795ab84ef5 Fix 3.9 compatibility 2026-02-28 10:31:48 -08:00
pdxlocations
5e108c5fe5 version bump 2026-02-12 07:45:41 -08:00
pdxlocations
edef37b116 IP bug fix 2026-02-12 07:38:43 -08:00
pdxlocations
e7e1bf7852 Merge pull request #246 from pdxlocations:display-ip
Display Human-Readable IP's and Actually Save Nested Configs
2026-02-11 21:57:54 -08:00
pdxlocations
1c2384ea8d actually save nested configs 2026-02-11 21:56:45 -08:00
pdxlocations
4cda264746 Display IPs Correctly 2026-02-11 21:40:42 -08:00
pdxlocations
0005aaf438 Bump version to 1.4.13 in pyproject.toml 2026-01-24 00:08:59 -08:00
pdxlocations
f39a09646a fix No Help Available translation 2026-01-24 00:08:33 -08:00
50 changed files with 2828 additions and 362 deletions

View File

@@ -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.
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />
<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`

View File

@@ -19,6 +19,7 @@ import subprocess
import sys
import threading
import traceback
from typing import Optional
# Third-party
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.utilities.arg_parser import setup_parser
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.i18n import t
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.singleton import ui_state, interface_state, app_state
@@ -59,18 +61,62 @@ app_state.lock = threading.Lock()
# ------------------------------------------------------------------------------
# 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."""
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface_state.interface)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
close_interface(interface_state.interface)
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."""
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()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
@@ -78,9 +124,18 @@ def initialize_globals() -> None:
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
if seed_demo:
seed_demo_messages()
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:
"""Main entry point for the curses UI."""
@@ -98,12 +153,14 @@ def main(stdscr: curses.window) -> None:
logging.info("Initializing interface...")
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:
prompt_region_if_unset(args)
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args, stdscr)
initialize_globals()
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
logging.info("Starting main UI")
stdscr.clear()
@@ -150,10 +207,10 @@ def start() -> None:
try:
curses.wrapper(main)
interface_state.interface.close()
close_interface(interface_state.interface)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
interface_state.interface.close()
close_interface(interface_state.interface)
sys.exit(0)
except Exception as e:
logging.critical("Fatal error", exc_info=True)

View File

@@ -12,6 +12,7 @@ Reboot, "Reboot", ""
Reset Node DB, "Reset Node DB", ""
Shutdown, "Shutdown", ""
Factory Reset, "Factory Reset", ""
factory_reset_config, "Factory Reset Config", ""
Exit, "Exit", ""
Yes, "Yes", ""
No, "No", ""
@@ -55,6 +56,7 @@ confirm.reboot, "Are you sure you want to Reboot?", ""
confirm.reset_node_db, "Are you sure you want to Reset Node DB?", ""
confirm.shutdown, "Are you sure you want to Shutdown?", ""
confirm.factory_reset, "Are you sure you want to Factory Reset?", ""
confirm.factory_reset_config, "Are you sure you want to Factory Reset Config?", ""
confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", ""
prompt.select_region, "Select your region:", ""
dialog.slow_down_title, "Slow down", ""
@@ -77,7 +79,7 @@ help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ = Search", ""
help.search, "Ctrl+/ or / = Search", ""
help.help, "Ctrl+K = Help", ""
help.no_help, "No help available.", ""
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""

View File

@@ -12,6 +12,7 @@ Reboot, "Перезагрузить", ""
Reset Node DB, "Сбросить БД узлов", ""
Shutdown, "Выключить", ""
Factory Reset, "Сброс до заводских", ""
factory_reset_config, "Сбросить только конфигурацию", ""
Exit, "Выход", ""
Yes, "Да", ""
No, "Нет", ""
@@ -55,6 +56,7 @@ confirm.reboot, "Перезагрузить устройство?", ""
confirm.reset_node_db, "Сбросить БД узлов?", ""
confirm.shutdown, "Выключить устройство?", ""
confirm.factory_reset, "Сбросить до заводских настроек?", ""
confirm.factory_reset_config, "Сбросить только конфигурацию?", ""
confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", ""
prompt.select_region, "Выберите ваш регион:", ""
dialog.slow_down_title, "Подождите", ""
@@ -77,7 +79,7 @@ help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ = Поиск", ""
help.search, "Ctrl+/ или / = Поиск", ""
help.help, "Ctrl+K = Справка", ""
help.no_help, "Нет справки.", ""
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""

View File

@@ -5,9 +5,10 @@ import shutil
import time
import subprocess
import threading
from typing import Any, Dict, Optional
# Debounce notification sounds so a burst of queued messages only plays once.
_SOUND_DEBOUNCE_SECONDS = 0.8
_sound_timer: threading.Timer | None = None
_sound_timer: Optional[threading.Timer] = None
_sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
@@ -42,18 +43,13 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
_sound_timer.start()
from typing import Any, Dict
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
request_ui_redraw,
)
from contact.utilities.db_handler import (
save_message_to_db,
@@ -122,7 +118,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
draw_packetlog_win()
request_ui_redraw(packetlog=True)
if ui_state.current_window == 4:
menu_state.need_redraw = True
@@ -133,7 +129,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if changed:
draw_node_list()
request_ui_redraw(nodes=True)
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
@@ -187,9 +183,9 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
if refresh_channels:
draw_channel_list()
request_ui_redraw(channels=True)
if refresh_messages:
draw_messages_window(True)
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
save_message_to_db(channel_id, message_from_id, message_string)

View File

@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
from contact.utilities.singleton import ui_state, interface_state, app_state
from contact.utilities.utils import add_new_message
@@ -28,145 +28,141 @@ def onAckNak(packet: Dict[str, Any]) -> None:
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import draw_messages_window
from contact.ui.contact_ui import request_ui_redraw
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
with app_state.lock:
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.nak_str
ack_type = "Nak"
confirm_string = config.nak_str
ack_type = "Nak"
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
draw_messages_window()
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
request_ui_redraw(messages=True)
def on_response_traceroute(packet: Dict[str, Any]) -> None:
"""
Handle traceroute response packets and render the route visually in the UI.
"""
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
from contact.ui.contact_ui import add_notification, request_ui_redraw
refresh_channels = False
refresh_messages = False
with app_state.lock:
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
msg_str = "Traceroute to:\n"
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with origin of response
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = (
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
) # Start with origin of response
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0:
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with destination of response (us)
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route back to us
msg_str += route_str + "\n"
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
if lenBack > 0:
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
msg_str += route_str + "\n"
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
save_message_to_db(channel_id, packet["from"], msg_str)
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if refresh_channels:
request_ui_redraw(channels=True)
if refresh_messages:
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
"""

View File

@@ -6,19 +6,26 @@ import sys
import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
from contact.ui.dialog import dialog
from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.interfaces import initialize_interface
from contact.utilities.i18n import t
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface, reconnect_interface
def close_interface(interface: object) -> None:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
interface = None
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
@@ -39,8 +46,9 @@ def main(stdscr: curses.window) -> None:
)
if confirmation == "Yes":
set_region(interface)
interface.close()
interface = initialize_interface(args)
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
@@ -51,6 +59,8 @@ def main(stdscr: curses.window) -> None:
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output before crash:\n%s", console_output)
raise
finally:
close_interface(interface)
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:

View File

@@ -12,14 +12,71 @@ 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.input_handlers import get_list_input
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.dialog
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.ui.nav_utils import (
move_main_highlight,
draw_main_arrows,
get_msg_window_lines,
wrap_text,
truncate_with_ellipsis,
pad_to_width,
)
from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state
MIN_COL = 1 # "effectively zero" without breaking curses
RESIZE_DEBOUNCE_MS = 250
root_win = None
nodes_pad = None
def request_ui_redraw(
*,
channels: bool = False,
messages: bool = False,
nodes: bool = False,
packetlog: bool = False,
full: bool = False,
scroll_messages_to_bottom: bool = False,
) -> None:
ui_state.redraw_channels = ui_state.redraw_channels or channels
ui_state.redraw_messages = ui_state.redraw_messages or messages
ui_state.redraw_nodes = ui_state.redraw_nodes or nodes
ui_state.redraw_packetlog = ui_state.redraw_packetlog or packetlog
ui_state.redraw_full_ui = ui_state.redraw_full_ui or full
ui_state.scroll_messages_to_bottom = ui_state.scroll_messages_to_bottom or scroll_messages_to_bottom
def process_pending_ui_updates(stdscr: curses.window) -> None:
if ui_state.redraw_full_ui:
ui_state.redraw_full_ui = False
ui_state.redraw_channels = False
ui_state.redraw_messages = False
ui_state.redraw_nodes = False
ui_state.redraw_packetlog = False
ui_state.scroll_messages_to_bottom = False
handle_resize(stdscr, False)
return
if ui_state.redraw_channels:
ui_state.redraw_channels = False
draw_channel_list()
if ui_state.redraw_nodes:
ui_state.redraw_nodes = False
draw_node_list()
if ui_state.redraw_messages:
scroll_to_bottom = ui_state.scroll_messages_to_bottom
ui_state.redraw_messages = False
ui_state.scroll_messages_to_bottom = False
draw_messages_window(scroll_to_bottom)
if ui_state.redraw_packetlog:
ui_state.redraw_packetlog = False
draw_packetlog_win()
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
@@ -62,6 +119,109 @@ def paint_frame(win, selected: bool) -> None:
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 get_selected_channel_title() -> str:
if not ui_state.channel_list:
return ""
channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)]
if isinstance(channel, int):
return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel)
return str(channel)
def get_window_title(window: int) -> str:
if window == 2:
return f"Nodes: {len(ui_state.node_list)}"
if ui_state.single_pane_mode and window == 1:
return get_selected_channel_title()
return ""
def draw_frame_title(box: curses.window, title: str) -> None:
if not title:
return
_, box_w = box.getmaxyx()
max_title_width = max(0, box_w - 6)
if max_title_width <= 0:
return
clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip()
if not clipped_title:
return
try:
box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD)
except curses.error:
pass
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
"""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
@@ -70,11 +230,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
height, width = stdscr.getmaxyx()
if ui_state.single_pane_mode:
channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window)
channel_width = width
messages_width = width
nodes_width = width
channel_x = 0
messages_x = 0
nodes_x = 0
else:
channel_width = int(config.channel_list_16ths) * (width // 16)
nodes_width = int(config.node_list_16ths) * (width // 16)
messages_width = width - channel_width - nodes_width
channel_x = 0
messages_x = channel_width
nodes_x = channel_width + messages_width
channel_width = max(MIN_COL, channel_width)
messages_width = max(MIN_COL, messages_width)
@@ -82,7 +250,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
# Ensure the three widths sum exactly to the terminal width by adjusting the focused pane
total = channel_width + messages_width + nodes_width
if total != width:
if not ui_state.single_pane_mode and total != width:
delta = total - width
if ui_state.current_window == 0:
channel_width = max(MIN_COL, channel_width - delta)
@@ -99,11 +267,11 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
if firstrun:
entry_win = curses.newwin(entry_height, width, height - entry_height, 0)
channel_win = curses.newwin(content_h, channel_width, 0, 0)
messages_win = curses.newwin(content_h, messages_width, 0, channel_width)
nodes_win = curses.newwin(content_h, nodes_width, 0, channel_width + messages_width)
channel_win = curses.newwin(content_h, channel_width, 0, channel_x)
messages_win = curses.newwin(content_h, messages_width, 0, messages_x)
nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x)
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, channel_width)
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x)
# Will be resized to what we need when drawn
messages_pad = curses.newpad(1, 1)
@@ -130,23 +298,30 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
entry_win.mvwin(height - entry_height, 0)
channel_win.resize(content_h, channel_width)
channel_win.mvwin(0, 0)
channel_win.mvwin(0, channel_x)
messages_win.resize(content_h, messages_width)
messages_win.mvwin(0, channel_width)
messages_win.mvwin(0, messages_x)
nodes_win.resize(content_h, nodes_width)
nodes_win.mvwin(0, channel_width + messages_width)
nodes_win.mvwin(0, nodes_x)
packetlog_win.resize(pkt_h, messages_width)
packetlog_win.mvwin(height - pkt_h - entry_height, channel_width)
packetlog_win.mvwin(height - pkt_h - entry_height, messages_x)
# Draw window borders
for win in [channel_win, entry_win, nodes_win, messages_win]:
windows_to_draw = [entry_win]
if ui_state.single_pane_mode:
windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window])
else:
windows_to_draw.extend([channel_win, nodes_win, messages_win])
for win in windows_to_draw:
win.box()
win.refresh()
entry_win.keypad(True)
entry_win.timeout(200)
curses.curs_set(1)
try:
@@ -161,6 +336,24 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
pass
def drain_resize_events(input_win: curses.window) -> Union[str, int, None]:
"""Wait for resize events to settle and preserve one queued non-resize key."""
input_win.timeout(RESIZE_DEBOUNCE_MS)
try:
while True:
try:
next_char = input_win.get_wch()
except curses.error:
return None
if next_char == curses.KEY_RESIZE:
continue
return next_char
finally:
input_win.timeout(-1)
def main_ui(stdscr: curses.window) -> None:
"""Main UI loop for the curses interface."""
global input_text
@@ -168,15 +361,25 @@ def main_ui(stdscr: curses.window) -> None:
root_win = stdscr
input_text = ""
queued_char = None
stdscr.keypad(True)
get_channels()
handle_resize(stdscr, True)
while True:
with app_state.lock:
process_pending_ui_updates(stdscr)
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
# Get user input from entry window
char = entry_win.get_wch()
try:
if queued_char is None:
char = entry_win.get_wch()
else:
char = queued_char
queued_char = None
except curses.error:
continue
# draw_debug(f"Keypress: {char}")
@@ -224,12 +427,16 @@ def main_ui(stdscr: curses.window) -> None:
elif char == curses.KEY_RESIZE:
input_text = ""
queued_char = drain_resize_events(entry_win)
handle_resize(stdscr, False)
continue
elif char == chr(4): # Ctrl + D to delete current channel or node
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()
elif char == chr(11): # Ctrl + K for Help
@@ -333,31 +540,16 @@ def handle_leftright(char: int) -> None:
delta = -1 if char == curses.KEY_LEFT else 1
old_window = ui_state.current_window
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:
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)
refresh_main_window(old_window, selected=False)
if not ui_state.single_pane_mode:
draw_window_arrows(old_window)
if ui_state.current_window == 0:
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)
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
@@ -378,31 +570,16 @@ def handle_function_keys(char: int) -> None:
return
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:
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)
refresh_main_window(old_window, selected=False)
if not ui_state.single_pane_mode:
draw_window_arrows(old_window)
if ui_state.current_window == 0:
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)
refresh_main_window(ui_state.current_window, selected=True)
draw_window_arrows(ui_state.current_window)
@@ -454,34 +631,34 @@ def handle_enter(input_text: str) -> str:
def handle_f5_key(stdscr: curses.window) -> None:
node = None
try:
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
if not ui_state.node_list:
return
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.append("**📋 Basic Information:**")
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"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
role = f"{node.get('user', {}).get('role', 'Unknown')}"
message_parts.append(f"• Role: {role}")
pk = f"{node.get('user', {}).get('publicKey')}"
message_parts.append(f"Public key: {pk}")
message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}")
message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}")
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
if "position" in node:
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}")
if pos.get("altitude"):
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"]):
message_parts.append("\n**🌐 Network Metrics:**")
message_parts.append("")
message_parts.append("**🌐 Network Metrics:**")
if "snr" in node:
snr = node["snr"]
@@ -501,12 +678,13 @@ def handle_f5_key(stdscr: curses.window) -> None:
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else ""
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'])}")
if node.get("deviceMetrics"):
metrics = node["deviceMetrics"]
message_parts.append("\n**📊 Device Metrics:**")
message_parts.append("")
message_parts.append("**📊 Device Metrics:**")
if "batteryLevel" in metrics:
battery = metrics["batteryLevel"]
@@ -527,21 +705,128 @@ def handle_f5_key(stdscr: curses.window) -> None:
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 = "\n".join(message_parts)
contact.ui.dialog.dialog(
t(
"ui.dialog.node_details_title",
default="📡 Node Details: {name}",
name=node.get("user", {}).get("shortName", "Unknown"),
),
message,
title = t(
"ui.dialog.node_details_title",
default="📡 Node Details: {name}",
name=node.get("user", {}).get("shortName", "Unknown"),
)
curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False)
return title, message_parts
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:
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:
@@ -598,7 +883,9 @@ def handle_backtick(stdscr: curses.window) -> None:
ui_state.current_window = 4
settings_menu(stdscr, interface_state.interface)
ui_state.current_window = previous_window
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
curses.curs_set(1)
get_channels()
refresh_node_list()
handle_resize(stdscr, False)
@@ -785,7 +1072,7 @@ def draw_channel_list() -> None:
channel_pad.erase()
win_width = channel_win.getmaxyx()[1]
channel_pad.resize(len(ui_state.all_messages), channel_win.getmaxyx()[1])
channel_pad.resize(max(1, len(ui_state.channel_list)), channel_win.getmaxyx()[1])
idx = 0
for channel in ui_state.channel_list:
@@ -802,9 +1089,7 @@ def draw_channel_list() -> None:
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
truncated_channel = (
(channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification
).ljust(win_width - 3)
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 4)
color = get_color("channel_list")
if idx == ui_state.selected_channel:
@@ -839,7 +1124,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
row = 0
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)
msg_line_count += len(wrapped_lines)
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
@@ -881,10 +1166,8 @@ def draw_node_list() -> None:
if ui_state.current_window != 2 and ui_state.single_pane_mode:
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:
# nodes_pad = curses.newpad(1, 1)
nodes_pad = curses.newpad(1, 1)
if nodes_pad is None:
nodes_pad = curses.newpad(1, 1)
try:
nodes_pad.erase()
@@ -898,28 +1181,11 @@ def draw_node_list() -> None:
node = interface_state.interface.nodesByNum[node_num]
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
status_icon = "🔐" if secure else "🔓"
node_name = get_name_from_database(node_num, "long")
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 ""
node_name = get_node_display_name(node_num, node)
# Future node name custom formatting possible
node_str = f"{status_icon} {node_name}"
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
color = "node_list"
if "isFavorite" in node and node["isFavorite"]:
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)
)
node_str = truncate_with_ellipsis(f"{status_icon} {node_name}", box_width - 4)
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
nodes_win.refresh()
@@ -1140,8 +1406,7 @@ def refresh_pad(window: int) -> None:
pad = messages_pad
box = messages_win
lines = get_msg_window_lines(messages_win, packetlog_win)
selected_item = ui_state.selected_message
start_index = ui_state.selected_message
start_index = ui_state.start_index[1]
if ui_state.display_log:
packetlog_win.box()
@@ -1151,7 +1416,6 @@ def refresh_pad(window: int) -> None:
pad = nodes_pad
box = nodes_win
lines = box.getmaxyx()[0] - 2
box.addstr(0, 2, (f"Nodes: {len(ui_state.node_list)}"), curses.A_BOLD)
selected_item = ui_state.selected_node
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
@@ -1188,6 +1452,9 @@ def refresh_pad(window: int) -> None:
if bottom < top or right < left:
return
draw_frame_title(box, get_window_title(window))
box.refresh()
pad.refresh(
start_index,
0,

View File

@@ -1,14 +1,19 @@
import base64
import curses
import ipaddress
import logging
import os
import sys
from typing import List
from meshtastic.protobuf import admin_pb2
from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.utilities.interfaces import reconnect_interface
from contact.utilities.control_utils import transform_menu_path
from contact.utilities.i18n import t
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import (
get_repeated_input,
get_text_input,
@@ -16,20 +21,21 @@ from contact.utilities.input_handlers import (
get_list_input,
get_admin_key_input,
)
from contact.utilities.i18n import t
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.splash import draw_splash
from contact.ui.user_config import json_editor
from contact.utilities.singleton import menu_state
from contact.utilities.arg_parser import setup_parser
from contact.utilities.singleton import interface_state, menu_state
# Setup Variables
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
save_option = "Save Changes"
max_help_lines = 0
help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
# Compute the effective menu width for the current terminal
@@ -38,7 +44,7 @@ def get_menu_width() -> int:
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -54,6 +60,13 @@ config_folder = os.path.abspath(config.node_configs_file_path)
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def reload_translations() -> None:
global translation_file, field_mapping, help_text
@@ -121,6 +134,22 @@ def display_menu() -> tuple[object, object]:
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
if isinstance(current_value, int):
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
elif isinstance(current_value, str) and current_value.isdigit():
try:
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
display_option = f"{display_name}"[: w // 2 - 2]
display_value = f"{current_value}"[: w // 2 - 4]
@@ -197,6 +226,59 @@ def get_input_type_for_field(field) -> type:
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 request_factory_reset(node: object, full: bool = False):
try:
return node.factoryReset(full=full)
except TypeError as ex:
field_name = "factory_reset_device" if full else "factory_reset_config"
field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name]
if field.cpp_type != field.CPPTYPE_INT32:
raise
node.ensureSessionKey()
message = admin_pb2.AdminMessage()
setattr(message, field_name, 1)
if node == node.iface.localNode:
on_response = None
else:
on_response = node.onAckNak
return node._sendAdmin(message, onResponse=on_response)
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:
curses.update_lines_cols()
@@ -305,9 +387,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state)
reconnect_required = save_changes(interface, modified_settings, menu_state)
modified_settings.clear()
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:
menu_state.menu_path.pop()
@@ -435,8 +520,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
interface = reconnect_after_admin_action(
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
@@ -447,8 +534,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
interface = reconnect_after_admin_action(
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()
continue
@@ -469,8 +558,30 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(interface.localNode, full=True),
"Factory Reset Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
elif selected_option == "factory_reset_config":
confirmation = get_list_input(
t("ui.confirm.factory_reset_config", default="Are you sure you want to Factory Reset Config?"),
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(interface.localNode, full=False),
"Factory Reset Config Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
@@ -546,7 +657,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
elif _is_repeated_field(field): # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop()
@@ -630,8 +741,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_changes(interface, modified_settings, menu_state)
reconnect_required = save_changes(interface, modified_settings, menu_state)
logging.info("Changes Saved")
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
modified_settings.clear()
menu = rebuild_menu_at_current_path(interface, menu_state)

View File

@@ -133,6 +133,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
"Reset Node DB": None,
"Shutdown": None,
"Factory Reset": None,
"factory_reset_config": None,
"Exit": None,
}
)

View File

@@ -331,6 +331,51 @@ def text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def slice_to_width(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
width = 0
chars = []
for char in text:
char_width = text_width(char)
if width + char_width > max_width:
break
chars.append(char)
width += char_width
return "".join(chars)
def pad_to_width(text: str, width: int) -> str:
clipped = slice_to_width(text, width)
return clipped + (" " * max(0, width - text_width(clipped)))
def truncate_with_ellipsis(text: str, width: int) -> str:
if width <= 0:
return ""
if text_width(text) <= width:
return pad_to_width(text, width)
if width == 1:
return ""
return pad_to_width(slice_to_width(text, width - 1) + "", width)
def split_text_to_width_chunks(text: str, width: int) -> List[str]:
if width <= 0:
return [""]
chunks = []
remaining = text
while remaining:
chunk = slice_to_width(remaining, width)
if not chunk:
break
chunks.append(chunk)
remaining = remaining[len(chunk) :]
return chunks or [""]
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
@@ -353,8 +398,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
wrapped_lines.extend(split_text_to_width_chunks(word, wrap_width))
continue
if line_length + word_length > wrap_width and word.strip():

View File

@@ -33,6 +33,12 @@ class ChatUIState:
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
redraw_channels: bool = False
redraw_messages: bool = False
redraw_nodes: bool = False
redraw_packetlog: bool = False
redraw_full_ui: bool = False
scroll_messages_to_bottom: bool = False
@dataclass

View File

@@ -6,7 +6,7 @@ from typing import Any, List, Dict, Optional
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
import contact.ui.default_config as config
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.control_utils import parse_ini_file
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
@@ -566,7 +566,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
config.reload_config()
reload_translations(data.get("language"))

View File

@@ -35,5 +35,10 @@ def setup_parser() -> ArgumentParser:
parser.add_argument(
"--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

View File

@@ -9,6 +9,17 @@ from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0+ exposes `is_repeated`, while older versions require
checking `label == LABEL_REPEATED`.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root)
@@ -89,7 +100,7 @@ def setPref(config, comp_name, raw_val) -> bool:
return False
# repeating fields need to be handled with append, not setattr
if pref.label != pref.LABEL_REPEATED:
if not _is_repeated_field(pref):
try:
if config_type.message_type is not None:
config_values = getattr(config_part, config_type.name)

View File

@@ -1,55 +1,7 @@
from typing import Optional, Tuple, Dict, List
from typing import List
import re
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else "No help available."
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = "No help available."
return field_mapping, help_text
def transform_menu_path(menu_path: List[str]) -> List[str]:
"""Applies path replacements and normalizes entries in the menu path."""
path_replacements = {"Radio Settings": "config", "Module Settings": "module"}

View File

@@ -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"),
],
}

View File

@@ -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))

View File

@@ -1,7 +1,7 @@
from typing import Optional
import contact.ui.default_config as config
from contact.utilities.control_utils import parse_ini_file
from contact.utilities.ini_utils import parse_ini_file
_translations = {}
_language = None

View File

@@ -0,0 +1,54 @@
from typing import Optional, Tuple, Dict
from contact.utilities import i18n
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
"""Parses an INI file and returns a mapping of keys to human-readable names and help text."""
try:
default_help = i18n.t("ui.help.no_help", default="No help available.")
except Exception:
default_help = "No help available."
field_mapping: Dict[str, str] = {}
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"):
continue
# Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
continue
# Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)]
if len(parts) >= 2:
key = parts[0]
# If key is 'title', map directly to the section
if key == "title":
full_key = current_section
else:
full_key = f"{current_section}.{key}" if current_section else key
# Use the provided human-readable name or fallback to key
human_readable_name = parts[1] if parts[1] else key
field_mapping[full_key] = human_readable_name
# Handle help text or default
help = parts[2] if len(parts) == 3 and parts[2] else default_help
help_text[full_key] = help
else:
# Handle cases with only the key present
full_key = f"{current_section}.{key}" if current_section else key
field_mapping[full_key] = key
help_text[full_key] = default_help
return field_mapping, help_text

View File

@@ -462,7 +462,10 @@ from contact.utilities.singleton import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
original_value = current_value
ip_string = str(ipaddress.IPv4Address(current_value))
try:
ip_string = str(ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)))
except Exception:
ip_string = str(ipaddress.IPv4Address(current_value))
height = 10
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
@@ -524,7 +527,7 @@ def get_fixed32_input(current_value: int) -> int:
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
return int(ipaddress.ip_address(user_input))
return int.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False)
else:
fixed32_win.addstr(
7,
@@ -536,7 +539,7 @@ def get_fixed32_input(current_value: int) -> int:
curses.napms(1500)
user_input = ""
elif key in (curses.KEY_BACKSPACE, 127):
elif key in (curses.KEY_BACKSPACE, curses.KEY_DC, 127, 8, "\b", "\x7f"):
user_input = user_input[:-1]
else:

View File

@@ -1,4 +1,5 @@
import logging
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
@@ -41,3 +42,21 @@ def initialize_interface(args):
except Exception as 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

View File

@@ -4,6 +4,79 @@ import logging
import base64
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):
"""
@@ -15,7 +88,7 @@ def save_changes(interface, modified_settings, menu_state):
try:
if not modified_settings:
logging.info("No changes to save. modified_settings is empty.")
return
return False
node = interface.getNode("^local")
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
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":
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)
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
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}"
)
return
return _requires_reconnect(menu_state, modified_settings)
elif menu_state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
@@ -107,21 +180,28 @@ def save_changes(interface, modified_settings, menu_state):
logging.info(f"Updated Channel {channel_num} in {config_category}")
logging.info(node.channels)
return
return False
else:
config_category = None
# Resolve the target config container, including nested sub-messages (e.g., network.ipv4_config)
config_container = None
if hasattr(node.localConfig, config_category):
config_container = getattr(node.localConfig, config_category)
elif hasattr(node.moduleConfig, config_category):
config_container = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
return False
if len(menu_state.menu_path) >= 4:
nested_key = menu_state.menu_path[3]
if hasattr(config_container, nested_key):
config_container = getattr(config_container, nested_key)
for config_item, new_value in modified_settings.items():
# Check if the category exists in localConfig
if hasattr(node.localConfig, config_category):
config_subcategory = getattr(node.localConfig, config_category)
# Check if the category exists in moduleConfig
elif hasattr(node.moduleConfig, config_category):
config_subcategory = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
continue
config_subcategory = config_container
# Check if the config_item exists in the subcategory
if hasattr(config_subcategory, config_item):
@@ -157,8 +237,11 @@ def save_changes(interface, modified_settings, menu_state):
if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup
return _requires_reconnect(menu_state, modified_settings)
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
return False
except Exception as e:
logging.error(f"Error saving changes: {e}")
return False

View File

@@ -68,19 +68,19 @@ def get_chunks(data):
# Leave it string as last resort
value = value
match key:
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
# convert seconds to hours, for our sanity
case "uptime_seconds":
value = round(value / 60 / 60, 1)
value = round(value / 60 / 60, 1)
elif key in ("longitude_i", "latitude_i"):
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
# truncate to 6th digit after floating point, which would be still accurate
case "longitude_i" | "latitude_i":
value = round(value * 1e-7, 6)
value = round(value * 1e-7, 6)
elif key == "wind_direction":
# Convert wind direction from degrees to abbreviation
case "wind_direction":
value = humanize_wind_direction(value)
case "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
value = humanize_wind_direction(value)
elif key == "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
if key in sensors:
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "

View File

@@ -10,35 +10,50 @@ from contact.utilities.singleton import ui_state, interface_state
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():
"""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")
device_channels = node.channels
previous_channel_list = list(ui_state.channel_list)
previous_messages = dict(ui_state.all_messages)
# Clear and rebuild channel list
# ui_state.channel_list = []
named_channels = []
for device_channel in device_channels:
if device_channel.role:
# Use the channel name if available, otherwise use the modem preset
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)
named_channels.append(_get_channel_name(device_channel, node))
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)]
preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)]
rebuilt_messages = {}
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
for index, channel_name in enumerate(named_channels):
previous_name = previous_named_channels[index] if index < len(previous_named_channels) else 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

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.4.12"
version = "1.5.4"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

13
tests/test_arg_parser.py Normal file
View File

@@ -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)

21
tests/test_config_io.py Normal file
View File

@@ -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()))

202
tests/test_contact_ui.py Normal file
View File

@@ -0,0 +1,202 @@
import unittest
from unittest import mock
import contact.ui.default_config as config
from contact.ui import contact_ui
from contact.ui.nav_utils import text_width
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)
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_channels = True
ui_state.redraw_messages = True
ui_state.redraw_nodes = True
ui_state.redraw_packetlog = True
ui_state.scroll_messages_to_bottom = True
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win:
contact_ui.process_pending_ui_updates(stdscr)
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_packetlog_win.assert_called_once_with()
def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_full_ui = True
ui_state.redraw_channels = True
ui_state.redraw_messages = True
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.process_pending_ui_updates(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
ui_state.start_index = [0, 0, 0]
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_pad.getmaxyx.return_value = (4, 20)
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
interface = mock.Mock()
interface.nodesByNum = {101: {}, 202: {}}
with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]):
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
contact_ui.refresh_node_selection(old_index=0, highlight=True)
self.assertEqual(
contact_ui.nodes_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
refresh_pad.assert_called_once_with(2)
draw_window_arrows.assert_called_once_with(2)
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
ui_state.channel_list = ["VeryLongChannelName"]
ui_state.notifications = []
ui_state.selected_channel = 0
ui_state.current_window = 0
contact_ui.channel_pad = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_color", return_value=1):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "remove_notification"):
contact_ui.draw_channel_list()
text = contact_ui.channel_pad.addstr.call_args.args[2]
self.assertEqual(len(text), 16)
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101]
ui_state.current_window = 2
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
contact_ui.entry_win = mock.Mock()
interface = mock.Mock()
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_node_list()
text = contact_ui.nodes_pad.addstr.call_args.args[2]
self.assertEqual(text_width(text), 16)
self.assertIn("", text)
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 80)
ui_state.single_pane_mode = True
ui_state.current_window = 1
contact_ui.entry_win = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.packetlog_win = mock.Mock()
contact_ui.messages_pad = mock.Mock()
contact_ui.nodes_pad = mock.Mock()
contact_ui.channel_pad = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
contact_ui.handle_resize(stdscr, False)
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
contact_ui.channel_win.box.assert_not_called()
contact_ui.nodes_win.box.assert_not_called()
contact_ui.messages_win.box.assert_called_once_with()
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_window_arrows.assert_called_once_with(1)
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
ui_state.single_pane_mode = True
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
self.assertEqual(contact_ui.get_window_title(0), "")
self.assertEqual(contact_ui.get_window_title(1), "Primary")
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
ui_state.single_pane_mode = True
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.start_index = [0, 0, 0]
ui_state.display_log = False
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (5, 20)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getbegyx.return_value = (0, 0)
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
contact_ui.refresh_pad(1)
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)

112
tests/test_control_ui.py Normal file
View File

@@ -0,0 +1,112 @@
from argparse import Namespace
from types import SimpleNamespace
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)
def test_request_factory_reset_uses_library_helper_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node)
node.factoryReset.assert_called_once_with(full=False)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node, full=True)
node.factoryReset.assert_called_once_with(full=True)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_config, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node, full=True)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_device, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])

View File

@@ -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"])

121
tests/test_db_handler.py Normal file
View File

@@ -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")

View File

@@ -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)

51
tests/test_demo_data.py Normal file
View File

@@ -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)

11
tests/test_emoji_utils.py Normal file
View File

@@ -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")

57
tests/test_i18n.py Normal file
View File

@@ -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)

40
tests/test_ini_utils.py Normal file
View File

@@ -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.")

26
tests/test_interfaces.py Normal file
View File

@@ -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)

232
tests/test_main.py Normal file
View File

@@ -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)

28
tests/test_menus.py Normal file
View File

@@ -0,0 +1,28 @@
from types import SimpleNamespace
import unittest
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.ui.menus import generate_menu_from_protobuf
class MenusTests(unittest.TestCase):
def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {
"user": {"longName": "Test User", "shortName": "TU", "isLicensed": False},
"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0},
},
)
menu = generate_menu_from_protobuf(interface)
keys = list(menu["Main Menu"].keys())
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")

36
tests/test_nav_utils.py Normal file
View File

@@ -0,0 +1,36 @@
import unittest
from unittest import mock
from contact.ui import nav_utils
from contact.ui.nav_utils import truncate_with_ellipsis, wrap_text
from contact.utilities.singleton import ui_state
class NavUtilsTests(unittest.TestCase):
def setUp(self) -> None:
ui_state.current_window = 0
ui_state.node_list = []
ui_state.start_index = [0, 0, 0]
def test_wrap_text_splits_wide_characters_by_display_width(self) -> None:
self.assertEqual(wrap_text("🔐🔐🔐", 4), ["🔐", "🔐", "🔐"])
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None:
ui_state.current_window = 2
ui_state.start_index = [0, 0, 0]
menu_win = mock.Mock()
menu_win.getbegyx.return_value = (0, 0)
menu_win.getmaxyx.return_value = (8, 20)
menu_pad = mock.Mock()
menu_pad.getmaxyx.return_value = (4, 20)
with mock.patch.object(nav_utils, "get_node_color", side_effect=[11, 22]):
nav_utils.highlight_line(menu_win, menu_pad, 0, 1, 5)
self.assertEqual(
menu_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)

90
tests/test_rx_handler.py Normal file
View File

@@ -0,0 +1,90 @@
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, "request_ui_redraw") as request_ui_redraw:
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)
self.assertEqual(request_ui_redraw.call_args_list, [mock.call(nodes=True), mock.call(messages=True, scroll_messages_to_bottom=True)])
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, "request_ui_redraw") as request_ui_redraw:
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)
request_ui_redraw.assert_called_once_with(channels=True)
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, "request_ui_redraw") as request_ui_redraw:
rx_handler.on_receive({"id": "new"}, interface=None)
request_ui_redraw.assert_called_once_with(packetlog=True)
self.assertEqual(len(ui_state.packet_buffer), 20)
self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"})
self.assertTrue(menu_state.need_redraw)

114
tests/test_save_to_radio.py Normal file
View File

@@ -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()

75
tests/test_settings.py Normal file
View File

@@ -0,0 +1,75 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.settings as settings
class SettingsRuntimeTests(unittest.TestCase):
def test_main_closes_interface_after_normal_settings_exit(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu") as settings_menu:
setup_parser.return_value.parse_args.return_value = args
settings.main(stdscr)
settings_menu.assert_called_once_with(stdscr, interface)
interface.close.assert_called_once_with()
def test_main_closes_reconnected_interface_after_region_reset(self) -> None:
stdscr = mock.Mock()
args = Namespace()
old_interface = mock.Mock()
old_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=0)))
new_interface = mock.Mock()
new_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=old_interface):
with mock.patch.object(settings, "get_list_input", return_value="Yes"):
with mock.patch.object(settings, "set_region") as set_region:
with mock.patch.object(
settings, "reconnect_interface", return_value=new_interface
) as reconnect_interface:
with mock.patch.object(settings, "settings_menu") as settings_menu:
setup_parser.return_value.parse_args.return_value = args
settings.main(stdscr)
set_region.assert_called_once_with(old_interface)
reconnect_interface.assert_called_once_with(args)
settings_menu.assert_called_once_with(stdscr, new_interface)
old_interface.close.assert_called_once_with()
new_interface.close.assert_called_once_with()
def test_main_closes_interface_when_settings_menu_raises(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")):
setup_parser.return_value.parse_args.return_value = args
with self.assertRaises(RuntimeError):
settings.main(stdscr)
interface.close.assert_called_once_with()

27
tests/test_support.py Normal file
View File

@@ -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__)

View File

@@ -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)

107
tests/test_tx_handler.py Normal file
View File

@@ -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.request_ui_redraw") as request_ui_redraw:
tx_handler.onAckNak(packet)
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack")
request_ui_redraw.assert_called_once_with(messages=True)
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.request_ui_redraw"):
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])

93
tests/test_utils.py Normal file
View File

@@ -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), "✉️")

View File

@@ -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"), {})