Compare commits

...
9 Commits
Author SHA1 Message Date
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
41 changed files with 632 additions and 533 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 -256
View File
@@ -1,263 +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.logging_utils import configure_logging
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"
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) -> 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__":
+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
+7 -1
View File
@@ -60,7 +60,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
@@ -135,6 +135,10 @@ 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)
@@ -189,6 +193,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 +211,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 -95
View File
@@ -1,103 +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
from contact.utilities.logging_utils import configure_logging
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()
configure_logging(logging.WARNING)
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)
+2 -30
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:
@@ -1867,20 +1853,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
+2 -4
View File
@@ -3,7 +3,6 @@ import curses
import ipaddress
import logging
import os
import sys
import threading
import time
from typing import List
@@ -28,8 +27,7 @@ from contact.ui.dialog import dialog
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
+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:
+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
+1 -2
View File
@@ -9,7 +9,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
@@ -626,7 +626,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"
+4 -4
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
@@ -370,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]:
@@ -460,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:
File renamed without changes.
+9 -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)
+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.12"
version = "1.7.0"
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")
+4 -4
View File
@@ -6,9 +6,9 @@ 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):
@@ -59,12 +59,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()
+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:
+3 -3
View File
@@ -7,7 +7,7 @@ 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_singletons
from tests.test_support import reset_shared_state
class HamSettingsTests(unittest.TestCase):
@@ -82,7 +82,7 @@ class HamSettingsTests(unittest.TestCase):
node.setOwner.assert_called_once_with('KD2ABC', 'HAM', False, False)
def test_edit_through_normal_menu_after_selecting_licensed(self):
reset_singletons()
reset_shared_state()
interface, node = self.build_interface()
win = Mock()
# Open User Settings, select licensed, edit the now-visible callsign, save.
@@ -106,7 +106,7 @@ class HamSettingsTests(unittest.TestCase):
self.assertTrue(saved['isLicensed'])
self.assertEqual(saved['tx_power'], 20)
finally:
reset_singletons()
reset_shared_state()
def test_new_library_fields_are_discovered_edited_and_saved(self):
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
+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):
+3 -3
View File
@@ -10,7 +10,7 @@ from unittest import mock
import contact.ui.default_config
from contact.utilities import input_handlers
from tests.test_support import reset_singletons
from tests.test_support import reset_shared_state
class RestoredSettingsTests(unittest.TestCase):
@@ -20,8 +20,8 @@ class RestoredSettingsTests(unittest.TestCase):
(["\x1b"], True, None), (["\n", "\x1b"], False, None),
]:
with self.subTest(keys=keys, allowed=allowed), ExitStack() as stack:
reset_singletons()
stack.callback(reset_singletons)
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))
+33 -7
View File
@@ -1,22 +1,23 @@
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_on_receive_text_message_refreshes_selected_channel(self) -> None:
interface_state.myNodeNum = 111
@@ -52,7 +53,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 +112,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 +138,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))
+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())
+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()
+11 -12
View File
@@ -5,7 +5,7 @@ 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,7 +15,7 @@ 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):
@@ -59,13 +59,13 @@ class UtilsTests(unittest.TestCase):
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"
@@ -81,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"),
],
)