Compare commits

..
34 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
51 changed files with 1471 additions and 625 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 -263
View File
@@ -1,270 +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",
# Meshtastic or another dependency may have installed a stderr handler
# before Contact starts curses. Replace it so background warnings cannot
# overwrite the terminal UI.
force=True,
)
app_state.lock = threading.Lock()
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) -> 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_exception(type(fatal_error), fatal_error, fatal_error.__traceback__)
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__":
+7 -1
View File
@@ -98,7 +98,12 @@ 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]
@@ -160,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."
+1 -1
View File
@@ -5,7 +5,7 @@ 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
+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:
+1 -1
View File
@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state
from contact.utilities.shared_state import ui_state, interface_state, app_state
from contact.utilities.utils import add_new_message
+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 -99
View File
@@ -1,107 +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, 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()
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",
force=True,
)
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)
+81 -71
View File
@@ -12,10 +12,9 @@ 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
@@ -34,11 +33,10 @@ 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
@@ -233,18 +231,6 @@ 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, refresh: bool = True) -> None:
if win is not entry_win and ui_state.input_focused:
@@ -603,7 +589,7 @@ 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))
@@ -689,11 +675,17 @@ def insert_input_text(draft: str, text: str) -> str:
def draw_message_input(draft: str) -> None:
"""Redraw the whole input row and scroll it to keep the draft cursor visible."""
width = entry_win.getmaxyx()[1]
# Получаем сразу высоту и ширину для правильного позиционирования счетчика
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
@@ -703,10 +695,26 @@ def draw_message_input(draft: str) -> None:
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:
@@ -971,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')}")
@@ -993,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}")
@@ -1007,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"]
@@ -1020,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}%")
@@ -1078,6 +1083,15 @@ def handle_f5_key(stdscr: curses.window) -> None:
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
@@ -1640,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}")
@@ -1666,10 +1683,10 @@ 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), refresh=False)
@@ -1864,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
@@ -1980,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
@@ -2062,34 +2065,41 @@ def refresh_pad(window: int, commit: bool = True) -> 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.noutrefresh()
try:
box.noutrefresh()
# 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()
# 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:
+33 -9
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
@@ -246,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
@@ -360,6 +358,14 @@ def _request_remote_section(
_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:
return False
@@ -508,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:
@@ -762,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"]:
@@ -785,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":
@@ -826,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()
@@ -852,6 +870,12 @@ 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:
+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:
+38 -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.
@@ -78,6 +110,7 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
"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"
@@ -93,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):
+7
View File
@@ -1,6 +1,7 @@
import curses
import textwrap
from contact.ui.colors import get_color
from contact.version import __version__
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
@@ -20,6 +21,12 @@ def draw_splash(stdscr: object, status: str = "connecting...") -> None:
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))
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 [""])
+1
View File
@@ -68,3 +68,4 @@ class InterfaceState:
@dataclass
class AppState:
lock: Any = None
connection_args: Any = None
+5 -14
View File
@@ -1,5 +1,4 @@
import os
import json
import curses
from typing import Any, List, Dict, Optional
@@ -9,7 +8,7 @@ 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, ui_state
from contact.utilities.shared_state import menu_state, ui_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
@@ -429,22 +428,15 @@ 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
@@ -626,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",
+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:
+28 -4
View File
@@ -3,6 +3,30 @@ import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
from meshtastic.protobuf import mesh_pb2
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:
@@ -31,7 +55,7 @@ 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:
@@ -40,7 +64,7 @@ def initialize_interface(args, status_callback=None):
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:
@@ -48,7 +72,7 @@ def initialize_interface(args, status_callback=None):
# The library prints its fallback notice directly to stdout,
# which would overwrite the curses splash screen.
with contextlib.redirect_stdout(io.StringIO()):
client = meshtastic.serial_interface.SerialInterface(args.port)
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:
@@ -63,7 +87,7 @@ def initialize_interface(args, status_callback=None):
try:
if status_callback:
status_callback("No serial device found.\nConnecting via TCP to localhost…")
client = meshtastic.tcp_interface.TCPInterface("localhost")
client = TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
+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
+34 -8
View File
@@ -1,4 +1,4 @@
from meshtastic.protobuf import channel_pb2
from meshtastic.protobuf import admin_pb2, channel_pb2
from google.protobuf.message import Message
import logging
import base64
@@ -139,6 +139,21 @@ def save_changes(interface, modified_settings, menu_state, node=None):
if is_unmessagable is not None:
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
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(
@@ -160,7 +175,15 @@ def save_changes(interface, modified_settings, menu_state, node=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 in {"position_precision", "is_muted"}:
# These fields belong to ChannelSettings.module_settings,
@@ -169,11 +192,6 @@ def save_changes(interface, modified_settings, menu_state, node=None):
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}")
@@ -230,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")
+12 -7
View File
@@ -3,13 +3,13 @@ import logging
import re
import time
from functools import lru_cache
from typing import Optional, Union
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 = {
@@ -224,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] = []
@@ -233,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]
@@ -255,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)
@@ -296,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.11"
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__)
+4 -4
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"):
+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")
+137 -4
View File
@@ -6,12 +6,91 @@ 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
@@ -59,12 +138,12 @@ class ContactUiTests(unittest.TestCase):
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()
@@ -214,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: ",
@@ -485,6 +616,8 @@ class ContactUiTests(unittest.TestCase):
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
+10 -16
View File
@@ -5,16 +5,16 @@ 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.singleton import menu_state
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", "", " ")]
@@ -42,29 +42,23 @@ class ControlUiTests(unittest.TestCase):
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)
+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)
+32 -1
View File
@@ -3,10 +3,41 @@ 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()
+15 -30
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)
@@ -244,7 +229,7 @@ class MainRuntimeTests(unittest.TestCase):
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()
+4 -4
View File
@@ -3,19 +3,19 @@ from contextlib import ExitStack
from unittest import mock
from contact.ui import contact_ui
from contact.utilities.singleton import ui_state
from tests.test_support import reset_singletons
from contact.utilities.shared_state import ui_state
from tests.test_support import reset_shared_state
class MessageInputTests(unittest.TestCase):
def setUp(self):
reset_singletons()
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_singletons)
self.addCleanup(reset_shared_state)
def test_keyboard_loop_sends_edited_draft_after_resize(self):
win = mock.Mock()
+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))
+46
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(
+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()
+34 -1
View File
@@ -1,5 +1,9 @@
import unittest
from tempfile import NamedTemporaryFile
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.
@@ -7,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")
+51 -13
View File
@@ -1,11 +1,11 @@
import unittest
from unittest import mock
from meshtastic.protobuf import config_pb2
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.shared_state import interface_state, ui_state
from contact.utilities.utils import (
_get_channel_name,
_get_modem_preset_name,
@@ -15,18 +15,57 @@ from contact.utilities.utils import (
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"
@@ -42,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"),
],
)