Compare commits

...
65 Commits
Author SHA1 Message Date
pdxlocations f375d788f1 Bump version to 1.7.4 in pyproject.toml 2026-10-27 22:01:14 -07:00
pdxlocations 4c4b8a42c5 tryfix f5 resize crash 2026-10-27 22:00:53 -07:00
pdxlocations 80667abb26 Bump version to 1.7.3 in pyproject.toml 2026-10-27 21:56:20 -07:00
pdxlocations 5210c54d2b Refactor SNR and metrics display in F5 key handler to remove emojis and simplify output 2026-10-27 21:52:15 -07:00
pdxlocations 6eaa567f48 Merge pull request #323 from pdxlocations/fix-F5-resize 2026-10-27 21:42:24 -07:00
pdxlocations 76a5a1a496 Enhance F5 key handling to rebuild background on terminal resize 2026-10-27 21:41:47 -07:00
pdxlocations 2013b23812 Merge pull request #322 from pdxlocations/silence-alert-in-messages-pane 2026-10-27 21:13:13 -07:00
pdxlocations 22d8901115 Don't play notification if chat is selected 2026-10-27 21:12:37 -07:00
pdxlocations 94a976527d Merge pull request #321 from pdxlocations/fix-enter 2026-10-27 21:04:42 -07:00
pdxlocations 3fd811d4c2 Support keypad and regular Enter key in main UI and search functions 2026-10-27 21:04:04 -07:00
pdxlocations acac61a060 bump version to 1.7.2 in pyproject.toml 2026-10-19 21:23:33 -07:00
pdxlocations fffc3e3e97 Fix single pane message crash: Refactor message rendering in draw_messages_window and add test for single pane typing behavior 2026-10-19 15:27:40 -07:00
pdxlocations c956a11672 Merge pull request #320 from vanotaras/main 2026-10-19 15:15:18 -07:00
vanotaras 950266637f Add UTF-8 byte counter to the message input field 2026-10-20 00:49:44 +05:00
vanotaras 368f464649 Fix popup console window on Windows 2026-10-20 00:45:59 +05:00
pdxlocations f854d887b6 bump version to 1.7.1 in pyproject.toml 2026-09-18 15:49:22 -07:00
pdxlocations 6c4bb0da11 maybe fix json permission error 2026-09-18 15:48:24 -07:00
pdxlocations 642c4289f8 Temp fix status message 2026-09-18 09:47:26 -07:00
pdxlocations a7f8946f4b bump version to 1.7.0 in pyproject.toml 2026-09-15 22:40:48 -07:00
pdxlocations 79e3bbe641 change name singleton to shared_state 2026-09-15 22:39:33 -07:00
pdxlocations 37c9e3222e remove unused code 2026-09-15 22:36:59 -07:00
pdxlocations 28ab86d385 use rxtime, not local time for timestamps 2026-09-15 22:26:23 -07:00
pdxlocations dfa0a892c0 Refactor command-line interface and settings management
- Consolidated command-line argument parsing and handling into a unified structure.
- Introduced a new runtime module to manage the main application logic.
- Updated settings management to streamline the user experience and improve error handling.
- Enhanced versioning and help display functionality across the application.
- Refactored UI components to ensure compatibility with the new command structure.
- Added comprehensive tests for CLI functionality and settings management.
2026-09-15 22:23:16 -07:00
pdxlocations 826879eb25 Merge pull request #313 from Zolotkey/zdev 2026-09-15 22:14:09 -07:00
Nick Zolotko aa38441d70 Add Version to splash and create '--version'/'-V' 2026-09-15 10:53:48 -07:00
pdxlocations de0662f1f6 Bump version to 1.6.13 in pyproject.toml 2026-09-09 23:29:49 -07:00
pdxlocations e9c1fecb23 Merge pull request #311 from pdxlocations:role-unknown
Fix Role: Unknown
2026-09-09 22:56:04 -07:00
pdxlocations afbde3c2d6 0 = Default 2026-09-09 22:55:35 -07:00
pdxlocations 51351d316f Merge pull request #310 from pdxlocations:ham
Add ham params
2026-09-09 22:50:22 -07:00
pdxlocations 888108f14e Add ham params 2026-09-09 22:50:02 -07:00
pdxlocations 72f97bfde7 Enhance telemetry beautification by adding local stats parsing and new metrics; update tests for coverage. 2026-09-06 00:02:25 -07:00
pdxlocations 31bff496f1 Bump version to 1.6.12 in pyproject.toml 2026-09-05 13:51:05 -07:00
pdxlocations b7c3ed20f2 retore missing commit 2026-09-05 13:49:56 -07:00
pdxlocations 7f7e0598d2 restore missing commits 2026-09-05 13:48:09 -07:00
pdxlocations 82e7456248 Bump version to 1.6.11 in pyproject.toml 2026-09-05 13:41:36 -07:00
pdxlocations 7b21c9c886 Enhance channel list rendering to display configured names without altering slot keys; add unit test for verification. 2026-09-05 13:41:19 -07:00
pdxlocations 392d236a40 Bump version to 1.6.10 in pyproject.toml 2026-09-05 13:36:37 -07:00
pdxlocations 546fab099c Merge pull request #308 from pdxlocations:flicker-fix
Refactor paint_frame and refresh_node_selection functions to include optional refresh parameter; add stage_node_details_background function for improved window management in F5 key handling tests.
2026-09-05 13:36:06 -07:00
pdxlocations a89d4f8c0d Refactor paint_frame and refresh_node_selection functions to include optional refresh parameter; add stage_node_details_background function for improved window management in F5 key handling tests. 2026-09-05 13:35:36 -07:00
pdxlocations 808fee4fe0 Bump version to 1.6.9 in pyproject.toml 2026-09-05 00:00:11 -07:00
pdxlocations 94d0a9053e Enhance interface initialization and splash screen status display
- Updated `initialize_runtime_interface` to accept `stdscr` for better integration with the UI.
- Modified `initialize_interface` to include a status callback for improved user feedback during connection attempts.
- Refactored `draw_splash` to dynamically display status messages, improving user experience during startup.
2026-09-04 23:59:53 -07:00
pdxlocations f38167db1a Bump version to 1.6.8 in pyproject.toml 2026-09-04 23:51:29 -07:00
pdxlocations 57caf463bc Merge pull request #305 from pdxlocations:edit-message-text
Edit message text with arrows
2026-09-04 23:30:23 -07:00
pdxlocations 36408a1f37 Edit message text with arrows 2026-09-04 23:30:08 -07:00
pdxlocations 07b9ed22d8 Bump version to 1.6.7 in pyproject.toml 2026-07-30 22:26:46 -07:00
pdxlocations bdba69677b Refactor error handling in start function to use print_exception for better traceback output and enhance tests for channel name resolution with new modem presets 2026-07-30 22:18:25 -07:00
pdxlocations 1f88a37ac1 Refactor save_changes to streamline admin key updates and enhance test coverage for key replacement 2026-07-26 20:34:55 -07:00
pdxlocations 8f33cbc22d Refactor handle_backtick to improve local settings selection 2026-07-26 20:32:21 -07:00
pdxlocations 40665ff076 Bump version to 1.6.6 in pyproject.toml 2026-07-26 14:26:01 -07:00
pdxlocations d1b6da6f26 Refactor channel settings to rename 'is_client_muted' to 'is_muted' and update related save functionality and tests 2026-07-26 14:23:11 -07:00
pdxlocations ac90b4af3e Enhance remote admin verification to establish authorized sessions and update related tests 2026-07-26 14:07:09 -07:00
pdxlocations 7af693ddea Enhance bot response to include reply context in message sending 2026-07-26 13:47:51 -07:00
pdxlocations dae6a43652 Bump version to 1.6.5 in pyproject.toml 2026-07-26 11:09:26 -07:00
pdxlocations 22e5e89828 Bump version to 1.6.4 in pyproject.toml 2026-07-26 11:08:57 -07:00
pdxlocations c2db76d5ec Merge pull request #298 from pdxlocations:log-viewer
Add log viewer functionality and enhance reconnect handling
2026-07-26 11:08:27 -07:00
pdxlocations d8dc4876b6 Add log viewer functionality and enhance reconnect handling
- Introduced a log viewer in the UI to display live logs.
- Added functionality to load the last few lines of the log file.
- Enhanced reconnect logic to handle UI state during connection attempts.
- Updated UI state management to track log viewer status.
- Added tests for log loading functionality.
2026-07-26 11:07:33 -07:00
pdxlocations 0ae644e3fb Keep Meshtastic logs out of the curses terminal 2026-07-26 10:44:02 -07:00
pdxlocations bfd20bfa49 Verify remote admin access before opening settings 2026-07-26 10:31:00 -07:00
pdxlocations fa2dbccf44 Bump version to 1.6.3 in pyproject.toml 2026-07-25 22:31:42 -07:00
pdxlocations 66256f0a4c Enhance config import/export to support selected remote nodes 2026-07-25 22:30:33 -07:00
pdxlocations e570ce4e48 Merge pull request #296 from pdxlocations:unmessagable
Add 'isUnmessagable' setting to user configuration
2026-07-25 22:12:28 -07:00
pdxlocations d0d9c1dd55 Add 'isUnmessagable' setting to user configuration and update related functionality 2026-07-25 22:12:04 -07:00
pdxlocations 043418bfde Implement remote admin cancellation handling and improve timeout management 2026-07-25 22:01:23 -07:00
pdxlocations a8a1527a84 Add interface connection check utility and refactor connection logic 2026-07-25 21:58:52 -07:00
pdxlocations 7d2092e25f Merge pull request #295 from pdxlocations:remote-admin
Refactor remote admin handling
2026-07-25 21:55:34 -07:00
54 changed files with 2497 additions and 789 deletions

No files matched your search

+8
View File
@@ -91,9 +91,17 @@ Optional arguments to specify a device to connect to and how.
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed.
- `--ble`, `-b`: The BLE device MAC address or name to connect to.
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
- `--help`, `-h`: Show usage and available options, then exit.
- `--version`, `-V`: Show the application version, then exit.
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
Choose at most one connection type. Connection options also apply to control mode,
for example `contact --control --host mesh.local`. The standalone
`python -m contact.settings` command accepts the same options. Help and version
exit with status 0; invalid arguments print an error to stderr and exit with
status 2 before starting the UI.
### Example Usage
```sh
+8 -255
View File
@@ -1,262 +1,15 @@
#!/usr/bin/env python3
"""Parse command-line options before initializing Contact's runtime."""
"""
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
Meshtastic® is a registered trademark of Meshtastic LLC.
Meshtastic software components are released under various licenses—see GitHub for details.
No warranty is provided. Use at your own risk.
"""
# Standard library
import contextlib
import curses
import io
import logging
import os
import subprocess
import sys
import threading
import traceback
from typing import Optional
# Third-party
from pubsub import pub
# Local application
import contact.ui.default_config as config
from contact.message_handlers.rx_handler import on_receive
from contact.settings import set_region
from contact.ui.colors import setup_colors
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, 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
# ------------------------------------------------------------------------------
# Environment & Logging Setup
# ------------------------------------------------------------------------------
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["LANG"] = "C.UTF-8"
os.environ.setdefault("TERM", "xterm-256color")
if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
logging.basicConfig(
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
app_state.lock = threading.Lock()
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
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)
close_interface(interface_state.interface)
if stdscr is not None:
draw_splash(stdscr)
interface_state.interface = reconnect_interface(args)
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
if interface is None:
return True
close_errors = []
def _close_target() -> None:
try:
interface.close()
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
close_errors.append(error)
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
close_thread.start()
close_thread.join(timeout_seconds)
if close_thread.is_alive():
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
return False
if not close_errors:
return True
error = close_errors[0]
if isinstance(error, KeyboardInterrupt):
logging.info("Interrupted while closing interface; continuing shutdown")
return True
logging.warning("Ignoring error while closing interface: %r", error)
return True
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()
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
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."""
output_capture = io.StringIO()
try:
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
args = setup_parser().parse_args()
if getattr(args, "settings", False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface...")
with app_state.lock:
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
if interface_state.interface is None:
return
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args, stdscr)
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
logging.info("Starting main UI")
stdscr.clear()
stdscr.refresh()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
main_ui(stdscr)
except Exception:
console_output = output_capture.getvalue()
logging.error("Uncaught exception inside main_ui")
logging.error("Traceback:\n%s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
return
except Exception:
raise
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
def start() -> None:
"""Entry point for the application."""
if "--help" in sys.argv or "-h" in sys.argv:
setup_parser().print_help()
sys.exit(0)
interrupted = False
fatal_error = None
try:
curses.wrapper(main)
except KeyboardInterrupt:
interrupted = True
logging.info("User exited with Ctrl+C")
except Exception as e:
fatal_error = e
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
finally:
close_interface(interface_state.interface)
if fatal_error is not None:
print("Fatal error:", fatal_error)
traceback.print_exc()
sys.exit(1)
if interrupted:
sys.exit(0)
def start(argv=None) -> None:
args = setup_parser().parse_args(argv)
if args.settings:
from contact.settings_runtime import start as start_runtime
else:
from contact.runtime import start as start_runtime
start_runtime(args)
if __name__ == "__main__":
+10 -2
View File
@@ -98,7 +98,13 @@ dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
user, "User"
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
isLicensed, "Enable licensed amateur (HAM) mode", "Shows HAM parameters for editing. Saving applies licensed operation, including radio and channel changes. Disabling does not restore previous settings."
call_sign, "Callsign", "Licensed operator callsign."
tx_power, "TX power in dBm", "Transmit power at the LoRa transceiver, before amplification. Use a value supported by your radio."
frequency, "Frequency in MHz", "Operating frequency override. Zero uses automatic frequency selection."
short_name, "HAM short name", "Optional short display name."
long_name, "Name appended to callsign", "Optional name appended to the callsign. Requires supporting firmware."
isUnmessagable, "Disable incoming messages", "When enabled, this node advertises that it cannot receive messages."
[app_settings]
title, "App Settings", ""
@@ -113,6 +119,7 @@ message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
view_log, "View Log", "Open a scrollable live view of Contact's log file."
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
@@ -158,6 +165,7 @@ node_ignored, "Node ignored", ""
[Channels.channel]
title, "Channels"
enabled, "Enabled", "Enable or disable this channel without clearing its name or encryption key."
channel_num, "Channel number", "The index number of this channel."
psk, "PSK", "The channel's encryption key."
name, "Name", "The channel's name."
@@ -166,7 +174,7 @@ uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT s
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
module_settings, "Module settings", "Position precision and Client Mute."
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
module_settings.is_muted, "Is muted", "Controls whether or not the client or device should mute this channel. Useful for noisy public channels you do not necessarily want to disable."
[config.device]
title, "Device"
+9 -2
View File
@@ -5,7 +5,8 @@ import time
from typing import Any, Dict
import contact.ui.default_config as config
from contact.utilities.singleton import app_state, interface_state
from contact.utilities.shared_state import app_state, interface_state
from contact.utilities.utils import get_reply_context
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
@@ -81,7 +82,13 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
# Use the triggering packet's ID so Meshtastic clients render this
# automatic response as a native reply when that ID is available.
send_message(response_data_string, channel=send_channel, reply_id=packet_id)
reply_context = get_reply_context(packet_id) if packet_id is not None else ""
send_message(
response_data_string,
channel=send_channel,
reply_id=packet_id,
reply_context=reply_context,
)
# Import locally to avoid circular import at module import time.
from contact.ui.contact_ui import request_ui_redraw
+27 -4
View File
@@ -13,6 +13,14 @@ _sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
def chat_is_selected() -> bool:
return (
ui_state.current_window == 1
and not ui_state.display_log
and not ui_state.log_viewer_open
)
def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
"""Schedule a notification sound after a short quiet period.
@@ -38,7 +46,8 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
with _sound_timer_lock:
if expected_request_time != _last_sound_request:
return
play_sound()
if not chat_is_selected():
play_sound()
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
@@ -60,7 +69,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
from contact.utilities.shared_state import ui_state, interface_state, app_state, menu_state
from contact.message_handlers.bot_handler import bot_respond
@@ -89,7 +98,12 @@ def play_sound():
elif player == "cvlc":
cmd = [executable, "--play-and-exit", sound_path]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if platform.system() == "Windows":
su = subprocess.STARTUPINFO()
su.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=0x08000000, startupinfo=su)
else:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
logging.warning(
"No suitable sound player found for notification sound; install ffmpeg (ffplay), mpg123, mpv, or VLC."
@@ -135,13 +149,20 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
maybe_store_nodeinfo_in_db(packet)
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
# Queued packets retain the time the node received them.
timestamp = packet.get("rxTime")
if not isinstance(timestamp, int) or timestamp <= 0:
timestamp = int(time.time())
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
hops = hop_start - hop_limit
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
if (
not chat_is_selected()
and str(config.notification_sound).casefold() not in {"none", "false", ""}
):
schedule_notification_sound()
message_bytes = packet["decoded"]["payload"]
@@ -189,6 +210,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
f"{config.message_prefix} [{hops}] {message_from_string} ",
f"{reply_context}{message_string}",
packet_id=packet.get("id"),
timestamp=timestamp,
)
if refresh_channels:
@@ -206,6 +228,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_string,
packet_id=packet.get("id"),
reply_id=reply_id,
timestamp=timestamp,
)
except KeyError as e:
+3 -3
View File
@@ -1,6 +1,6 @@
import time
from typing import Any, Dict
from typing import Any, Dict, Optional
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
@@ -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, app_state
from contact.utilities.shared_state import ui_state, interface_state, app_state
from contact.utilities.utils import add_new_message
@@ -168,7 +168,7 @@ def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: int | None = None,
reply_id: Optional[int] = None,
reply_context: str = "",
) -> None:
"""
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
Meshtastic® is a registered trademark of Meshtastic LLC.
Meshtastic software components are released under various licenses—see GitHub for details.
No warranty is provided. Use at your own risk.
"""
# Standard library
import contextlib
import curses
import io
import logging
import os
import sys
import threading
import traceback
from typing import Optional
# Third-party
from pubsub import pub
# Local application
import contact.ui.default_config as config
from contact.message_handlers.rx_handler import on_receive
from contact.ui.control_ui import set_region
from contact.ui.colors import setup_colors
from contact.ui.contact_ui import main_ui
from contact.ui.splash import draw_splash
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, reconnect_interface
from contact.utilities.logging_utils import configure_logging
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
from contact.utilities.shared_state import ui_state, interface_state, app_state
# ------------------------------------------------------------------------------
# Environment & Logging Setup
# ------------------------------------------------------------------------------
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["LANG"] = "C.UTF-8"
os.environ.setdefault("TERM", "xterm-256color")
if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
configure_logging(logging.INFO)
app_state.lock = threading.Lock()
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
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)
close_interface(interface_state.interface)
if stdscr is not None:
draw_splash(stdscr)
interface_state.interface = reconnect_interface(args)
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
if interface is None:
return True
close_errors = []
def _close_target() -> None:
try:
interface.close()
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
close_errors.append(error)
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
close_thread.start()
close_thread.join(timeout_seconds)
if close_thread.is_alive():
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
return False
if not close_errors:
return True
error = close_errors[0]
if isinstance(error, KeyboardInterrupt):
logging.info("Interrupted while closing interface; continuing shutdown")
return True
logging.warning("Ignoring error while closing interface: %r", error)
return True
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, stdscr)
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()
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
if seed_demo:
seed_demo_messages()
load_messages_from_db()
def initialize_runtime_interface(args: object, stdscr=None):
if getattr(args, "demo_screenshot", False):
configure_demo_database()
return build_demo_interface()
if stdscr is not None:
return initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
return initialize_interface(args)
def main(stdscr: curses.window, args: object) -> None:
"""Main entry point for the curses UI."""
app_state.connection_args = args
output_capture = io.StringIO()
try:
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
logging.info("Initializing interface...")
with app_state.lock:
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
if interface_state.interface is None:
return
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args, stdscr)
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
logging.info("Starting main UI")
stdscr.clear()
stdscr.refresh()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
main_ui(stdscr)
except Exception:
console_output = output_capture.getvalue()
logging.error("Uncaught exception inside main_ui")
logging.error("Traceback:\n%s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
return
except Exception:
raise
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
def start(args: object) -> None:
"""Entry point for the application."""
interrupted = False
fatal_error = None
try:
curses.wrapper(main, args)
except KeyboardInterrupt:
interrupted = True
logging.info("User exited with Ctrl+C")
except Exception as e:
fatal_error = e
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
finally:
close_interface(interface_state.interface)
if fatal_error is not None:
print("Fatal error:", fatal_error)
traceback.print_exception(type(fatal_error), fatal_error, fatal_error.__traceback__)
sys.exit(1)
if interrupted:
sys.exit(0)
+6 -98
View File
@@ -1,106 +1,14 @@
import contextlib
import curses
import io
import logging
import sys
import traceback
"""Standalone command-line entry point for node settings."""
import contact.ui.default_config as config
from contact.ui.colors import setup_colors
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.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 start(argv=None) -> None:
args = setup_parser().parse_args(argv)
from contact.settings_runtime import start as start_runtime
start_runtime(args)
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()
ensure_min_rows(stdscr)
draw_splash(stdscr)
curses.curs_set(0)
stdscr.keypad(True)
parser = setup_parser()
args = parser.parse_args()
interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input(
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
"Yes",
["Yes", "No"],
)
if confirmation == "Yes":
set_region(interface)
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
except Exception as e:
console_output = output_capture.getvalue()
logging.error("An error occurred: %s", e)
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:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
)
if __name__ == "__main__":
log_file = config.log_file_path
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
sys.stdout = log_f
sys.stderr = log_f
with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f):
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging
sys.exit(0) # Ensure a clean exit
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code
start()
+94
View File
@@ -0,0 +1,94 @@
import contextlib
import curses
import io
import logging
import sys
import traceback
import contact.ui.default_config as config
from contact.ui.colors import setup_colors
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.i18n import t
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface, reconnect_interface
from contact.utilities.logging_utils import configure_logging
from contact.utilities.shared_state import app_state
def close_interface(interface: object) -> None:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
def main(stdscr: curses.window, args: object) -> None:
app_state.connection_args = args
output_capture = io.StringIO()
interface = None
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
ensure_min_rows(stdscr)
draw_splash(stdscr)
curses.curs_set(0)
stdscr.keypad(True)
interface = initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input(
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
"Yes",
["Yes", "No"],
)
if confirmation == "Yes":
set_region(interface)
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
except Exception as e:
console_output = output_capture.getvalue()
logging.error("An error occurred: %s", e)
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:
while True:
rows, _ = stdscr.getmaxyx()
if rows >= min_rows:
return
dialog(
t("ui.dialog.resize_title", default="Resize Terminal"),
t(
"ui.dialog.resize_body",
default="Please resize the terminal to at least {rows} rows.",
rows=min_rows,
),
)
curses.update_lines_cols()
stdscr.clear()
stdscr.refresh()
def start(args: object) -> None:
configure_logging(logging.WARNING)
with open(config.log_file_path, "a", buffering=1) as log_file:
with contextlib.redirect_stderr(log_file), contextlib.redirect_stdout(log_file):
try:
curses.wrapper(main, args)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C or Ctrl+X")
except Exception:
logging.exception("Fatal error in curses wrapper")
sys.exit(1)
+432 -151
View File
@@ -1,5 +1,6 @@
import curses
import logging
import os
from typing import Optional
import time
import traceback
@@ -11,15 +12,17 @@ from contact.utilities.utils import (
get_readable_duration,
get_time_ago,
refresh_node_list,
add_new_message,
build_reply_prefix,
)
from contact.settings import settings_menu
from contact.ui.control_ui import settings_menu
from contact.ui.control_ui import RemoteAdminCancelled, verify_remote_admin
from contact.ui.user_config import load_log_tail
from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import interface_is_connected
from contact.utilities.i18n import t
from contact.utilities.emoji_utils import normalize_message_text
import contact.ui.default_config as config
@@ -30,15 +33,127 @@ from contact.ui.nav_utils import (
get_msg_window_lines,
wrap_text,
truncate_with_ellipsis,
pad_to_width,
text_width,
slice_to_width,
)
from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state
from contact.utilities.shared_state 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
entry_win = None
input_text = ""
def refresh_log_viewer() -> None:
"""Refresh the in-app log overlay only when the log file changes."""
try:
stat = os.stat(config.log_file_path)
signature = (stat.st_mtime_ns, stat.st_size)
except OSError:
signature = None
if not ui_state.log_viewer_loaded or signature != ui_state.log_viewer_signature:
ui_state.log_viewer_lines = load_log_tail(config.log_file_path)
ui_state.log_viewer_signature = signature
ui_state.log_viewer_loaded = True
def flush_log_handlers() -> None:
"""Ensure a just-written status event is visible to the log tailer."""
for handler in logging.getLogger().handlers:
try:
handler.flush()
except Exception:
pass
def draw_log_viewer(stdscr: curses.window) -> int:
"""Draw the log overlay and return the number of visible log rows."""
refresh_log_viewer()
height, width = stdscr.getmaxyx()
content_height = max(1, height - 4)
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
if ui_state.log_viewer_follow:
ui_state.log_viewer_start_line = max_start
else:
ui_state.log_viewer_start_line = min(ui_state.log_viewer_start_line, max_start)
try:
stdscr.erase()
stdscr.bkgd(get_color("background"))
stdscr.attrset(get_color("window_frame"))
stdscr.border()
state = "Following" if ui_state.log_viewer_follow else "Paused"
title = " View Log — {} ".format(state)
stdscr.addstr(0, 2, title[: max(0, width - 4)], get_color("settings_breadcrumbs", bold=True))
for row, line in enumerate(
ui_state.log_viewer_lines[ui_state.log_viewer_start_line : ui_state.log_viewer_start_line + content_height],
start=1,
):
stdscr.addstr(row, 1, line[: max(0, width - 2)], get_color("settings_default"))
hint = " Esc Close Up/Down Scroll PgUp/PgDn Page F Follow "
stdscr.addstr(height - 2, 1, hint[: max(0, width - 2)], get_color("commands"))
stdscr.refresh()
except curses.error:
pass
return content_height
def handle_log_viewer_key(key: int, content_height: int) -> bool:
"""Handle a log-overlay key. Returns True when the overlay closes."""
if ui_state.reconnect_prompt_open:
if key in (ord("r"), ord("R")):
ui_state.reconnect_prompt_open = False
ui_state.reconnect_attempted = False
elif key in (ord("c"), ord("C"), 27):
ui_state.reconnect_prompt_open = False
return False
if key == 27:
ui_state.log_viewer_open = False
return True
if key in (ord("f"), ord("F")):
ui_state.log_viewer_follow = not ui_state.log_viewer_follow
return False
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
if key == curses.KEY_UP:
ui_state.log_viewer_follow = False
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - 1)
elif key == curses.KEY_DOWN:
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + 1)
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
elif key == curses.KEY_PPAGE:
ui_state.log_viewer_follow = False
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - content_height)
elif key == curses.KEY_NPAGE:
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + content_height)
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
return False
def draw_log_reconnect_prompt(stdscr: curses.window) -> None:
"""Draw a non-blocking reconnect choice over the live log overlay."""
if not ui_state.reconnect_prompt_open:
return
try:
height, width = stdscr.getmaxyx()
message = "Could not reconnect after 20 seconds."
hint = "R Retry C Cancel"
box_width = min(width - 4, max(len(message) + 4, len(hint) + 4, 42))
win = curses.newwin(6, box_width, max(0, (height - 6) // 2), max(0, (width - box_width) // 2))
win.bkgd(get_color("background"))
win.attrset(get_color("window_frame"))
win.border()
win.addstr(0, 2, " Disconnected ", get_color("settings_default"))
win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
win.addstr(4, 2, hint[: box_width - 4], get_color("commands"))
win.refresh()
except curses.error:
pass
def request_ui_redraw(
@@ -116,24 +231,15 @@ def draw_window_arrows(window_id: int) -> None:
nodes_win.refresh()
def compute_widths(total_w: int, focus: int):
# focus: 0=channel, 1=messages, 2=nodes
if total_w < 3 * MIN_COL:
# tiny terminals: allocate something, anything
return max(1, total_w), 0, 0
if focus == 0:
return total_w - 2 * MIN_COL, MIN_COL, MIN_COL
if focus == 1:
return MIN_COL, total_w - 2 * MIN_COL, MIN_COL
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
def paint_frame(win, selected: bool) -> None:
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
if win is not entry_win and ui_state.input_focused:
selected = False
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
win.box()
win.attrset(get_color("window_frame"))
win.refresh()
if refresh:
win.refresh()
def get_channel_row_color(index: int) -> int:
@@ -156,7 +262,7 @@ def get_node_row_color(index: int, highlight: bool = False) -> int:
return get_color(color, reverse=reverse)
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh: bool = True) -> None:
if nodes_pad is None or not ui_state.node_list:
return
@@ -175,8 +281,9 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
pass
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
refresh_pad(2)
draw_window_arrows(2)
if refresh:
refresh_pad(2)
draw_window_arrows(2)
def refresh_main_window(window_id: int, selected: bool) -> None:
@@ -187,7 +294,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
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)
paint_frame(messages_win, selected=selected, refresh=False)
refresh_pad(1)
elif window_id == 2:
paint_frame(nodes_win, selected=selected)
@@ -378,6 +485,8 @@ def main_ui(stdscr: curses.window) -> None:
root_win = stdscr
input_text = ""
ui_state.input_cursor = 0
ui_state.input_focused = False
queued_char = None
stdscr.keypad(True)
get_channels()
@@ -385,13 +494,17 @@ def main_ui(stdscr: curses.window) -> None:
while True:
interface = interface_state.interface
if (
hasattr(interface, "stream")
and interface.stream is None
and not ui_state.reconnect_attempted
):
if not interface_is_connected(interface) and not ui_state.reconnect_attempted:
ui_state.reconnect_attempted = True
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
logging.warning("Meshtastic connection disconnected; attempting automatic reconnect")
flush_log_handlers()
if ui_state.log_viewer_open:
# Reconnect is synchronous. Repaint once before entering it so
# the disconnect event remains visible during the attempt.
draw_log_viewer(stdscr)
status_win = None
if not ui_state.log_viewer_open:
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
try:
from contact.ui.control_ui import reconnect_interface_with_splash
@@ -400,13 +513,18 @@ def main_ui(stdscr: curses.window) -> None:
status_win.erase()
status_win.refresh()
ui_state.reconnect_attempted = False
handle_resize(stdscr, False)
if not ui_state.log_viewer_open:
handle_resize(stdscr, False)
except Exception:
if status_win is not None:
status_win.erase()
status_win.refresh()
handle_resize(stdscr, False)
if not ui_state.log_viewer_open:
handle_resize(stdscr, False)
logging.exception("Automatic reconnect after transport disconnect failed")
if ui_state.log_viewer_open:
ui_state.reconnect_prompt_open = True
continue
retry = get_list_input(
"Contact could not reconnect after 20 seconds.",
"Retry",
@@ -419,10 +537,21 @@ def main_ui(stdscr: curses.window) -> None:
continue
handle_resize(stdscr, False)
if ui_state.log_viewer_open:
content_height = draw_log_viewer(stdscr)
draw_log_reconnect_prompt(stdscr)
stdscr.timeout(200)
try:
key = stdscr.getch()
except curses.error:
continue
if key != -1 and handle_log_viewer_key(key, content_height):
handle_resize(stdscr, False)
continue
with app_state.lock:
process_pending_ui_updates(stdscr)
entry_display = f"{ui_state.reply_context}{input_text or ''}"
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
draw_message_input(input_text)
# Get user input from entry window
try:
@@ -460,8 +589,9 @@ def main_ui(stdscr: curses.window) -> None:
elif char in (curses.KEY_F1, curses.KEY_F2, curses.KEY_F3):
handle_function_keys(char)
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
elif char in (curses.KEY_ENTER, chr(10), chr(13)):
input_text = handle_enter(input_text)
ui_state.input_cursor = min(ui_state.input_cursor, len(input_text))
elif char in (curses.KEY_F4, chr(20)): # Ctrl + t and F4 for Traceroute
handle_ctrl_t(stdscr)
@@ -469,9 +599,14 @@ def main_ui(stdscr: curses.window) -> None:
elif char == curses.KEY_F5:
handle_f5_key(stdscr)
elif char in (curses.KEY_BACKSPACE, chr(127)):
elif char in (curses.KEY_BACKSPACE, chr(127), chr(8)):
input_text = handle_backspace(entry_win, input_text)
elif char == curses.KEY_DC:
focus_message_input()
position = ui_state.input_cursor
input_text = input_text[:position] + input_text[position + 1:]
elif char in (curses.KEY_F12, "`"): # ` Launch the settings interface
handle_backtick(stdscr)
@@ -485,7 +620,6 @@ def main_ui(stdscr: curses.window) -> None:
entry_win.erase()
elif char == curses.KEY_RESIZE:
input_text = ""
queued_char = drain_resize_events(entry_win)
handle_resize(stdscr, False)
continue
@@ -511,15 +645,88 @@ def main_ui(stdscr: curses.window) -> None:
break
else:
# Append typed character to input text
if isinstance(char, str):
input_text += char
else:
input_text += chr(char)
if isinstance(char, str) and char.isprintable():
input_text = insert_input_text(input_text, char)
def focus_message_input() -> None:
if ui_state.input_focused:
return
# Keep the conversation visible in single-pane mode while composing.
handle_function_keys(curses.KEY_F2)
ui_state.input_focused = True
refresh_message_highlight()
refresh_main_window(1, selected=False)
def leave_message_input() -> None:
ui_state.input_focused = False
ui_state.current_window = 1
refresh_message_highlight()
refresh_main_window(1, selected=True)
def insert_input_text(draft: str, text: str) -> str:
focus_message_input()
position = min(ui_state.input_cursor, len(draft))
ui_state.input_cursor = position + len(text)
return draft[:position] + text + draft[position:]
def draw_message_input(draft: str) -> None:
"""Redraw the whole input row and scroll it to keep the draft cursor visible."""
# Получаем сразу высоту и ширину для правильного позиционирования счетчика
height, width = entry_win.getmaxyx()
prompt = slice_to_width("Message: ", max(0, width - 3))
available = max(1, width - 2 - text_width(prompt))
ui_state.input_cursor = max(0, min(ui_state.input_cursor, len(draft)))
content = ui_state.reply_context + draft
# Считаем именно байты в UTF-8 (важно для кириллицы)
byte_count = len(content.encode('utf-8'))
counter_str = f" {byte_count}/220b "
cursor = len(ui_state.reply_context) + ui_state.input_cursor
start = cursor
used = 0
while start > 0 and used + text_width(content[start - 1]) < available:
start -= 1
used += text_width(content[start])
visible = slice_to_width(content[start:], available)
draw_text_field(entry_win, prompt + visible, get_color("input"))
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
try:
hint = " Ctrl+K Help "
if width >= len(hint) + 4:
entry_win.addstr(0, width - len(hint) - 2, hint, get_color("commands"))
# меняем цвет, если превышен лимит
if byte_count > 220:
try:
# Используем settings_warning с добавлением жирности шрифта
counter_color = get_color("settings_warning", bold=True)
except:
counter_color = get_color("commands", bold=True, reverse=True)
else:
counter_color = get_color("commands")
# Отрисовка счетчика внизу с защитой от краша (height > 2)
if width >= len(counter_str) + 4 and height > 2:
entry_win.addstr(height - 1, width - len(counter_str) - 2, counter_str, counter_color)
curses.curs_set(1 if ui_state.input_focused else 0)
entry_win.move(1, min(width - 2, 1 + text_width(prompt) + used))
except curses.error:
pass
entry_win.refresh()
def handle_up() -> None:
"""Handle key up events to scroll the current window."""
if ui_state.input_focused:
leave_message_input()
return
if ui_state.current_window == 0:
scroll_channels(-1)
elif ui_state.current_window == 1:
@@ -530,6 +737,9 @@ def handle_up() -> None:
def handle_down() -> None:
"""Handle key down events to scroll the current window."""
if ui_state.input_focused:
leave_message_input()
return
if ui_state.current_window == 0:
scroll_channels(1)
elif ui_state.current_window == 1:
@@ -540,6 +750,9 @@ def handle_down() -> None:
def handle_home() -> None:
"""Handle home key events to select the first item in the current window."""
if ui_state.input_focused:
ui_state.input_cursor = 0
return
if ui_state.current_window == 0:
select_channel(0)
elif ui_state.current_window == 1:
@@ -554,6 +767,9 @@ def handle_home() -> None:
def handle_end() -> None:
"""Handle end key events to select the last item in the current window."""
if ui_state.input_focused:
ui_state.input_cursor = len(input_text)
return
if ui_state.current_window == 0:
select_channel(len(ui_state.channel_list) - 1)
elif ui_state.current_window == 1:
@@ -594,6 +810,9 @@ def handle_pagedown() -> None:
def handle_leftright(char: int) -> None:
"""Handle left/right key events to switch between windows."""
delta = -1 if char == curses.KEY_LEFT else 1
if ui_state.input_focused:
ui_state.input_cursor = max(0, min(len(input_text), ui_state.input_cursor + delta))
return
old_window = ui_state.current_window
ui_state.current_window = (ui_state.current_window + delta) % 3
if ui_state.single_pane_mode:
@@ -614,6 +833,8 @@ def handle_leftright(char: int) -> None:
def handle_function_keys(char: int) -> None:
"""Switch windows using F1/F2/F3."""
if ui_state.input_focused:
leave_message_input()
if char == curses.KEY_F1:
target = 0
elif char == curses.KEY_F2:
@@ -724,6 +945,28 @@ def handle_enter(input_text: str) -> str:
return input_text
def stage_node_details_background(stdscr, previous_window):
"""Restore the panes behind a moved dialog without displaying an intermediate frame."""
current_window = ui_state.current_window
try:
ui_state.current_window = previous_window
stdscr.touchwin()
stdscr.noutrefresh()
windows = (channel_win, messages_win, nodes_win)
for index, win in enumerate(windows):
if ui_state.single_pane_mode and index != previous_window:
continue
win.touchwin()
refresh_pad(index, commit=False)
if ui_state.display_log and (not ui_state.single_pane_mode or previous_window == 1):
packetlog_win.touchwin()
packetlog_win.noutrefresh()
entry_win.touchwin()
entry_win.noutrefresh()
finally:
ui_state.current_window = current_window
def handle_f5_key(stdscr: curses.window) -> None:
if not ui_state.node_list:
return
@@ -736,7 +979,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
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')}")
message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}")
user = node.get('user', {})
# Protobuf dictionaries omit CLIENT because it is the default enum value.
role = user.get('role', 'CLIENT') if user else 'Unknown'
message_parts.append(f"• Role: {role}")
message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}")
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
@@ -758,12 +1004,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
snr = node["snr"]
if isinstance(snr, Real):
snr_status = (
"🟢 Excellent"
"Excellent"
if snr > 10
else (
"🟡 Good"
"Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
else "Fair" if snr > -10 else "Poor" if snr > -20 else "Very Poor"
)
)
message_parts.append(f"• SNR: {snr}dB {snr_status}")
@@ -772,11 +1018,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
if node.get("hopsAway") is not None:
hops = node["hopsAway"]
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
message_parts.append(f"• Hops away: {hops}")
if node.get("lastHeard"):
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
message_parts.append(f"• Last heard: {get_time_ago(node['lastHeard'])}")
if node.get("deviceMetrics"):
metrics = node["deviceMetrics"]
@@ -785,28 +1030,23 @@ def handle_f5_key(stdscr: curses.window) -> None:
if metrics.get("batteryLevel") is not None:
battery = metrics["batteryLevel"]
battery_emoji = "🔴"
if isinstance(battery, Real):
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else ""
message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}")
message_parts.append(f"• Battery: {battery}%{voltage_info}")
if metrics.get("uptimeSeconds") is not None:
message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}")
message_parts.append(f"• Uptime: {get_readable_duration(metrics['uptimeSeconds'])}")
if metrics.get("channelUtilization") is not None:
util = metrics["channelUtilization"]
if isinstance(util, Real):
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
message_parts.append(f"• Channel utilization: {util:.2f}%")
else:
message_parts.append(f"• Channel utilization: {util}%")
if metrics.get("airUtilTx") is not None:
air_util = metrics["airUtilTx"]
if isinstance(air_util, Real):
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
message_parts.append(f"• Air utilization TX: {air_util:.2f}%")
else:
message_parts.append(f"• Air utilization TX: {air_util}%")
@@ -821,6 +1061,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
ui_state.current_window = 4
scroll_offset = 0
dialog_win = None
previous_layout = None
curses.curs_set(0)
refresh_node_selection(highlight=True)
@@ -840,11 +1081,24 @@ def handle_f5_key(stdscr: curses.window) -> None:
max_scroll = max(0, len(message_lines) - viewport_h)
scroll_offset = max(0, min(scroll_offset, max_scroll))
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
if previous_layout is not None and layout != previous_layout:
if (height, width) != previous_layout[-2:]:
# Rebuild the background before staging pads whose old
# screen coordinates may now lie outside the terminal.
ui_state.current_window = previous_window
try:
handle_resize(stdscr, False)
finally:
ui_state.current_window = 4
curses.curs_set(0)
stage_node_details_background(stdscr, previous_window)
previous_layout = layout
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)
@@ -889,7 +1143,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
except curses.error:
pass
dialog_win.refresh()
dialog_win.noutrefresh()
msg_win.noutrefresh()
curses.doupdate()
@@ -909,12 +1163,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
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)
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
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)
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
elif char == curses.KEY_PPAGE:
scroll_offset = max(0, scroll_offset - viewport_h)
elif char == curses.KEY_NPAGE:
@@ -973,14 +1227,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
def handle_backspace(entry_win: curses.window, input_text: str) -> str:
"""Handle backspace key events to remove the last character from input text."""
if input_text:
input_text = input_text[:-1]
y, x = entry_win.getyx()
entry_win.move(y, x - 1)
entry_win.addch(" ") #
entry_win.move(y, x - 1)
entry_win.refresh()
"""Delete before the draft cursor; the next frame redraws the entire line."""
focus_message_input()
position = ui_state.input_cursor
if position > 0:
input_text = input_text[:position - 1] + input_text[position:]
ui_state.input_cursor -= 1
return input_text
@@ -1000,54 +1252,83 @@ def handle_backtick(stdscr: curses.window) -> None:
handle_resize(stdscr, False)
return
options = ["Local settings"]
remote_node_num = None
selected_node_num = None
if ui_state.node_list:
remote_node_num = ui_state.node_list[ui_state.selected_node]
options.append(f"Remote admin: {get_name_from_database(remote_node_num)}")
choice = get_list_input("Open settings for", None, options)
if choice == "Local settings":
settings_menu(stdscr, interface)
elif remote_node_num is not None:
wait_win = None
selected_node_num = ui_state.node_list[ui_state.selected_node]
def remote_status(message: Optional[str]) -> None:
nonlocal wait_win
if message is None:
# The local node is already the target for normal settings; do not offer a
# misleading remote-admin choice when it is selected in the node pane.
if selected_node_num == interface_state.myNodeNum:
settings_menu(stdscr, interface)
else:
options = ["Local settings"]
remote_node_num = None
if selected_node_num is not None:
remote_node_num = selected_node_num
options.append(f"Remote admin: {get_name_from_database(remote_node_num)}")
choice = get_list_input("Open settings for", None, options)
if choice == "Local settings":
settings_menu(stdscr, interface)
elif remote_node_num is not None:
wait_win = None
def remote_status(message: Optional[str]) -> None:
nonlocal wait_win
if message is None:
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
wait_win = None
return
if wait_win is None:
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
update_remote_admin_wait(wait_win, message)
def remote_cancel_requested() -> bool:
if wait_win is None:
return False
try:
wait_win.timeout(0)
return wait_win.getch() == 27
finally:
wait_win.timeout(-1)
try:
remote_node = interface.getNode(remote_node_num, False)
verify_remote_admin(
remote_node,
status_callback=remote_status,
cancel_callback=remote_cancel_requested,
)
remote_status(None)
settings_menu(
stdscr,
interface,
# Settings requests configs explicitly; avoid getNode's
# implicit channel request racing that setup.
node=remote_node,
remote=True,
status_callback=remote_status,
cancel_callback=remote_cancel_requested,
)
except RemoteAdminCancelled:
logging.info("Remote admin request cancelled for %s", remote_node_num)
remote_status(None)
except (SystemExit, PermissionError) as exc:
logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc)
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
wait_win = None
return
if wait_win is None:
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
update_remote_admin_wait(wait_win, message)
try:
settings_menu(
stdscr,
interface,
# Settings requests configs explicitly; avoid getNode's
# implicit channel request racing that setup.
node=interface.getNode(remote_node_num, False),
remote=True,
status_callback=remote_status,
)
except SystemExit as exc:
logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc)
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
contact.ui.dialog.dialog(
"Remote admin rejected",
"The selected node did not authorize this admin request.",
)
except Exception as exc:
logging.exception("Remote admin failed for %s", remote_node_num)
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
contact.ui.dialog.dialog("Remote admin failed", str(exc))
contact.ui.dialog.dialog(
"Remote admin rejected",
"The selected node did not authorize this admin request.",
)
except Exception as exc:
logging.exception("Remote admin failed for %s", remote_node_num)
if wait_win is not None:
wait_win.erase()
wait_win.refresh()
contact.ui.dialog.dialog("Remote admin failed", str(exc))
ui_state.current_window = previous_window
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
curses.curs_set(1)
@@ -1056,7 +1337,7 @@ def handle_backtick(stdscr: curses.window) -> None:
handle_resize(stdscr, False)
def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.window | None:
def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> Optional[curses.window]:
"""Show progress while Meshtastic retrieves remote-admin settings."""
message = f"Attempting remote admin of {node_name}, waiting for response..."
try:
@@ -1075,7 +1356,7 @@ def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.wind
return None
def show_connection_status(stdscr: curses.window, title: str, message: str) -> curses.window | None:
def show_connection_status(stdscr: curses.window, title: str, message: str) -> Optional[curses.window]:
"""Display a non-blocking connection-status dialog over the current UI."""
try:
height, width = stdscr.getmaxyx()
@@ -1092,7 +1373,7 @@ def show_connection_status(stdscr: curses.window, title: str, message: str) -> c
return None
def update_remote_admin_wait(wait_win: curses.window | None, message: str) -> None:
def update_remote_admin_wait(wait_win: Optional[curses.window], message: str) -> None:
"""Append the current remote-admin request to the waiting overlay."""
if wait_win is None:
return
@@ -1373,14 +1654,17 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
return
messages_pad.erase()
# Erasing the pad also removes the old highlight. Its row range may no
# longer exist after switching conversations or rewrapping messages.
ui_state.highlighted_message_range = ()
channel = ui_state.channel_list[ui_state.selected_channel]
msg_line_count = 0
message_ranges = []
rendered_lines = []
if channel in ui_state.all_messages:
messages = ui_state.all_messages[channel]
rendered_lines = []
for prefix, message in messages:
start_line = len(rendered_lines)
full_message = normalize_message_text(f"{prefix}{message}")
@@ -1399,12 +1683,12 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
message_ranges.append((start_line, len(rendered_lines), color))
msg_line_count = len(rendered_lines)
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
paint_frame(messages_win, selected=(ui_state.current_window == 1))
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
@@ -1419,7 +1703,6 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
draw_packetlog_win()
draw_window_arrows(1)
@@ -1433,7 +1716,7 @@ def refresh_message_highlight() -> None:
previous_range = ui_state.highlighted_message_range
width = max(0, messages_win.getmaxyx()[1] - 2)
if not ui_state.channel_list or ui_state.current_window != 1:
if not ui_state.channel_list or ui_state.current_window != 1 or ui_state.input_focused:
if previous_range:
start, end, color = previous_range
for row in range(start, end):
@@ -1598,20 +1881,6 @@ def move_message_selection(direction: int) -> None:
set_message_selection(end - 1 if current_index == len(ranges) - 1 else start)
def select_last_message() -> None:
"""Select and reveal the newest message in the active channel."""
if not ui_state.channel_list:
return
channel = ui_state.channel_list[ui_state.selected_channel]
ranges = ui_state.message_line_ranges.get(channel, [])
if ranges:
_start, end, _color = ranges[-1]
set_message_selection(end - 1)
else:
set_message_selection(0)
def select_node(idx: int) -> None:
"""Select a node by index and update the UI state accordingly."""
old_selected_node = ui_state.selected_node
@@ -1714,7 +1983,7 @@ def search(win: int) -> None:
except curses.error:
break
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
if char in (chr(27), curses.KEY_ENTER, chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
@@ -1749,7 +2018,7 @@ def search(win: int) -> None:
entry_win.erase()
def refresh_pad(window: int) -> None:
def refresh_pad(window: int, commit: bool = True) -> None:
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
if ui_state.single_pane_mode and window != ui_state.current_window:
@@ -1766,7 +2035,7 @@ def refresh_pad(window: int) -> None:
if ui_state.display_log:
packetlog_win.box()
packetlog_win.refresh()
packetlog_win.noutrefresh()
elif window == 2:
pad = nodes_pad
@@ -1796,29 +2065,41 @@ def refresh_pad(window: int) -> None:
# Clamp start_index within the pad's height
pad_h, pad_w = pad.getmaxyx()
if pad_h <= 0:
if pad_h <= 0 or pad_w <= 0:
return
start_index = max(0, min(start_index, max(0, pad_h - 1)))
top = box_y + 1
left = box_x + 1
bottom = box_y + min(inner_h, lines) # inclusive
right = box_x + min(inner_w, box_w - 2)
# A resize can leave the frame larger than its pad or the screen.
# noutrefresh requires both source and destination rectangles to fit.
bottom = min(top + lines - 1, top + pad_h - start_index - 1, curses.LINES - 1)
right = min(left + inner_w - 1, left + pad_w - 1, curses.COLS - 1)
if bottom < top or right < left:
return
draw_frame_title(box, get_window_title(window))
box.refresh()
try:
box.noutrefresh()
pad.refresh(
start_index,
0,
top,
left,
bottom,
right,
)
# The frame overlaps the pad's viewport. Recompose even unchanged pad
# rows before committing, so its text cannot flash blank between draws.
pad.touchwin()
pad.noutrefresh(
start_index,
0,
top,
left,
bottom,
right,
)
if commit:
curses.doupdate()
except curses.error:
# The terminal can resize again between checking bounds and drawing.
# Let the next resize/redraw rebuild the windows instead of exiting.
ui_state.redraw_full_ui = True
def add_notification(channel_number: int) -> None:
+92 -23
View File
@@ -3,7 +3,6 @@ import curses
import ipaddress
import logging
import os
import sys
import threading
import time
from typing import List
@@ -25,11 +24,10 @@ from contact.utilities.input_handlers import (
)
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.menus import generate_menu_from_protobuf, update_ham_fields
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor
from contact.utilities.arg_parser import setup_parser
from contact.utilities.singleton import interface_state, menu_state
from contact.utilities.shared_state import app_state, interface_state, menu_state
# Setup Variables
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
@@ -41,6 +39,10 @@ help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
class RemoteAdminCancelled(Exception):
"""Raised when the user dismisses an in-progress remote-admin request."""
# Compute the effective menu width for the current terminal
def get_menu_width() -> int:
# Leave at least 2 columns for borders; clamp to >= 20 for usability
@@ -137,6 +139,12 @@ def display_menu() -> tuple[object, object]:
current_value = field_info[1] if isinstance(field_info, tuple) else ""
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
if menu_state.menu_path == ["Main Menu", "Channels"] and isinstance(field_info, dict):
# Keep slot keys for navigation/saving, but label rows with the
# configured name. Empty names retain their slot label.
channel_name = field_info.get("name", (None, ""))[1]
if isinstance(channel_name, str) and channel_name.strip():
display_name = channel_name
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
if isinstance(current_value, int):
@@ -236,7 +244,7 @@ def reconnect_interface_with_splash(stdscr: object, interface: object) -> object
except Exception:
pass
new_interface = reconnect_interface(setup_parser().parse_args())
new_interface = reconnect_interface(app_state.connection_args)
interface_state.interface = new_interface
redraw_main_ui_after_reconnect(stdscr)
return new_interface
@@ -275,12 +283,13 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None:
get_channels()
refresh_node_list()
contact_ui.handle_resize(stdscr, False)
if not contact_ui.ui_state.log_viewer_open:
contact_ui.handle_resize(stdscr, False)
except Exception:
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
def _request_remote_with_timeout(request, *args) -> None:
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
failure = []
@@ -292,24 +301,51 @@ def _request_remote_with_timeout(request, *args) -> None:
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
worker.start()
worker.join(REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS)
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
while worker.is_alive() and time.monotonic() < deadline:
worker.join(0.1)
if cancel_callback and cancel_callback():
raise RemoteAdminCancelled()
if worker.is_alive():
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
if failure:
raise failure[0]
def _request_remote_channels_with_timeout(node: object) -> None:
def _request_remote_channels_with_timeout(node: object, cancel_callback=None) -> None:
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
node.requestChannels()
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
while getattr(node, "channels", None) is None:
if cancel_callback and cancel_callback():
raise RemoteAdminCancelled()
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the remote node's channel list.")
time.sleep(0.1)
def _request_remote_section(node: object, menu_path: List[str], selected_option: str, status_callback=None) -> bool:
def verify_remote_admin(node: object, status_callback=None, cancel_callback=None) -> None:
"""Establish and verify an authorized remote-admin session.
Config reads alone are not a sufficient authorization check. The
Meshtastic admin-session exchange only provides a session passkey when
the remote node accepts this client's administrative credentials.
"""
if status_callback:
status_callback("Checking remote-admin permission…")
_request_remote_with_timeout(node.ensureSessionKey, cancel_callback=cancel_callback)
try:
remote_session = node.iface._getOrCreateByNum(node.nodeNum).get("adminSessionPassKey")
except (AttributeError, TypeError):
remote_session = None
if not remote_session:
raise PermissionError("The selected node did not authorize this admin request.")
def _request_remote_section(
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
) -> bool:
"""Fetch exactly the remote-admin section the user is opening.
Meshtastic returns radio and module settings one protobuf section at a
@@ -319,7 +355,15 @@ def _request_remote_section(node: object, menu_path: List[str], selected_option:
if menu_path == ["Main Menu"] and selected_option == "Channels":
if status_callback:
status_callback("Requesting channel list…")
_request_remote_channels_with_timeout(node)
_request_remote_channels_with_timeout(node, cancel_callback=cancel_callback)
return True
if menu_path == ["Main Menu"] and selected_option == "User Settings":
# HAM inputs derive their initial power/frequency from this node's LoRa config.
field = node.localConfig.DESCRIPTOR.fields_by_name["lora"]
if status_callback:
status_callback("Requesting lora config…")
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
return True
if len(menu_path) != 2:
@@ -338,12 +382,17 @@ def _request_remote_section(node: object, menu_path: List[str], selected_option:
return False
if status_callback:
status_callback(f"Requesting {selected_option} config…")
_request_remote_with_timeout(node.requestConfig, field)
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
return True
def settings_menu(
stdscr: object, interface: object, node: object = None, remote: bool = False, status_callback=None
stdscr: object,
interface: object,
node: object = None,
remote: bool = False,
status_callback=None,
cancel_callback=None,
) -> None:
curses.update_lines_cols()
global admin_target_label
@@ -465,6 +514,7 @@ def settings_menu(
break
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
node = interface.localNode
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
if len(menu_state.menu_path) > 1:
@@ -495,7 +545,7 @@ def settings_menu(
filename += ".yaml"
try:
config_text = config_export(interface)
config_text = config_export(interface, node=node)
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
@@ -560,7 +610,7 @@ def settings_menu(
["Yes", "No"],
)
if overwrite == "Yes":
config_import(interface, file_path)
config_import(interface, file_path, node=node)
menu_state.start_index.pop()
continue
@@ -663,12 +713,14 @@ def settings_menu(
menu_win.refresh()
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu
open_log_viewer = json_editor(stdscr, menu_state) # Open the App Settings menu
reload_translations()
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
menu_state.start_index.pop()
menu_state.selected_index = 4
if open_log_viewer:
break
continue
field_info = menu_state.current_menu.get(selected_option)
@@ -700,7 +752,7 @@ def settings_menu(
menu_state.need_redraw = True
continue
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName", "isLicensed", "isUnmessagable"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
@@ -708,7 +760,7 @@ def settings_menu(
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
elif selected_option == "isLicensed":
elif selected_option in ["isLicensed", "isUnmessagable"]:
new_value = get_list_input(
f"{human_readable_name} is currently: {current_value}",
str(current_value),
@@ -717,9 +769,6 @@ def settings_menu(
new_value = new_value == "True"
menu_state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in menu_state.current_menu.items():
modified_settings[option] = value
menu_state.start_index.pop()
elif selected_option in ["latitude", "longitude", "altitude"]:
@@ -740,6 +789,11 @@ def settings_menu(
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
menu_state.start_index.pop()
elif field is None and selected_option == "enabled" and menu_state.menu_path[1] == "Channels":
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
@@ -781,8 +835,17 @@ def settings_menu(
else: # Handle other field types
input_type = get_input_type_for_field(field)
allow_empty = (
menu_state.menu_path == ["Main Menu", "User Settings"]
and selected_option in ("short_name", "long_name")
) or (
selected_option == "name"
and len(menu_state.menu_path) == 3
and menu_state.menu_path[1] == "Channels"
)
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
f"{human_readable_name} is currently: {current_value}", selected_option, input_type,
allow_empty=allow_empty,
)
new_value = current_value if new_value is None else new_value
menu_state.start_index.pop()
@@ -807,12 +870,18 @@ def settings_menu(
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
menu_state.current_menu[selected_option] = (field, new_value)
if menu_state.menu_path == ["Main Menu", "User Settings"]:
update_ham_fields(menu_state.current_menu, node)
# Save the full HAM command, including fields that were not edited.
modified_settings.clear()
modified_settings.update({name: setting[1] for name, setting in menu_state.current_menu.items()})
menu_state.selected_index = list(menu_state.current_menu).index(selected_option)
else:
requested_remote_section = False
if remote:
try:
requested_remote_section = _request_remote_section(
node, menu_state.menu_path, selected_option, status_callback
node, menu_state.menu_path, selected_option, status_callback, cancel_callback
)
finally:
if status_callback:
+1 -1
View File
@@ -3,7 +3,7 @@ import curses
from contact.utilities.i18n import t_text
from contact.ui.colors import get_color
from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width
from contact.utilities.singleton import menu_state, ui_state
from contact.utilities.shared_state import menu_state, ui_state
def dialog(title: str, message: str) -> None:
+39 -1
View File
@@ -5,7 +5,7 @@ from collections import OrderedDict
from typing import Any, Union, Dict
from google.protobuf.message import Message
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, module_config_pb2
def encode_if_bytes(value: Any) -> str:
"""Encode byte values to base64 string."""
@@ -57,6 +57,38 @@ def extract_fields(
return menu
def update_ham_fields(user_menu, node):
"""Reflect the selected license flag using the installed HAM descriptors."""
params = admin_pb2.HamParameters()
fields = params.DESCRIPTOR.fields_by_name
licensed = user_menu.get("isLicensed", (None, False))[1] in (True, "True")
if not licensed:
call_sign = user_menu.get("call_sign", (None, ""))[1]
suffix = user_menu.get("long_name", (None, ""))[1]
user_menu.setdefault("longName", (None, " ".join(filter(None, (call_sign, suffix)))))
user_menu.setdefault("shortName", (None, user_menu.get("short_name", (None, ""))[1]))
for name in fields:
user_menu.pop(name, None)
return
owner_name = user_menu.get("longName", (None, ""))[1]
call_sign, _, suffix = owner_name.partition(" ")
defaults = {
"call_sign": call_sign,
"short_name": user_menu.get("shortName", (None, ""))[1],
"long_name": suffix,
"tx_power": node.localConfig.lora.tx_power,
"frequency": node.localConfig.lora.override_frequency,
}
for field in params.DESCRIPTOR.fields:
if field.name in defaults:
setattr(params, field.name, defaults[field.name])
for name, setting in extract_fields(params, params).items():
user_menu.setdefault(name, setting)
user_menu.pop("longName", None)
user_menu.pop("shortName", None)
def generate_menu_from_protobuf(interface: object, node: Any = None, include_app_settings: bool = True) -> Dict[str, Any]:
"""
Builds the full settings menu structure from the protobuf definitions.
@@ -76,7 +108,9 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
"longName": (None, current_user_config.get("longName", "Not Set")),
"shortName": (None, current_user_config.get("shortName", "Not Set")),
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
}
update_ham_fields(menu_structure["Main Menu"]["User Settings"], node)
else:
logging.info("User settings not found in Node Info")
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
@@ -92,6 +126,10 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
current_channel = node.getChannelByChannelIndex(i)
if current_channel:
channel_config = extract_fields(channel, current_channel.settings)
channel_config = {
"enabled": (None, current_channel.role != channel_pb2.Channel.Role.DISABLED),
**channel_config,
}
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
# Add Radio Settings
+1 -1
View File
@@ -6,7 +6,7 @@ from contact.ui.colors import get_color
from contact.utilities.i18n import t
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.shared_state import interface_state, ui_state
def get_node_color(node_index: int, reverse: bool = False):
+17 -5
View File
@@ -1,8 +1,10 @@
import curses
import textwrap
from contact.ui.colors import get_color
from contact.version import __version__
def draw_splash(stdscr: object) -> None:
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
@@ -13,16 +15,26 @@ def draw_splash(stdscr: object) -> None:
message_1 = "/ Λ"
message_2 = "/ / \\"
message_3 = "P W R D"
message_4 = "connecting..."
start_x = width // 2 - len(message_1) // 2
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.move(start_y + 5, start_x2)
if __version__:
ver_line = f"v{__version__}"
ver_x = max(1, (width - len(ver_line)) // 2)
ver_y = start_y + 3
if ver_y < height - 1:
stdscr.addstr(ver_y, ver_x, ver_line, get_color("splash_text"))
status_lines = []
for line in status.splitlines():
status_lines.extend(textwrap.wrap(line, max(1, width - 4)) or [""])
for offset, line in enumerate(status_lines):
row = start_y + 4 + offset
if row >= height - 1:
break
stdscr.addstr(row, max(1, (width - len(line)) // 2), line, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
+10
View File
@@ -25,6 +25,8 @@ class ChatUIState:
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
input_focused: bool = False
input_cursor: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
selected_index: int = 0
@@ -40,6 +42,13 @@ class ChatUIState:
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
reconnect_attempted: bool = False
reconnect_prompt_open: bool = False
log_viewer_open: bool = False
log_viewer_follow: bool = True
log_viewer_start_line: int = 0
log_viewer_lines: List[str] = field(default_factory=list)
log_viewer_signature: Any = None
log_viewer_loaded: bool = False
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
@@ -59,3 +68,4 @@ class InterfaceState:
@dataclass
class AppState:
lock: Any = None
connection_args: Any = None
+52 -18
View File
@@ -1,5 +1,4 @@
import os
import json
import curses
from typing import Any, List, Dict, Optional
@@ -9,12 +8,14 @@ from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
from contact.utilities.shared_state import menu_state, ui_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
save_option = "Save Changes"
VIEW_LOG_OPTION = "__view_log__"
LOG_VIEWER_LINE_LIMIT = 500
translation_file = config.get_localisation_file(config.language)
field_mapping, help_text = parse_ini_file(translation_file)
translation_language = config.language
@@ -86,6 +87,32 @@ def get_effective_width() -> int:
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def load_log_tail(path: str, max_lines: int = LOG_VIEWER_LINE_LIMIT) -> List[str]:
"""Read the final log lines without loading an arbitrarily large log file."""
if max_lines <= 0:
return []
try:
with open(path, "rb") as log_file:
log_file.seek(0, os.SEEK_END)
position = log_file.tell()
chunks = []
newline_count = 0
while position > 0 and newline_count <= max_lines:
read_size = min(8192, position)
position -= read_size
log_file.seek(position)
chunk = log_file.read(read_size)
chunks.append(chunk)
newline_count += chunk.count(b"\n")
data = b"".join(reversed(chunks)).decode("utf-8", errors="replace")
lines = data.splitlines()
if position > 0 and lines:
lines = lines[1:]
return lines[-max_lines:]
except OSError as exc:
return [f"Unable to read log: {exc}"]
def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
@@ -273,6 +300,8 @@ def display_menu() -> tuple[Any, Any, List[str]]:
# Determine menu items based on the type of current_menu
if isinstance(menu_state.current_menu, dict):
options = list(menu_state.current_menu.keys())
if len(menu_state.menu_path) <= 2:
options.insert(0, VIEW_LOG_OPTION)
elif isinstance(menu_state.current_menu, list):
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
else:
@@ -311,10 +340,13 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_state.current_menu[key]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
) if key != VIEW_LOG_OPTION else ""
if isinstance(menu_state.current_menu, dict):
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
if key == VIEW_LOG_OPTION:
display_key = field_mapping.get("app_settings.view_log", "View Log")
else:
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
@@ -391,27 +423,20 @@ def update_app_settings_help(menu_win: curses.window, options: List[str]) -> Non
)
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
file_path = os.path.join(parent_dir, "config.json")
file_path = config.json_file_path
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# Ensure the file exists
if not os.path.exists(file_path):
with open(file_path, "w") as f:
json.dump({}, f)
# Load JSON data
with open(file_path, "r", encoding="utf-8") as f:
original_data = json.load(f)
# Use the same path and defaults as startup, including the writable
# fallback for shared installations and CONTACT_CONFIG_ROOT overrides.
original_data = config.initialize_config()
data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu
@@ -470,6 +495,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
if selected_key == VIEW_LOG_OPTION:
ui_state.log_viewer_open = True
ui_state.log_viewer_follow = True
ui_state.log_viewer_start_line = 0
ui_state.log_viewer_lines = []
ui_state.log_viewer_signature = None
ui_state.log_viewer_loaded = False
return True
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index)
@@ -573,6 +606,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
break
return False
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
@@ -583,7 +618,6 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
def main(stdscr: curses.window) -> None:
from contact.ui.ui_state import MenuState
if len(menu_state.menu_path) == 0:
menu_state.menu_path = ["App Settings"] # Initialize if not set
+6 -3
View File
@@ -1,16 +1,19 @@
from argparse import ArgumentParser
from contact.version import __version__
def setup_parser() -> ArgumentParser:
parser = ArgumentParser(
add_help=True,
description="A terminal client for Meshtastic messaging and node settings.",
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.",
)
parser.add_argument("--version", "-V", action="version", version="%(prog)s " + __version__)
connOuter = parser.add_argument_group(
connection_group = parser.add_argument_group(
"Connection", "Optional arguments to specify a device to connect to and how."
)
conn = connOuter.add_mutually_exclusive_group()
conn = connection_group.add_mutually_exclusive_group()
conn.add_argument(
"--port",
"--serial",
+28 -23
View File
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename):
def config_import(interface, filename, node=None):
"""Apply a configuration file to the selected local or remote node."""
node = node or interface.localNode
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
interface.getNode("^local", False).beginSettingsTransaction()
node.beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(configuration["owner"])
node.setOwner(configuration["owner"])
time.sleep(0.5)
if "owner_short" in configuration:
logging.info(f"Setting device owner short to {configuration['owner_short']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
node.setOwner(long_name=None, short_name=configuration["owner_short"])
time.sleep(0.5)
if "ownerShort" in configuration:
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
time.sleep(0.5)
if "channel_url" in configuration:
logging.info(f"Setting channel url to {configuration['channel_url']}")
interface.getNode("^local").setURL(configuration["channel_url"])
node.setURL(configuration["channel_url"])
time.sleep(0.5)
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode("^local").setURL(configuration["channelUrl"])
node.setURL(configuration["channelUrl"])
time.sleep(0.5)
if "location" in configuration:
alt = 0
lat = 0.0
lon = 0.0
localConfig = interface.localNode.localConfig
if "alt" in configuration["location"]:
alt = int(configuration["location"]["alt"] or 0)
logging.info(f"Fixing altitude at {alt} meters")
@@ -185,39 +185,44 @@ def config_import(interface, filename):
lon = float(configuration["location"]["lon"] or 0)
logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position")
interface.localNode.setFixedPosition(lat, lon, alt)
node.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
if "config" in configuration:
localConfig = interface.getNode("^local").localConfig
localConfig = node.localConfig
for section in configuration["config"]:
traverseConfig(section, configuration["config"][section], localConfig)
interface.getNode("^local").writeConfig(camel_to_snake(section))
node.writeConfig(camel_to_snake(section))
time.sleep(0.5)
if "module_config" in configuration:
moduleConfig = interface.getNode("^local").moduleConfig
moduleConfig = node.moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
node.writeConfig(camel_to_snake(section))
time.sleep(0.5)
interface.getNode("^local", False).commitSettingsTransaction()
node.commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface) -> str:
"""used in --export-config"""
def config_export(interface, node=None) -> str:
"""Export the selected node's configuration as Meshtastic configure YAML."""
configObj = {}
node = node or interface.localNode
owner = interface.getLongName()
owner_short = interface.getShortName()
channel_url = interface.localNode.getURL()
myinfo = interface.getMyNodeInfo()
if node is interface.localNode:
myinfo = interface.getMyNodeInfo()
else:
myinfo = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None), {})
user = myinfo.get("user", {}) if isinstance(myinfo, dict) else {}
owner = user.get("longName")
owner_short = user.get("shortName")
channel_url = node.getURL()
pos = myinfo.get("position")
lat = None
lon = None
@@ -242,7 +247,7 @@ def config_export(interface) -> str:
if alt:
configObj["location"]["alt"] = alt
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
if config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -265,7 +270,7 @@ def config_export(interface) -> str:
else:
configObj["config"] = config
module_config = MessageToDict(interface.localNode.moduleConfig)
module_config = MessageToDict(node.moduleConfig)
if module_config:
# Convert inner keys to correct snake/camelCase
prefs = {}
+4 -2
View File
@@ -8,7 +8,7 @@ from contact.utilities.utils import build_reply_prefix, decimal_to_hex
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
from contact.utilities.shared_state import ui_state, interface_state
MESSAGE_PAGE_SIZE = 100
@@ -35,6 +35,7 @@ def save_message_to_db(
message_text: str,
packet_id: Optional[int] = None,
reply_id: Optional[int] = None,
timestamp: Optional[int] = None,
) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
@@ -54,7 +55,8 @@ def save_message_to_db(
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
_ensure_message_columns(db_cursor, quoted_table_name)
timestamp = int(time.time())
if timestamp is None:
timestamp = int(time.time())
# Insert the message
insert_query = f"""
-1
View File
@@ -6,7 +6,6 @@ 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"
+8 -5
View File
@@ -2,14 +2,14 @@ import base64
import binascii
import curses
import ipaddress
from typing import Any, Optional, List
from typing import Optional, List
from contact.ui.colors import get_color
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
from contact.ui.dialog import dialog
from contact.utilities.i18n import t, t_text
from contact.utilities.validation_rules import get_validation_for
from contact.utilities.singleton import menu_state
from contact.utilities.shared_state import menu_state
# Dialogs should be at most 80 cols, but shrink on small terminals
MAX_DIALOG_WIDTH = 80
@@ -38,7 +38,7 @@ def invalid_input(window: curses.window, message: str, redraw_func: Optional[cal
curses.curs_set(1)
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
def get_text_input(prompt: str, selected_config: str, input_type: str, *, allow_empty: bool = False) -> Optional[str]:
"""Handles user input with wrapped text for long prompts."""
def redraw_input_win():
@@ -127,6 +127,9 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
menu_state.need_redraw = True
if not user_input.strip():
if allow_empty:
user_input = ""
break
invalid_input(
input_win,
t("ui.error.value_empty", default="Value cannot be empty."),
@@ -367,7 +370,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
pass # Ignore invalid character inputs
from contact.utilities.singleton import menu_state # Required if not already imported
from contact.utilities.shared_state import menu_state # Required if not already imported
def get_repeated_input(current_value: List[str]) -> Optional[str]:
@@ -457,7 +460,7 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
pass
from contact.utilities.singleton import menu_state # Ensure this is imported
from contact.utilities.shared_state import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
+69 -7
View File
@@ -1,13 +1,61 @@
import logging
import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
from meshtastic.protobuf import mesh_pb2
def initialize_interface(args):
class _StatusMessageConfigMixin:
"""Read status-message config omitted by Meshtastic 2.7.11's receiver."""
def _handleFromRadio(self, fromRadioBytes):
packet = mesh_pb2.FromRadio()
packet.ParseFromString(fromRadioBytes)
if packet.HasField("moduleConfig") and packet.moduleConfig.HasField("statusmessage"):
self.localNode.moduleConfig.statusmessage.CopyFrom(packet.moduleConfig.statusmessage)
return super()._handleFromRadio(fromRadioBytes)
class SerialInterface(_StatusMessageConfigMixin, meshtastic.serial_interface.SerialInterface):
pass
class TCPInterface(_StatusMessageConfigMixin, meshtastic.tcp_interface.TCPInterface):
pass
class BLEInterface(_StatusMessageConfigMixin, meshtastic.ble_interface.BLEInterface):
pass
def interface_is_connected(interface) -> bool:
"""Return the transport-independent connection state for an interface.
TCPInterface deliberately leaves ``stream`` set to ``None`` and uses its
``socket`` instead. Checking only ``stream`` therefore treats every TCP
connection (including meshtasticd) as disconnected.
"""
if interface is None:
return False
connected = getattr(interface, "isConnected", None)
if hasattr(connected, "is_set"):
return connected.is_set()
if isinstance(connected, bool):
return connected
if hasattr(interface, "socket"):
return getattr(interface, "socket", None) is not None
if hasattr(interface, "stream"):
return getattr(interface, "stream", None) is not None
return getattr(interface, "localNode", None) is not None
def initialize_interface(args, status_callback=None):
try:
if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
return BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
try:
@@ -16,12 +64,15 @@ def initialize_interface(args):
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
return TCPInterface(tcp_hostname, portNumber=tcp_port)
except Exception as ex:
logging.error(f"Error connecting to {args.host}. {ex}")
else:
try:
client = meshtastic.serial_interface.SerialInterface(args.port)
# The library prints its fallback notice directly to stdout,
# which would overwrite the curses splash screen.
with contextlib.redirect_stdout(io.StringIO()):
client = SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
@@ -34,7 +85,9 @@ def initialize_interface(args):
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
if client.devPath is None:
try:
client = meshtastic.tcp_interface.TCPInterface("localhost")
if status_callback:
status_callback("No serial device found.\nConnecting via TCP to localhost…")
client = TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
@@ -46,13 +99,19 @@ def initialize_interface(args):
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
last_error = None
target = getattr(args, "host", None) or getattr(args, "port", None) or getattr(args, "ble", None) or "auto"
for attempt in range(attempts):
attempt_number = attempt + 1
logging.info("Reconnect attempt %d/%d to %s", attempt_number, attempts, target)
try:
interface = initialize_interface(args)
if interface is not None and getattr(interface, "localNode", None) is not None and getattr(
if interface_is_connected(interface) and getattr(interface, "localNode", None) is not None and getattr(
interface.localNode, "localConfig", None
) is not None and (not hasattr(interface, "stream") or interface.stream is not None):
) is not None:
node_num = getattr(interface.localNode, "nodeNum", None)
node_label = f" !{node_num:08x}" if isinstance(node_num, int) else ""
logging.info("Reconnected to Meshtastic node%s on attempt %d/%d", node_label, attempt_number, attempts)
return interface
last_error = RuntimeError("interface did not complete connection setup")
try:
@@ -62,7 +121,10 @@ def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
except Exception as ex:
last_error = ex
logging.warning("Reconnect attempt %d/%d failed: %s", attempt_number, attempts, last_error)
if attempt < attempts - 1:
time.sleep(delay_seconds)
logging.error("Unable to reconnect to Meshtastic node after %d attempts", attempts)
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
+26
View File
@@ -0,0 +1,26 @@
import logging
import os
import sys
import contact.ui.default_config as config
def configure_logging(level: int) -> None:
"""Set up file logging or exit with instructions for fixing its path."""
try:
logging.basicConfig(
filename=config.log_file_path,
level=level,
format="%(asctime)s - %(levelname)s - %(message)s",
force=True,
)
except OSError as error:
print(
f"Unable to open log file: {os.path.abspath(config.log_file_path)}\n"
f"{error}\n"
f"Please edit config.json at: {os.path.abspath(config.json_file_path)}\n"
'Set "log_file_path" to a file in an existing, writable directory, '
"then restart Contact.",
file=sys.stderr,
)
raise SystemExit(1) from None
+55 -31
View File
@@ -1,8 +1,7 @@
from meshtastic.protobuf import channel_pb2
from meshtastic.protobuf import admin_pb2, channel_pb2
from google.protobuf.message import Message
import logging
import base64
import time
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
POWER_REBOOT_KEYS = {
@@ -29,7 +28,7 @@ LORA_REBOOT_KEYS = {
"sx126x_rx_boosted_gain",
}
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
def _collect_changed_keys(modified_settings):
@@ -100,22 +99,14 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.")
else:
# Clear existing keys if needed
if security_config.admin_key:
logging.info("Clearing existing admin keys...")
del security_config.admin_key[:]
node.writeConfig("security")
time.sleep(2) # Give time for device to process
# Append new keys
for key in valid_keys:
logging.info(f"Adding admin key: {key}")
security_config.admin_key.append(key)
node.writeConfig("security")
logging.info("Admin keys updated successfully!")
# Update the repeated field in memory, then send one complete
# security config. Sending an empty list first can revoke the key
# authorizing this remote-admin session before a second write can
# restore the remaining keys.
del security_config.admin_key[:]
security_config.admin_key.extend(valid_keys)
node.writeConfig("security")
logging.info("Admin keys updated successfully (%d key(s)).", len(valid_keys))
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
@@ -143,12 +134,31 @@ def save_changes(interface, modified_settings, menu_state, node=None):
long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName")
is_licensed = modified_settings.get("isLicensed")
is_unmessagable = modified_settings.get("isUnmessagable")
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
if is_unmessagable is not None:
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
node.setOwner(long_name, short_name, is_licensed)
if is_licensed and "call_sign" in modified_settings:
message = admin_pb2.AdminMessage()
for field in message.set_ham_mode.DESCRIPTOR.fields:
if field.name in modified_settings:
setattr(message.set_ham_mode, field.name, modified_settings[field.name])
node.ensureSessionKey()
# Keep the separate user flag and HAM command in one settings transaction.
node.beginSettingsTransaction()
if is_unmessagable is not None:
node.setOwner(None, None, True, is_unmessagable)
callback = None if node is interface.localNode else node.onAckNak
node._sendAdmin(message, onResponse=callback)
node.commitSettingsTransaction()
return True
node.setOwner(long_name, short_name, is_licensed, is_unmessagable)
logging.info(
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
)
return _requires_reconnect(menu_state, modified_settings)
@@ -157,25 +167,31 @@ def save_changes(interface, modified_settings, menu_state, node=None):
config_category = "Channels"
try:
channel = menu_state.menu_path[-1]
channels_index = menu_state.menu_path.index("Channels")
channel = menu_state.menu_path[channels_index + 1]
channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e:
channel_num = None
channel = node.channels[channel_num]
for key, value in modified_settings.items():
if key == "psk": # Special case: decode Base64 for psk
if key == "enabled":
if not value:
channel.role = channel_pb2.Channel.Role.DISABLED
elif channel.role == channel_pb2.Channel.Role.DISABLED:
channel.role = (
channel_pb2.Channel.Role.PRIMARY if channel_num == 0
else channel_pb2.Channel.Role.SECONDARY
)
elif key == "psk": # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == "position_precision": # Special case: module_settings
channel.settings.module_settings.position_precision = value
elif key in {"position_precision", "is_muted"}:
# These fields belong to ChannelSettings.module_settings,
# not ChannelSettings itself.
setattr(channel.settings.module_settings, key, value)
else:
setattr(channel.settings, key, value) # Use setattr for other fields
if channel_num == 0:
channel.role = channel_pb2.Channel.Role.PRIMARY
else:
channel.role = channel_pb2.Channel.Role.SECONDARY
node.writeChannel(channel_num)
logging.info(f"Updated Channel {channel_num} in {config_category}")
@@ -232,7 +248,15 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Write the configuration changes to the node
try:
node.writeConfig(config_category)
if config_category == "statusmessage":
# Meshtastic 2.7.11 includes this protobuf but omits it from
# writeConfig's dispatch, which exits for unknown categories.
message = admin_pb2.AdminMessage()
message.set_module_config.statusmessage.CopyFrom(node.moduleConfig.statusmessage)
callback = None if node is interface.localNode else node.onAckNak
node._sendAdmin(message, onResponse=callback)
else:
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None:
File renamed without changes.
+34 -8
View File
@@ -13,9 +13,29 @@ sensors = {
'channel_utilization': {'icon':'ChUtil:', 'unit':'%'},
'air_util_tx': {'icon':'AirUtil:', 'unit':'%'},
'uptime_seconds': {'icon':'🆙 ', 'unit':'h'},
'num_packets_tx': {'icon':'📤 Tx:', 'unit':''},
'num_packets_rx': {'icon':'📥 Rx:', 'unit':''},
'num_packets_rx_bad': {'icon':'⚠️ RxBad:', 'unit':''},
'num_online_nodes': {'icon':'📡 Online:', 'unit':''},
'num_total_nodes': {'icon':'📡 Total:', 'unit':''},
'num_rx_dupe': {'icon':'📥 Dupe:', 'unit':''},
'num_tx_relay': {'icon':'📤 Relay:', 'unit':''},
'num_tx_relay_canceled': {'icon':'📤 Canceled:', 'unit':''},
'num_tx_dropped': {'icon':'⚠️ Dropped:', 'unit':''},
'heap_total_bytes': {'icon':'💾 Total:', 'unit':'B'},
'heap_free_bytes': {'icon':'💾 Free:', 'unit':'B'},
'noise_floor': {'icon':'📶 Noise:', 'unit':'dBm'},
'latitude_i': {'icon':'🌍 ', 'unit':''},
'longitude_i': {'icon':'', 'unit':''},
'altitude': {'icon':'⬆️ ', 'unit':'m'},
'location_source': {'icon':'📍 ', 'unit':''},
'altitude_source': {'icon':'⬆️ Source:', 'unit':''},
'ground_speed': {'icon':'💨 ', 'unit':'m/s'},
'ground_track': {'icon':'🧭 ', 'unit':'°'},
'PDOP': {'icon':'🎯 PDOP:', 'unit':''},
'HDOP': {'icon':'🎯 HDOP:', 'unit':''},
'VDOP': {'icon':'🎯 VDOP:', 'unit':''},
'sats_in_view': {'icon':'🛰️ ', 'unit':''},
'time': {'icon':'🕔 ', 'unit':''}
}
@@ -55,18 +75,20 @@ def get_chunks(data):
parsed=""
for item in reading:
key, value = item.split(":")
key, value = item.split(":", 1)
key = key.strip()
value = value.strip()
# If value is float, round it to the 1 digit after point
# else make it int
if "." in value:
value = round(float(value.strip()),1)
else:
try:
value = int(value)
except ValueError:
try:
value = int(value.strip())
except Exception:
# Leave it string as last resort
value = value
value = round(float(value), 1)
except ValueError:
# Leave nonnumeric values intact, including strings with periods.
pass
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
@@ -79,6 +101,10 @@ def get_chunks(data):
elif key == "wind_direction":
# Convert wind direction from degrees to abbreviation
value = humanize_wind_direction(value)
elif key in ("PDOP", "HDOP", "VDOP"):
value = round(value / 100, 2)
elif key == "ground_track":
value = round(value / 100, 2)
elif key == "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
+44 -10
View File
@@ -1,23 +1,52 @@
import datetime
import logging
import re
import time
from typing import Optional, Union
from functools import lru_cache
from typing import Union
from google.protobuf.message import DecodeError
from meshtastic import protocols
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
from meshtastic.protobuf import config_pb2, portnums_pb2
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
from contact.utilities.shared_state import ui_state, interface_state
import contact.utilities.telemetry_beautifier as tb
NEW_MODEM_PRESET_NAMES = {
14: "TINY_FAST",
15: "TINY_SLOW",
16: "MEDIUM_TURBO",
}
@lru_cache(maxsize=None)
def _get_modem_preset_name(modem_preset_enum: int) -> str:
try:
return config_pb2.Config.LoRaConfig.ModemPreset.Name(modem_preset_enum)
except ValueError:
compatibility_name = NEW_MODEM_PRESET_NAMES.get(modem_preset_enum)
if compatibility_name is not None:
logging.warning(
"Modem preset %s is newer than the installed Meshtastic protobuf schema; "
"using compatibility name %s",
modem_preset_enum,
compatibility_name,
)
return compatibility_name
logging.warning(
"Modem preset %s is not recognized by the installed Meshtastic protobuf schema",
modem_preset_enum,
)
return f"UNKNOWN_PRESET_{modem_preset_enum}"
def _get_channel_name(device_channel, node):
if device_channel.settings.name:
return device_channel.settings.name
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_enum = node.localConfig.lora.modem_preset
modem_preset_string = _get_modem_preset_name(modem_preset_enum)
return convert_to_camel_case(modem_preset_string)
@@ -195,7 +224,7 @@ def get_reply_context(reply_id):
return ""
def add_new_message(channel_id, prefix, message, packet_id=None):
def add_new_message(channel_id, prefix, message, packet_id=None, timestamp=None):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
@@ -204,8 +233,10 @@ def add_new_message(channel_id, prefix, message, packet_id=None):
packet_ids.append(None)
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
if timestamp is None:
timestamp = time.time()
message_time = datetime.datetime.fromtimestamp(timestamp)
current_hour = message_time.strftime("%Y-%m-%d %H:00")
# Retrieve the last timestamp if available
channel_messages = ui_state.all_messages[channel_id]
@@ -226,7 +257,7 @@ def add_new_message(channel_id, prefix, message, packet_id=None):
packet_ids.append(None)
# Add the message
ts_str = time.strftime("[%H:%M:%S] ")
ts_str = message_time.strftime("[%H:%M:%S] ")
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
packet_ids.append(packet_id)
@@ -267,6 +298,9 @@ def parse_protobuf(packet: dict) -> Union[str, dict]:
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
return tb.get_chunks(str(pb.environment_metrics))
if hasattr(pb, "local_stats") and pb.HasField("local_stats"):
return tb.get_chunks(str(pb.local_stats))
# For other data, without implemented beautification, fallback to just printing the object
return str(pb).replace("\n", " ").replace("\r", " ").strip()
+8
View File
@@ -0,0 +1,8 @@
"""Application version shared by the CLI and UI."""
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("contact")
except PackageNotFoundError:
__version__ = "dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.6.2"
version = "1.7.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"}
+14 -1
View File
@@ -1,6 +1,6 @@
import unittest
from contact.utilities.arg_parser import setup_parser
from contact.utilities.arg_parser import __version__, setup_parser
class ArgParserTests(unittest.TestCase):
@@ -11,3 +11,16 @@ class ArgParserTests(unittest.TestCase):
def test_demo_screenshot_defaults_to_false(self) -> None:
args = setup_parser().parse_args([])
self.assertFalse(args.demo_screenshot)
def test_version_flag_exits_zero(self) -> None:
with self.assertRaises(SystemExit) as ctx:
setup_parser().parse_args(["--version"])
self.assertEqual(ctx.exception.code, 0)
def test_version_short_flag_exits_zero(self) -> None:
with self.assertRaises(SystemExit) as ctx:
setup_parser().parse_args(["-V"])
self.assertEqual(ctx.exception.code, 0)
def test_version_is_not_none(self) -> None:
self.assertIsNotNone(__version__)
+15 -14
View File
@@ -5,8 +5,8 @@ import types
from unittest import mock
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state
from tests.test_support import reset_singletons
from contact.utilities.shared_state import interface_state
from tests.test_support import reset_shared_state
class BotHandlerTests(unittest.TestCase):
@@ -19,11 +19,11 @@ class BotHandlerTests(unittest.TestCase):
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.bot_handler.send_message.reset_mock()
def tearDown(self) -> None:
reset_singletons()
reset_shared_state()
def test_is_bot_message_uses_configured_catch_words(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"):
@@ -40,18 +40,19 @@ class BotHandlerTests(unittest.TestCase):
packet = {"id": 900, "from": 222, "decoded": {}}
with mock.patch.object(config, "ping_bot_enabled", "True"):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
with mock.patch.object(self.bot_handler, "get_reply_context", return_value="<Re: NODE: ping> "):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
self.bot_handler.send_message.assert_called_once_with(
"Pong!", channel=0, reply_id=900
"Pong!", channel=0, reply_id=900, reply_context="<Re: NODE: ping> "
)
def test_bot_response_requires_enabled_app_setting(self) -> None:
+63
View File
@@ -0,0 +1,63 @@
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.__main__ as cli
import contact.settings as settings_cli
from contact.version import __version__
class CliTests(unittest.TestCase):
def test_help_version_and_errors_work_without_runtime_configuration(self) -> None:
cases = [
(["--help"], 0, "usage:"),
(["-h"], 0, "usage:"),
(["--version"], 0, __version__),
(["-V"], 0, __version__),
(["--unknown"], 2, "unrecognized arguments"),
(["--host", "localhost", "--ble"], 2, "not allowed with argument"),
(["--", "--version"], 2, "unrecognized arguments"),
]
with tempfile.TemporaryDirectory() as directory:
config_root = Path(directory) / "missing"
env = dict(os.environ, CONTACT_CONFIG_ROOT=str(config_root))
for command in (["-m", "contact"], ["-m", "contact.settings"],
["-c", "from contact.__main__ import start; start()"]):
for flags, code, expected in cases:
with self.subTest(command=command, flags=flags):
result = subprocess.run(
[sys.executable, *command, *flags], env=env,
capture_output=True, text=True, timeout=10,
)
self.assertEqual(result.returncode, code, result.stderr)
self.assertIn(expected, result.stderr if code else result.stdout)
self.assertEqual(result.stdout if code else result.stderr, "")
self.assertFalse(config_root.exists())
def test_control_aliases_preserve_connection_options(self) -> None:
for alias in ("--settings", "--set", "--control", "-c"):
for connection, value, attribute in (
("--host", "mesh.local", "host"),
("--port", "/dev/ttyUSB0", "port"),
("--ble", "my-radio", "ble"),
):
with self.subTest(alias=alias, connection=connection):
runtime = SimpleNamespace(start=mock.Mock())
with mock.patch.dict(sys.modules, {"contact.settings_runtime": runtime}):
cli.start([alias, connection, value])
runtime.start.assert_called_once()
self.assertEqual(getattr(runtime.start.call_args.args[0], attribute), value)
def test_main_and_standalone_settings_pass_parsed_options(self) -> None:
for entrypoint, module in ((cli, "contact.runtime"),
(settings_cli, "contact.settings_runtime")):
runtime = SimpleNamespace(start=mock.Mock())
with mock.patch.dict(sys.modules, {module: runtime}):
entrypoint.start(["--host=mesh.local"])
runtime.start.assert_called_once()
self.assertEqual(runtime.start.call_args.args[0].host, "mesh.local")
+46 -1
View File
@@ -1,6 +1,12 @@
import unittest
from types import SimpleNamespace
from tempfile import NamedTemporaryFile
from unittest.mock import Mock
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
import yaml
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.utilities.config_io import _is_repeated_field, config_export, config_import, splitCompoundName
class ConfigIoTests(unittest.TestCase):
@@ -19,3 +25,42 @@ class ConfigIoTests(unittest.TestCase):
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
self.assertTrue(_is_repeated_field(field_type()))
def test_config_export_uses_selected_remote_node(self) -> None:
local_node = SimpleNamespace(nodeNum=1)
remote_node = SimpleNamespace(
nodeNum=2,
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getURL=lambda: "remote-channel-url",
)
interface = SimpleNamespace(
localNode=local_node,
nodesByNum={
2: {
"user": {"longName": "Remote Node", "shortName": "REM"},
"position": {"latitude": 1.25, "longitude": 2.5, "altitude": 12},
}
},
)
exported = yaml.safe_load(config_export(interface, node=remote_node))
self.assertEqual(exported["owner"], "Remote Node")
self.assertEqual(exported["owner_short"], "REM")
self.assertEqual(exported["channel_url"], "remote-channel-url")
self.assertEqual(exported["location"], {"lat": 1.25, "lon": 2.5, "alt": 12})
def test_config_import_uses_selected_remote_node(self) -> None:
remote_node = Mock()
interface = SimpleNamespace(localNode=Mock())
with NamedTemporaryFile("w", suffix=".yaml") as config_file:
yaml.safe_dump({"owner": "Remote Node"}, config_file)
config_file.flush()
config_import(interface, config_file.name, node=remote_node)
remote_node.beginSettingsTransaction.assert_called_once_with()
remote_node.setOwner.assert_called_once_with("Remote Node")
remote_node.commitSettingsTransaction.assert_called_once_with()
interface.localNode.setOwner.assert_not_called()
+221 -8
View File
@@ -1,23 +1,149 @@
import unittest
from contextlib import ExitStack
from types import SimpleNamespace
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 contact.utilities.shared_state import ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class ContactUiTests(unittest.TestCase):
def test_refresh_pad_clips_to_pad_and_resized_screen(self):
for pad_size, screen_size, expected in (
((2, 5), (24, 80), (1, 0, 1, 1, 1, 5)),
((100, 100), (6, 8), (1, 0, 1, 1, 5, 7)),
):
with self.subTest(pad_size=pad_size, screen_size=screen_size), ExitStack() as stack:
box = mock.Mock()
box.getmaxyx.return_value = (40, 100)
box.getbegyx.return_value = (0, 0)
pad = mock.Mock()
pad.getmaxyx.return_value = pad_size
ui_state.selected_channel = 38
stack.enter_context(mock.patch.object(contact_ui, "channel_win", box, create=True))
stack.enter_context(mock.patch.object(contact_ui, "channel_pad", pad, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", screen_size[0], create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", screen_size[1], create=True))
contact_ui.refresh_pad(0, commit=False)
pad.noutrefresh.assert_called_once_with(*expected)
pad.noutrefresh.side_effect = contact_ui.curses.error("resize during refresh")
contact_ui.refresh_pad(0, commit=False)
self.assertTrue(ui_state.redraw_full_ui)
def test_f5_rebuilds_background_before_staging_after_terminal_resize(self):
for size in ((24, 80), (50, 140)):
with self.subTest(size=size), ExitStack() as stack:
ui_state.node_list = [101]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={101: {"user": {"shortName": "NODE"}}})
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", 40, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", 100, create=True))
win = mock.Mock()
def read_key():
if (contact_ui.curses.LINES, contact_ui.curses.COLS) == (40, 100):
contact_ui.curses.LINES, contact_ui.curses.COLS = size
return contact_ui.curses.KEY_RESIZE
return 27
win.getch.side_effect = read_key
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
for name in ("get_color", "refresh_node_selection"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
events = []
def resize(*args):
events.append(("resize", ui_state.current_window))
def stage(*args):
events.append(("stage", ui_state.current_window))
stack.enter_context(mock.patch.object(contact_ui, "handle_resize", side_effect=resize))
stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background", side_effect=stage))
contact_ui.handle_f5_key(mock.Mock())
self.assertEqual(events, [("resize", 2), ("stage", 4), ("resize", 2)])
self.assertEqual(ui_state.current_window, 2)
def test_main_ui_accepts_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key, "\x1b"]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
stack.enter_context(mock.patch.object(contact_ui, name))
enter = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
contact_ui.main_ui(mock.Mock())
enter.assert_called_once_with("")
def test_search_closes_on_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=0))
stack.enter_context(mock.patch.object(contact_ui, "draw_centered_text_field"))
contact_ui.search(2)
entry.get_wch.assert_called_once_with()
self.assertEqual(entry.timeout.call_args_list, [mock.call(-1), mock.call(200)])
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
ui_state.node_list = [101, 102]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
102: {"user": {"shortName": "SMALL", "longName": "B"}},
})
win = mock.Mock()
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
events = mock.Mock()
events.attach_mock(win.noutrefresh, "dialog")
stdscr = mock.Mock()
with ExitStack() as stack:
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
for name, value in (("LINES", 40), ("COLS", 100)):
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("get_color", "refresh_node_selection", "handle_resize"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
events.attach_mock(restore, "restore")
contact_ui.handle_f5_key(stdscr)
self.assertEqual(events.mock_calls, [
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
mock.call.restore(stdscr, 2), mock.call.dialog(),
])
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
self.assertEqual(win.refresh.call_count, 1) # Only on close.
def test_background_restore_stages_panes_without_flushing(self):
ui_state.current_window = 4
ui_state.single_pane_mode = True
stdscr = mock.Mock()
with ExitStack() as stack:
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
contact_ui.stage_node_details_background(stdscr, 2)
refresh.assert_called_once_with(2, commit=False)
update.assert_not_called()
stdscr.noutrefresh.assert_called_once()
self.assertEqual(ui_state.current_window, 4)
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
stdscr = mock.Mock()
@@ -53,14 +179,35 @@ class ContactUiTests(unittest.TestCase):
with mock.patch.object(contact_ui, "handle_resize"):
with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"):
with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"):
with mock.patch.object(contact_ui, "settings_menu", side_effect=SystemExit(1)):
with mock.patch("contact.ui.dialog.dialog") as dialog:
contact_ui.handle_backtick(stdscr)
with mock.patch.object(contact_ui, "verify_remote_admin", side_effect=SystemExit(1)):
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch("contact.ui.dialog.dialog") as dialog:
contact_ui.handle_backtick(stdscr)
dialog.assert_called_once_with(
"Remote admin rejected",
"The selected node did not authorize this admin request.",
)
settings_menu.assert_not_called()
def test_handle_backtick_opens_local_settings_for_selected_local_node(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 2
ui_state.node_list = [123]
ui_state.selected_node = 0
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "get_list_input") as chooser:
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch.object(contact_ui, "get_channels"):
with mock.patch.object(contact_ui, "refresh_node_list"):
with mock.patch.object(contact_ui, "handle_resize"):
contact_ui.handle_backtick(stdscr)
chooser.assert_not_called()
settings_menu.assert_called_once_with(stdscr, contact_ui.interface_state.interface)
def test_show_remote_admin_wait_draws_status(self) -> None:
stdscr = mock.Mock()
@@ -146,6 +293,58 @@ class ContactUiTests(unittest.TestCase):
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
def test_single_pane_typing_after_switching_to_shorter_conversation(self) -> None:
for messages in ([("RX: ", "hello")], [], None):
with self.subTest(messages=messages):
reset_shared_state()
ui_state.single_pane_mode = True
ui_state.current_window = 0
ui_state.channel_list = [123]
ui_state.all_messages = {123: messages} if messages is not None else {}
ui_state.highlighted_message_range = (10, 12, 20)
ui_state.message_line_ranges = {123: [(10, 12, 20)]}
pad = mock.Mock()
pad.getmaxyx.return_value = (12, 40)
def resize_pad(height, width):
pad.getmaxyx.return_value = (height, width)
def change_attributes(row, column, width, color):
height, columns = pad.getmaxyx()
if not (0 <= row < height and 0 <= column < columns and column + width <= columns):
raise contact_ui.curses.error("chgat() returned ERR")
pad.resize.side_effect = resize_pad
pad.chgat.side_effect = change_attributes
win = mock.Mock()
win.getmaxyx.return_value = (10, 40)
packetlog = mock.Mock()
packetlog.getmaxyx.return_value = (1, 40)
with ExitStack() as stack:
for name, value in (("messages_pad", pad), ("messages_win", win), ("packetlog_win", packetlog)):
stack.enter_context(mock.patch.object(contact_ui, name, value, create=True))
for name in ("paint_frame", "refresh_pad", "draw_packetlog_win", "draw_window_arrows", "refresh_main_window"):
stack.enter_context(mock.patch.object(contact_ui, name))
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=10))
stack.enter_context(mock.patch.object(
contact_ui, "handle_resize", side_effect=lambda *args: contact_ui.draw_messages_window(True)
))
self.assertEqual(contact_ui.insert_input_text("", "a"), "a")
self.assertEqual(ui_state.current_window, 1)
self.assertTrue(ui_state.input_focused)
self.assertEqual(ui_state.highlighted_message_range, ())
pad.resize.assert_called_once_with(1, 40)
if messages:
self.assertEqual(ui_state.message_line_ranges[123], [(0, 1, 10)])
self.assertEqual(pad.chgat.call_args_list, [
mock.call(0, 1, 38, 10 | contact_ui.curses.A_REVERSE),
mock.call(0, 1, 38, 10),
])
else:
self.assertEqual(ui_state.message_line_ranges[123], [])
pad.chgat.assert_not_called()
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> [6] B1G1: ",
@@ -416,6 +615,10 @@ class ContactUiTests(unittest.TestCase):
self.assertEqual(contact_ui.get_window_title(1), "Primary")
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
self.addCleanup(mock.patch.stopall)
mock.patch.object(contact_ui.curses, "LINES", 24, create=True).start()
mock.patch.object(contact_ui.curses, "COLS", 80, create=True).start()
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
ui_state.single_pane_mode = True
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
@@ -432,9 +635,16 @@ class ContactUiTests(unittest.TestCase):
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)
with mock.patch.object(contact_ui.curses, "doupdate") as update:
contact_ui.refresh_pad(1)
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
contact_ui.messages_win.refresh.assert_not_called()
contact_ui.messages_pad.refresh.assert_not_called()
contact_ui.messages_win.noutrefresh.assert_called_once()
contact_ui.messages_pad.touchwin.assert_called_once()
contact_ui.messages_pad.noutrefresh.assert_called_once()
update.assert_called_once()
def test_search_ignores_no_input_from_curses(self) -> None:
ui_state.node_list = [101]
@@ -487,6 +697,9 @@ class ContactUiTests(unittest.TestCase):
contact_ui.handle_f5_key(stdscr)
self.assertEqual(dialog_win.getch.call_count, 2)
# Idle polling must never present an erased dialog before its text.
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
handle_resize.assert_called_once_with(stdscr, False)
def test_f5_node_details_tolerates_none_metrics(self) -> None:
+66 -15
View File
@@ -1,39 +1,64 @@
from argparse import Namespace
from contextlib import ExitStack
from types import SimpleNamespace
import unittest
from unittest import mock
from contact.ui import control_ui
from contact.utilities.singleton import interface_state
from contact.utilities.shared_state import app_state, interface_state
from tests.test_support import reset_singletons
from tests.test_support import reset_shared_state
class ControlUiTests(unittest.TestCase):
def test_channel_list_renders_names_without_changing_slot_keys(self):
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
from contact.ui.menus import generate_menu_from_protobuf
from contact.utilities.shared_state import app_state, menu_state
channels = [channel_pb2.Channel(settings=channel_pb2.ChannelSettings(name=name))
for name in ("Local", "Local", "", " ")]
node = SimpleNamespace(
localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda i: channels[i] if i < len(channels) else None,
)
menu_state.menu_path = ["Main Menu", "Channels"]
menu_state.current_menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"]
win, pad = mock.Mock(), mock.Mock()
win.getbegyx.return_value = (2, 2)
win.getmaxyx.return_value = (10, 80)
with ExitStack() as stack:
for name, value in (("LINES", 24), ("COLS", 100)):
stack.enter_context(mock.patch.object(control_ui.curses, name, value, create=True))
stack.enter_context(mock.patch.object(control_ui.curses, "newwin", return_value=win))
stack.enter_context(mock.patch.object(control_ui.curses, "newpad", return_value=pad))
stack.enter_context(mock.patch.object(control_ui.curses, "curs_set"))
stack.enter_context(mock.patch.object(control_ui, "get_menu_width", return_value=80))
for name in ("get_color", "draw_help_window", "draw_arrows"):
stack.enter_context(mock.patch.object(control_ui, name, return_value=0))
control_ui.display_menu()
labels = [call.args[2].strip() for call in pad.addstr.call_args_list]
self.assertEqual(labels, ["Local", "Local", "Channel 3", "Channel 4"])
self.assertEqual(list(menu_state.current_menu), [f"Channel {i}" for i in range(1, 5)])
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
def tearDown(self) -> None:
reset_singletons()
reset_shared_state()
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()
app_state.connection_args = Namespace(host="mesh.local", port=None, ble=None)
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)
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)
reconnect.assert_called_once_with(app_state.connection_args)
redraw.assert_called_once_with(stdscr)
self.assertIs(result, new_interface)
self.assertIs(interface_state.interface, new_interface)
@@ -53,6 +78,32 @@ class ControlUiTests(unittest.TestCase):
reconnect.assert_called_once_with(stdscr, interface)
self.assertIs(result, new_interface)
def test_verify_remote_admin_establishes_an_authorized_session(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={"adminSessionPassKey": b"key"})),
)
status_callback = mock.Mock()
with mock.patch.object(control_ui, "_request_remote_with_timeout") as request:
control_ui.verify_remote_admin(node, status_callback=status_callback)
status_callback.assert_called_once_with("Checking remote-admin permission…")
request.assert_called_once_with(node.ensureSessionKey, cancel_callback=None)
node.iface._getOrCreateByNum.assert_called_once_with(123)
def test_verify_remote_admin_rejects_missing_session_key(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={})),
)
with mock.patch.object(control_ui, "_request_remote_with_timeout"):
with self.assertRaises(PermissionError):
control_ui.verify_remote_admin(node)
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
stdscr = mock.Mock()
+17 -4
View File
@@ -1,3 +1,4 @@
from datetime import datetime
import os
import sqlite3
import tempfile
@@ -6,15 +7,15 @@ 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.shared_state import interface_state, ui_state
from contact.utilities.utils import decimal_to_hex
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class DbHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.saved_config = snapshot_config(
"db_file_path",
"message_prefix",
@@ -31,7 +32,7 @@ class DbHandlerTests(unittest.TestCase):
def tearDown(self) -> None:
self.tempdir.cleanup()
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_save_message_to_db_and_update_ack_roundtrip(self) -> None:
timestamp = db_handler.save_message_to_db("Primary", "123", "hello")
@@ -45,6 +46,18 @@ class DbHandlerTests(unittest.TestCase):
self.assertEqual(row, ("123", "hello", "Ack"))
def test_receive_timestamp_survives_history_reload(self) -> None:
timestamp = 1700000000
result = db_handler.save_message_to_db("Primary", "456", "queued", timestamp=timestamp)
self.assertEqual(result, timestamp)
with sqlite3.connect(config.db_file_path) as conn:
stored = conn.execute('SELECT timestamp FROM "123_Primary_messages"').fetchone()[0]
self.assertEqual(stored, timestamp)
db_handler.load_messages_from_db()
prefix, message = ui_state.all_messages["Primary"][-1]
self.assertEqual(message, "queued")
self.assertTrue(prefix.startswith(datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")))
def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None:
db_handler.ensure_table_exists(
'"123_Primary_messages"',
+5 -5
View File
@@ -2,23 +2,23 @@ import tempfile
import unittest
from unittest import mock
import contact.__main__ as entrypoint
import contact.runtime 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 contact.utilities.shared_state import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class DemoDataTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.saved_config = snapshot_config("db_file_path", "node_sort", "single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_build_demo_interface_exposes_expected_shape(self) -> None:
interface = build_demo_interface()
+1 -1
View File
@@ -2,7 +2,7 @@ import unittest
from unittest import mock
from contact.ui import dialog as dialog_module
from contact.utilities.singleton import menu_state, ui_state
from contact.utilities.shared_state import menu_state, ui_state
class _FakeWindow:
+146
View File
@@ -0,0 +1,146 @@
from contextlib import ExitStack
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from meshtastic.protobuf import admin_pb2, config_pb2, module_config_pb2
from contact.ui import control_ui
from contact.ui.menus import generate_menu_from_protobuf, update_ham_fields
from contact.utilities.save_to_radio import save_changes
from tests.test_support import reset_shared_state
class HamSettingsTests(unittest.TestCase):
def build_interface(self, licensed=False):
node = Mock(nodeNum=2, localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig())
node.localConfig.lora.tx_power = 20
node.localConfig.lora.override_frequency = 915.5
node.getChannelByChannelIndex.return_value = None
info = {'user': {'longName': 'KD2ABC', 'shortName': 'HAM', 'isLicensed': licensed}}
interface = SimpleNamespace(localNode=node, getMyNodeInfo=lambda: info, nodesByNum={2: info})
node.iface = interface
return interface, node
def test_fields_follow_license_selection_and_installed_descriptors(self):
interface, node = self.build_interface()
user = generate_menu_from_protobuf(interface)['Main Menu']['User Settings']
descriptors = admin_pb2.HamParameters.DESCRIPTOR.fields
self.assertTrue(all(field.name not in user for field in descriptors))
user['isLicensed'] = (None, True)
update_ham_fields(user, node)
for field in descriptors:
self.assertIs(user[field.name][0], field)
self.assertEqual(user['call_sign'][1], 'KD2ABC')
self.assertEqual(user['tx_power'][1], 20)
self.assertEqual(user['frequency'][1], 915.5)
self.assertNotIn('longName', user)
user['frequency'] = (user['frequency'][0], 916.0)
update_ham_fields(user, node)
self.assertEqual(user['frequency'][1], 916.0)
user['isLicensed'] = (None, False)
update_ham_fields(user, node)
self.assertTrue(all(field.name not in user for field in descriptors))
self.assertEqual(user['longName'][1], 'KD2ABC')
self.assertEqual(user['shortName'][1], 'HAM')
def test_remote_menu_uses_remote_license_and_radio_values(self):
interface, node = self.build_interface(True)
interface.localNode = Mock()
user = generate_menu_from_protobuf(interface, node=node)['Main Menu']['User Settings']
self.assertEqual(user['frequency'][1], 915.5)
self.assertEqual(user['call_sign'][1], 'KD2ABC')
def test_save_full_ham_message_on_selected_node(self):
for remote in (False, True):
interface, node = self.build_interface(True)
if remote:
interface.localNode = Mock()
user = generate_menu_from_protobuf(interface, node=node)['Main Menu']['User Settings']
changes = {name: value for name, (_, value) in user.items()}
changes['frequency'] = 916.0
state = SimpleNamespace(menu_path=['Main Menu', 'User Settings'])
self.assertTrue(save_changes(interface, changes, state, node=node))
message = node._sendAdmin.call_args.args[0]
self.assertEqual(message.WhichOneof('payload_variant'), 'set_ham_mode')
self.assertEqual(message.set_ham_mode.call_sign, 'KD2ABC')
self.assertEqual(message.set_ham_mode.short_name, 'HAM')
self.assertEqual(message.set_ham_mode.tx_power, 20)
self.assertEqual(message.set_ham_mode.frequency, 916.0)
self.assertEqual(node._sendAdmin.call_args.kwargs['onResponse'], node.onAckNak if remote else None)
self.assertEqual([c[0] for c in node.method_calls], [
*['getChannelByChannelIndex'] * 8, 'ensureSessionKey', 'beginSettingsTransaction',
'setOwner', '_sendAdmin', 'commitSettingsTransaction'])
def test_disabling_license_does_not_send_ham_command(self):
interface, node = self.build_interface(True)
user = generate_menu_from_protobuf(interface)['Main Menu']['User Settings']
user['isLicensed'] = (None, False)
update_ham_fields(user, node)
state = SimpleNamespace(menu_path=['Main Menu', 'User Settings'])
self.assertTrue(save_changes(interface, {k: v[1] for k, v in user.items()}, state, node=node))
node._sendAdmin.assert_not_called()
node.setOwner.assert_called_once_with('KD2ABC', 'HAM', False, False)
def test_edit_through_normal_menu_after_selecting_licensed(self):
reset_shared_state()
interface, node = self.build_interface()
win = Mock()
# Open User Settings, select licensed, edit the now-visible callsign, save.
win.getch.side_effect = [10, control_ui.curses.KEY_DOWN, control_ui.curses.KEY_DOWN,
10, control_ui.curses.KEY_DOWN, control_ui.curses.KEY_DOWN, 10, 9, 10, 27]
saved = {}
def capture(_interface, values, _state, **kwargs):
saved.update(values)
return False
try:
with ExitStack() as stack:
stack.enter_context(patch.object(control_ui.curses, 'update_lines_cols'))
stack.enter_context(patch.object(control_ui, 'display_menu', return_value=(win, Mock())))
stack.enter_context(patch.object(control_ui, 'move_highlight'))
stack.enter_context(patch.object(control_ui, 'get_list_input', return_value='True'))
text = stack.enter_context(patch.object(control_ui, 'get_text_input', return_value='KD2XYZ'))
stack.enter_context(patch.object(control_ui, 'save_changes', side_effect=capture))
control_ui.settings_menu(Mock(), interface)
self.assertEqual(text.call_args.args[1], 'call_sign')
self.assertEqual(saved['call_sign'], 'KD2XYZ')
self.assertTrue(saved['isLicensed'])
self.assertEqual(saved['tx_power'], 20)
finally:
reset_shared_state()
def test_new_library_fields_are_discovered_edited_and_saved(self):
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
schema = descriptor_pb2.FileDescriptorProto(name='future_ham.proto', syntax='proto3')
params = schema.message_type.add(name='HamParameters')
for field in admin_pb2.HamParameters.DESCRIPTOR.fields:
params.field.add(name=field.name, number=field.number, type=field.type)
if 'long_name' not in admin_pb2.HamParameters.DESCRIPTOR.fields_by_name:
params.field.add(name='long_name', number=5, type=9)
params.field.add(name='future_option', number=100, type=8)
message = schema.message_type.add(name='AdminMessage')
message.field.add(name='set_ham_mode', number=18, type=11, type_name='.HamParameters')
pool = descriptor_pool.DescriptorPool()
pool.Add(schema)
future_params = message_factory.GetMessageClass(pool.FindMessageTypeByName('HamParameters'))
future_message = message_factory.GetMessageClass(pool.FindMessageTypeByName('AdminMessage'))
interface, node = self.build_interface(True)
with patch.object(admin_pb2, 'HamParameters', future_params), patch.object(admin_pb2, 'AdminMessage', future_message):
user = generate_menu_from_protobuf(interface)['Main Menu']['User Settings']
self.assertIn('long_name', user)
self.assertEqual(user['future_option'][1], False)
changes = {name: setting[1] for name, setting in user.items()}
changes.update(long_name='Alice', future_option=True)
state = SimpleNamespace(menu_path=['Main Menu', 'User Settings'])
self.assertTrue(save_changes(interface, changes, state, node=node))
sent = node._sendAdmin.call_args.args[0]
parsed = future_message.FromString(sent.SerializeToString())
self.assertEqual(parsed.set_ham_mode.long_name, 'Alice')
self.assertTrue(parsed.set_ham_mode.future_option)
def test_remote_user_settings_fetch_lora_before_showing_ham_defaults(self):
_, node = self.build_interface(True)
with patch.object(control_ui, '_request_remote_with_timeout') as request:
self.assertTrue(control_ui._request_remote_section(node, ['Main Menu'], 'User Settings'))
request.assert_called_once_with(node.requestConfig,
node.localConfig.DESCRIPTOR.fields_by_name['lora'],
cancel_callback=None)
+41 -4
View File
@@ -1,21 +1,58 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
from contact.utilities.interfaces import reconnect_interface
from meshtastic.protobuf import localonly_pb2, mesh_pb2
from contact.ui.menus import generate_menu_from_protobuf
from contact.utilities.interfaces import BLEInterface, SerialInterface, TCPInterface, reconnect_interface
class InterfacesTests(unittest.TestCase):
def test_received_status_message_appears_in_settings_after_reconnect(self):
for transport in (SerialInterface, TCPInterface, BLEInterface):
with self.subTest(transport=transport.__name__):
# A fresh interface has no cached value from the previous save.
interface = transport.__new__(transport)
interface.configId = 123
interface.localNode = SimpleNamespace(
localConfig=localonly_pb2.LocalConfig(),
moduleConfig=localonly_pb2.LocalModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface.getMyNodeInfo = lambda: {"position": {}}
for status in ("Available", ""):
packet = mesh_pb2.FromRadio()
packet.moduleConfig.statusmessage.node_status = status
interface._handleFromRadio(packet.SerializeToString())
# Other module packets must still pass through normally,
# without clearing the status message already received.
packet = mesh_pb2.FromRadio()
packet.moduleConfig.mqtt.enabled = True
interface._handleFromRadio(packet.SerializeToString())
self.assertTrue(interface.localNode.moduleConfig.mqtt.enabled)
menu = generate_menu_from_protobuf(interface)
value = menu["Main Menu"]["Module Settings"]["statusmessage"]["node_status"][1]
self.assertEqual(value, status)
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
args = Namespace()
with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize:
ready_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=object(), nodeNum=123))
with mock.patch(
"contact.utilities.interfaces.initialize_interface", side_effect=[None, None, ready_interface]
) as initialize:
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
with mock.patch("contact.utilities.interfaces.logging.info") as info:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
self.assertEqual(result, "iface")
self.assertIs(result, ready_interface)
self.assertEqual(initialize.call_count, 3)
self.assertEqual(sleep.call_count, 2)
self.assertTrue(any("Reconnected to Meshtastic node" in call.args[0] for call in info.call_args_list))
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
args = Namespace()
+20 -32
View File
@@ -3,21 +3,21 @@ from types import SimpleNamespace
import unittest
from unittest import mock
import contact.__main__ as entrypoint
import contact.runtime as entrypoint
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.shared_state import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class MainRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
args = Namespace(demo_screenshot=True)
@@ -162,28 +162,15 @@ class MainRuntimeTests(unittest.TestCase):
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()
entrypoint.start(Namespace())
wrapper.assert_called_once_with(entrypoint.main)
wrapper.assert_called_once_with(entrypoint.main, Namespace())
interface.close.assert_called_once_with()
def test_start_does_not_crash_when_wrapper_returns_without_interface(self) -> None:
@@ -191,9 +178,9 @@ class MainRuntimeTests(unittest.TestCase):
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
entrypoint.start()
entrypoint.start(Namespace())
wrapper.assert_called_once_with(entrypoint.main)
wrapper.assert_called_once_with(entrypoint.main, Namespace())
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
stdscr = mock.Mock()
@@ -202,11 +189,9 @@ class MainRuntimeTests(unittest.TestCase):
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)
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
entrypoint.main(stdscr, args)
initialize_globals.assert_not_called()
@@ -218,7 +203,7 @@ class MainRuntimeTests(unittest.TestCase):
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()
entrypoint.start(Namespace())
self.assertEqual(raised.exception.code, 0)
interface.close.assert_called_once_with()
@@ -231,7 +216,7 @@ class MainRuntimeTests(unittest.TestCase):
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()
entrypoint.start(Namespace())
self.assertEqual(raised.exception.code, 0)
exit_mock.assert_called_once_with(0)
@@ -240,14 +225,17 @@ class MainRuntimeTests(unittest.TestCase):
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
with mock.patch.object(entrypoint.traceback, "print_exception") as print_exception:
with mock.patch("builtins.print") as print_mock:
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
entrypoint.start(Namespace())
self.assertEqual(raised.exception.code, 1)
endwin.assert_called_once_with()
print_exc.assert_called_once_with()
exception_type, exception, exception_traceback = print_exception.call_args_list[-1].args
self.assertIs(exception_type, RuntimeError)
self.assertEqual(str(exception), "boom")
self.assertIsNotNone(exception_traceback)
print_mock.assert_any_call("Fatal error:", mock.ANY)
exit_mock.assert_called_once_with(1)
+17
View File
@@ -44,3 +44,20 @@ class MenusTests(unittest.TestCase):
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
def test_user_settings_include_unmessageable_toggle(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {
"user": {"longName": "Test User", "shortName": "TU", "isUnmessagable": True},
"position": {},
},
)
user_settings = generate_menu_from_protobuf(interface)["Main Menu"]["User Settings"]
self.assertEqual(user_settings["isUnmessagable"], (None, True))
+95
View File
@@ -0,0 +1,95 @@
import unittest
from contextlib import ExitStack
from unittest import mock
from contact.ui import contact_ui
from contact.utilities.shared_state import ui_state
from tests.test_support import reset_shared_state
class MessageInputTests(unittest.TestCase):
def setUp(self):
reset_shared_state()
ui_state.current_window = 1
for name in ("refresh_message_highlight", "refresh_main_window", "get_color"):
patcher = mock.patch.object(contact_ui, name, return_value=1)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(reset_shared_state)
def test_keyboard_loop_sends_edited_draft_after_resize(self):
win = mock.Mock()
win.get_wch.side_effect = [
"a", "b", "c", contact_ui.curses.KEY_LEFT,
contact_ui.curses.KEY_DC, "d", contact_ui.curses.KEY_BACKSPACE,
"e", contact_ui.curses.KEY_RESIZE, "\n", "\x1b",
]
with ExitStack() as stack:
stack.enter_context(mock.patch.object(contact_ui, "entry_win", win))
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
stack.enter_context(mock.patch.object(contact_ui, name))
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
stack.enter_context(mock.patch.object(contact_ui, "drain_resize_events", return_value=None))
send = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
contact_ui.main_ui(mock.Mock())
send.assert_called_once_with("abe")
self.assertEqual(contact_ui.input_text, "")
self.assertEqual(ui_state.input_cursor, 0)
def test_typing_focuses_input_and_edits_at_cursor(self):
draft = contact_ui.insert_input_text("", "helo")
self.assertTrue(ui_state.input_focused)
with mock.patch.object(contact_ui, "input_text", draft):
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
draft = contact_ui.insert_input_text(draft, "l")
self.assertEqual(draft, "hello")
self.assertEqual(ui_state.input_cursor, 4)
draft = contact_ui.handle_backspace(mock.Mock(), draft)
self.assertEqual(draft, "helo")
self.assertEqual(ui_state.input_cursor, 3)
def test_up_and_down_return_to_messages_without_scrolling(self):
for handler in (contact_ui.handle_up, contact_ui.handle_down):
ui_state.input_focused = True
ui_state.input_cursor = 2
with mock.patch.object(contact_ui, "scroll_messages") as scroll:
handler()
self.assertFalse(ui_state.input_focused)
self.assertEqual(ui_state.current_window, 1)
self.assertEqual(ui_state.input_cursor, 2)
scroll.assert_not_called()
def test_cursor_stops_at_draft_boundaries(self):
ui_state.input_focused = True
with mock.patch.object(contact_ui, "input_text", "abc"):
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
self.assertEqual(ui_state.input_cursor, 0)
self.assertEqual(contact_ui.handle_backspace(mock.Mock(), "abc"), "abc")
contact_ui.handle_end()
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
self.assertEqual(ui_state.input_cursor, 3)
contact_ui.handle_home()
self.assertEqual(ui_state.input_cursor, 0)
def test_redraw_clears_line_and_shows_text_after_cursor(self):
win = mock.Mock()
win.getmaxyx.return_value = (3, 30)
ui_state.input_focused = True
ui_state.input_cursor = 2
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
contact_ui.draw_message_input("hello")
win.addstr.assert_any_call(1, 1, " " * 28, 1)
win.addstr.assert_any_call(1, 1, "Message: hello", 1)
win.move.assert_called_once_with(1, 12)
contact_ui.get_color.assert_any_call("window_frame_selected")
def test_long_draft_scrolls_to_cursor_with_reply_context(self):
win = mock.Mock()
win.getmaxyx.return_value = (3, 24)
ui_state.reply_context = "Reply to someone: "
ui_state.input_cursor = 20
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
contact_ui.draw_message_input("x" * 20)
self.assertLess(win.move.call_args.args[1], 23)
rendered = [call.args[2] for call in win.addstr.call_args_list if call.args[:2] == (1, 1)][-1]
self.assertEqual(rendered, "Message: " + "x" * 12)
+1 -1
View File
@@ -3,7 +3,7 @@ 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
from contact.utilities.shared_state import ui_state
class NavUtilsTests(unittest.TestCase):
+55
View File
@@ -0,0 +1,55 @@
from contextlib import ExitStack
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import contact.ui.default_config
from contact.utilities import input_handlers
from tests.test_support import reset_shared_state
class RestoredSettingsTests(unittest.TestCase):
def test_blank_input_is_unset_only_when_allowed(self):
for keys, allowed, expected in [
(["\n"], True, ""), ([" ", " ", "\n"], True, ""),
(["\x1b"], True, None), (["\n", "\x1b"], False, None),
]:
with self.subTest(keys=keys, allowed=allowed), ExitStack() as stack:
reset_shared_state()
stack.callback(reset_shared_state)
win = mock.Mock()
win.get_wch.side_effect = keys
stack.enter_context(mock.patch.object(input_handlers.curses, "newwin", return_value=win))
for name, value in (("LINES", 24), ("COLS", 80)):
stack.enter_context(mock.patch.object(input_handlers.curses, name, value, create=True))
stack.enter_context(mock.patch.object(input_handlers.curses, "curs_set"))
stack.enter_context(mock.patch.object(input_handlers, "get_color", return_value=0))
invalid = stack.enter_context(mock.patch.object(input_handlers, "invalid_input"))
result = input_handlers.get_text_input("Channel name", "name", str, allow_empty=allowed)
self.assertEqual(result, expected)
self.assertEqual(invalid.call_count, 0 if allowed else 1)
def test_missing_log_path_shows_configuration_guidance(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve()
config_path = root / "config.json"
log_path = root / "missing" / "client.log"
for module in ("contact", "contact.settings"):
with self.subTest(module=module):
config_path.write_text(json.dumps({"log_file_path": str(log_path)}))
result = subprocess.run(
[sys.executable, "-m", module],
env={**os.environ, "CONTACT_CONFIG_ROOT": str(root)},
cwd=Path(__file__).resolve().parents[1],
capture_output=True, text=True, timeout=15,
)
self.assertEqual(result.returncode, 1)
self.assertIn(str(log_path), result.stderr)
self.assertIn(f"Please edit config.json at: {config_path}", result.stderr)
self.assertIn('"log_file_path"', result.stderr)
self.assertNotIn("Traceback", result.stdout + result.stderr)
+78 -7
View File
@@ -1,22 +1,68 @@
from datetime import datetime
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 contact.utilities.shared_state import interface_state, menu_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class RxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
self.saved_config = snapshot_config("notification_sound", "message_prefix")
config.notification_sound = "False"
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_message_sound_depends_on_selected_view(self):
config.notification_sound = "alert.mp3"
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary", "Other"]
for window, packet_log, log_viewer, channel, expected in (
(1, False, False, 0, False),
(1, False, False, 1, False),
(0, False, False, 0, True),
(2, False, False, 0, True),
(4, False, False, 0, True),
(1, True, False, 0, True),
(1, False, True, 0, True),
):
with self.subTest(window=window, packet_log=packet_log, log_viewer=log_viewer, channel=channel):
ui_state.current_window = window
ui_state.display_log = packet_log
ui_state.log_viewer_open = log_viewer
ui_state.all_messages = {"Primary": [], "Other": []}
packet = {
"from": 222, "to": 999, "channel": channel,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
mock.patch.object(rx_handler, "request_ui_redraw"), \
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
mock.patch.object(rx_handler, "save_message_to_db"), \
mock.patch.object(rx_handler, "schedule_notification_sound") as sound:
rx_handler.on_receive(packet, interface=None)
self.assertEqual(sound.call_count, int(expected))
def test_pending_sound_is_suppressed_if_chat_selected_before_playback(self):
with mock.patch.object(rx_handler, "_sound_timer", None), \
mock.patch.object(rx_handler, "_last_sound_request", 0.0), \
mock.patch.object(rx_handler.threading, "Timer") as timer, \
mock.patch.object(rx_handler, "play_sound") as play:
rx_handler.schedule_notification_sound()
callback = timer.call_args.args[1]
callback_args = timer.call_args.kwargs["args"]
ui_state.current_window = 1
callback(*callback_args)
play.assert_not_called()
ui_state.current_window = 0
callback(*callback_args)
play.assert_called_once_with()
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
interface_state.myNodeNum = 111
@@ -52,7 +98,7 @@ class RxHandlerTests(unittest.TestCase):
],
)
add_notification.assert_not_called()
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None)
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None, timestamp=mock.ANY)
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])
@@ -111,7 +157,7 @@ class RxHandlerTests(unittest.TestCase):
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", packet_id=None, reply_id=None)
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None, timestamp=mock.ANY)
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
interface_state.myNodeNum = 111
@@ -137,7 +183,32 @@ class RxHandlerTests(unittest.TestCase):
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: SAT2: hello> hi")
self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901)
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900)
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900, timestamp=mock.ANY)
def test_node_receive_time_is_used_for_display_and_storage(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
for rx_time in (1700000000, 1700007200, None, 0):
with self.subTest(rx_time=rx_time):
ui_state.all_messages = {"Primary": []}
packet = {
"from": 222, "to": 999, "rxTime": rx_time,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"queued"},
}
expected = rx_time or 1800000000
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
mock.patch.object(rx_handler, "request_ui_redraw"), \
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
mock.patch.object(rx_handler, "save_message_to_db") as save, \
mock.patch.object(rx_handler.time, "time", return_value=1800000000):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(save.call_args.kwargs["timestamp"], expected)
message_time = datetime.fromtimestamp(expected)
self.assertEqual(ui_state.all_messages["Primary"][0][0],
message_time.strftime("-- %Y-%m-%d %H:00 --"))
self.assertTrue(ui_state.all_messages["Primary"][-1][0].startswith(
message_time.strftime("[%H:%M:%S] ")))
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
ui_state.packet_buffer = list(range(25))
+81
View File
@@ -1,11 +1,57 @@
from types import SimpleNamespace
import unittest
from unittest import mock
from meshtastic.protobuf import channel_pb2, localonly_pb2, module_config_pb2
from contact.utilities.save_to_radio import save_changes
class SaveToRadioTests(unittest.TestCase):
def test_status_message_saves_for_local_and_remote_nodes(self):
for remote in (False, True):
with self.subTest(remote=remote):
interface, node = self.build_interface()
node.localConfig = localonly_pb2.LocalConfig()
node.moduleConfig = module_config_pb2.ModuleConfig()
node.moduleConfig.statusmessage.node_status = "Old status"
interface.localNode = mock.Mock() if remote else node
node.writeConfig.side_effect = SystemExit(1)
menu_state = SimpleNamespace(
menu_path=["Main Menu", "Module Settings", "Statusmessage"]
)
reconnect = save_changes(
interface, {"node_status": "Available"}, menu_state, node=node
)
self.assertTrue(reconnect)
node.writeConfig.assert_not_called()
node._sendAdmin.assert_called_once()
message = node._sendAdmin.call_args.args[0]
self.assertEqual(message.WhichOneof("payload_variant"), "set_module_config")
self.assertEqual(message.set_module_config.WhichOneof("payload_variant"), "statusmessage")
self.assertEqual(message.set_module_config.statusmessage.node_status, "Available")
self.assertEqual(
node._sendAdmin.call_args.kwargs["onResponse"],
node.onAckNak if remote else None,
)
def test_channel_enable_disable_and_unrelated_edits_preserve_role(self):
for index in (0, 1):
interface, node = self.build_interface()
node.channels = [channel_pb2.Channel(index=i) for i in range(2)]
channel = node.channels[index]
path = SimpleNamespace(menu_path=["Main Menu", "Channels", f"Channel {index + 1}"])
save_changes(interface, {"name": "Test"}, path)
self.assertEqual(channel.role, channel_pb2.Channel.DISABLED)
save_changes(interface, {"enabled": True}, path)
expected = channel_pb2.Channel.PRIMARY if index == 0 else channel_pb2.Channel.SECONDARY
self.assertEqual(channel.role, expected)
save_changes(interface, {"enabled": False}, path)
self.assertEqual(channel.role, channel_pb2.Channel.DISABLED)
self.assertEqual(channel.settings.name, "Test")
self.assertEqual(node.writeChannel.call_args_list, [mock.call(index)] * 3)
def build_interface(self):
node = mock.Mock()
node.localConfig = SimpleNamespace(
@@ -55,6 +101,20 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
def test_save_changes_replaces_remote_admin_keys_in_one_write(self) -> None:
interface, node = self.build_interface()
key_one = b"1" * 32
key_two = b"2" * 32
key_three = b"3" * 32
node.localConfig.security.admin_key = [key_one, key_two, key_three]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"admin_key": [key_one, key_two]}, menu_state, node=node)
self.assertTrue(reconnect_required)
self.assertEqual(node.localConfig.security.admin_key, [key_one, key_two])
node.writeConfig.assert_called_once_with("security")
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
@@ -95,6 +155,18 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
self.assertTrue(node.moduleConfig.mqtt.enabled)
def test_save_changes_writes_channel_is_muted_to_module_settings(self) -> None:
interface, node = self.build_interface()
channel = SimpleNamespace(settings=SimpleNamespace(module_settings=SimpleNamespace(is_muted=False)), role=None)
node.channels = [channel]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Channels", "Channel 1", "module_settings"])
reconnect_required = save_changes(interface, {"is_muted": True}, menu_state)
self.assertFalse(reconnect_required)
self.assertTrue(channel.settings.module_settings.is_muted)
node.writeChannel.assert_called_once_with(0)
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
@@ -112,3 +184,12 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
node.setOwner.assert_called_once()
def test_save_changes_sends_user_unmessageable_setting(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
reconnect_required = save_changes(interface, {"isUnmessagable": True}, menu_state)
self.assertTrue(reconnect_required)
self.assertEqual(node.setOwner.call_args.args[3], True)
+16 -22
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
import unittest
from unittest import mock
import contact.settings as settings
import contact.settings_runtime as settings
class SettingsRuntimeTests(unittest.TestCase):
@@ -17,11 +17,9 @@ class SettingsRuntimeTests(unittest.TestCase):
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)
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu") as settings_menu:
settings.main(stdscr, args)
settings_menu.assert_called_once_with(stdscr, interface)
interface.close.assert_called_once_with()
@@ -38,16 +36,14 @@ class SettingsRuntimeTests(unittest.TestCase):
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)
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:
settings.main(stdscr, args)
set_region.assert_called_once_with(old_interface)
reconnect_interface.assert_called_once_with(args)
@@ -65,11 +61,9 @@ class SettingsRuntimeTests(unittest.TestCase):
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)
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")):
with self.assertRaises(RuntimeError):
settings.main(stdscr, args)
interface.close.assert_called_once_with()
+2 -2
View File
@@ -2,10 +2,10 @@ 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
from contact.utilities.shared_state import app_state, interface_state, menu_state, ui_state
def reset_singletons() -> None:
def reset_shared_state() -> None:
_reset_instance(ui_state, ChatUIState())
_reset_instance(interface_state, InterfaceState())
_reset_instance(menu_state, MenuState())
+6
View File
@@ -25,3 +25,9 @@ class TelemetryBeautifierTests(unittest.TestCase):
rendered = get_chunks("time:0\n")
self.assertIn("🕔 01.01.1970 00:00", rendered)
def test_get_chunks_normalizes_keys_and_preserves_unknown_strings(self) -> None:
rendered = get_chunks(' uptime_seconds: 7200\nunknown: "v1.2:ok"\n')
self.assertIn("🆙 2.0h", rendered)
self.assertIn('unknown:"v1.2:ok"', rendered)
+4 -4
View File
@@ -6,21 +6,21 @@ 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 contact.utilities.shared_state import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class TxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
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()
reset_shared_state()
def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
interface = mock.Mock()
+45
View File
@@ -1,4 +1,9 @@
import unittest
import json
from contextlib import ExitStack
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config # Initialize config before the color helpers.
@@ -6,6 +11,35 @@ from contact.ui import user_config
class UserConfigTests(unittest.TestCase):
def test_editor_reads_and_saves_active_config_outside_installation(self):
for existing in (False, True):
with self.subTest(existing=existing), TemporaryDirectory() as directory, ExitStack() as stack:
root = Path(directory)
config_path = root / "config.json"
package = root / "site-packages" / "contact"
(package / "ui").mkdir(parents=True)
if existing:
config_path.write_text(json.dumps({"message_prefix": "Custom"}), encoding="utf-8")
stack.enter_context(mock.patch.object(user_config, "__file__", str(package / "ui" / "user_config.py")))
stack.enter_context(mock.patch.object(user_config.config, "json_file_path", str(config_path)))
reload_config = stack.enter_context(mock.patch.object(user_config.config, "reload_config"))
stack.enter_context(mock.patch.object(user_config, "reload_translations"))
stack.enter_context(mock.patch.object(user_config, "update_app_settings_help"))
window = mock.Mock()
state = SimpleNamespace()
stack.enter_context(mock.patch.object(user_config, "display_menu", return_value=(window, mock.Mock(), [])))
def save_from_editor():
self.assertEqual(state.current_menu["message_prefix"], "Custom" if existing else ">>")
state.current_menu["message_prefix"] = "Edited"
return 10 # Save button.
window.getch.side_effect = save_from_editor
self.assertFalse(user_config.json_editor(mock.Mock(), state))
self.assertEqual(json.loads(config_path.read_text())["message_prefix"], "Edited")
self.assertFalse((package / "config.json").exists())
reload_config.assert_called_once_with()
def test_ping_bot_enabled_uses_boolean_picker(self) -> None:
with mock.patch.object(user_config, "get_list_input", return_value="True") as picker:
result = user_config.edit_value("enabled", "Enabled", "False")
@@ -13,6 +47,17 @@ class UserConfigTests(unittest.TestCase):
self.assertEqual(result, "True")
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
def test_load_log_tail_returns_only_requested_recent_lines(self) -> None:
with NamedTemporaryFile("w") as log_file:
log_file.write("".join(f"line {index}\n" for index in range(600)))
log_file.flush()
lines = user_config.load_log_tail(log_file.name, max_lines=500)
self.assertEqual(len(lines), 500)
self.assertEqual(lines[0], "line 100")
self.assertEqual(lines[-1], "line 599")
if __name__ == "__main__":
unittest.main()
+100 -13
View File
@@ -1,22 +1,71 @@
import unittest
from unittest import mock
from meshtastic.protobuf import config_pb2, mesh_pb2, telemetry_pb2
import contact.ui.default_config as config
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
from contact.utilities.shared_state import interface_state, ui_state
from contact.utilities.utils import (
_get_channel_name,
_get_modem_preset_name,
add_new_message,
get_channels,
get_node_list,
parse_protobuf,
)
from tests.test_support import reset_singletons, restore_config, snapshot_config
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class UtilsTests(unittest.TestCase):
def test_parse_protobuf_beautifies_local_stats(self) -> None:
telemetry = telemetry_pb2.Telemetry(time=1788677531)
telemetry.local_stats.uptime_seconds = 7200
telemetry.local_stats.channel_utilization = 2.90333319
telemetry.local_stats.num_packets_tx = 12
telemetry.local_stats.num_packets_rx_bad = 3
telemetry.local_stats.num_online_nodes = 4
telemetry.local_stats.heap_free_bytes = 1024
rendered = parse_protobuf({"decoded": {
"portnum": "TELEMETRY_APP", "payload": telemetry.SerializeToString(),
}})
self.assertIn("🆙 2.0h", rendered)
self.assertIn("ChUtil:2.9%", rendered)
self.assertIn("📤 Tx:12", rendered)
self.assertIn("⚠️ RxBad:3", rendered)
self.assertIn("📡 Online:4", rendered)
self.assertIn("💾 Free:1024B", rendered)
self.assertNotIn("local_stats", rendered)
def test_parse_protobuf_beautifies_position_details(self) -> None:
position = mesh_pb2.Position(
latitude_i=454230020, longitude_i=-1228472320,
location_source=mesh_pb2.Position.LOC_INTERNAL,
ground_speed=2, ground_track=9000, PDOP=211, sats_in_view=8,
)
rendered = parse_protobuf({"decoded": {
"portnum": "POSITION_APP", "payload": position.SerializeToString(),
}})
self.assertIn("🌍 45.423002", rendered)
self.assertIn("-122.847232", rendered)
self.assertIn("📍 LOC_INTERNAL", rendered)
self.assertIn("💨 2m/s", rendered)
self.assertIn("🧭 90.0°", rendered)
self.assertIn("🎯 PDOP:2.11", rendered)
self.assertIn("🛰️ 8", rendered)
def setUp(self) -> None:
reset_singletons()
reset_shared_state()
_get_modem_preset_name.cache_clear()
self.saved_config = snapshot_config("node_sort")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
reset_shared_state()
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
config.node_sort = "lastHeard"
@@ -32,19 +81,18 @@ class UtilsTests(unittest.TestCase):
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")
from datetime import datetime
timestamp = datetime(2025, 2, 4, 17, 16, 40).timestamp()
with mock.patch("contact.utilities.utils.time.time", return_value=timestamp):
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"),
("[17:16:40] >> Test: ", "First"),
("[17:16:40] >> Test: ", "Second"),
],
)
@@ -60,6 +108,45 @@ class UtilsTests(unittest.TestCase):
self.assertIn("MediumFast", ui_state.all_messages)
self.assertIn("Another Channel", ui_state.all_messages)
def test_get_channel_name_handles_newer_known_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
warning.assert_called_once()
def test_get_channel_name_handles_unknown_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 99
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "UnknownPreset99")
warning.assert_called_once()
def test_get_channel_name_prefers_public_protobuf_enum_over_compatibility_map(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch.object(
config_pb2.Config.LoRaConfig.ModemPreset,
"Name",
return_value="TINY_FAST",
) as enum_name:
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
enum_name.assert_called_once_with(14)
warning.assert_not_called()
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
interface = build_demo_interface()
interface.localNode.channels[0].settings.name = "Renamed Channel"