mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 08:11:57 +01:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f375d788f1 | ||
|
|
4c4b8a42c5 | ||
|
|
80667abb26 | ||
|
|
5210c54d2b | ||
|
|
6eaa567f48 | ||
|
|
76a5a1a496 | ||
|
|
2013b23812 | ||
|
|
22d8901115 | ||
|
|
94a976527d | ||
|
|
3fd811d4c2 | ||
|
|
acac61a060 | ||
|
|
fffc3e3e97 | ||
|
|
c956a11672 | ||
|
|
950266637f | ||
|
|
368f464649 | ||
|
|
f854d887b6 | ||
|
|
6c4bb0da11 | ||
|
|
642c4289f8 | ||
|
|
a7f8946f4b | ||
|
|
79e3bbe641 | ||
|
|
37c9e3222e | ||
|
|
28ab86d385 | ||
|
|
dfa0a892c0 | ||
|
|
826879eb25 | ||
|
|
aa38441d70 | ||
|
|
de0662f1f6 | ||
|
|
e9c1fecb23 | ||
|
|
afbde3c2d6 | ||
|
|
51351d316f | ||
|
|
888108f14e | ||
|
|
72f97bfde7 | ||
|
|
31bff496f1 | ||
|
|
b7c3ed20f2 | ||
|
|
7f7e0598d2 | ||
|
|
82e7456248 | ||
|
|
7b21c9c886 | ||
|
|
392d236a40 | ||
|
|
546fab099c | ||
|
|
a89d4f8c0d | ||
|
|
808fee4fe0 | ||
|
|
94d0a9053e | ||
|
|
f38167db1a | ||
|
|
57caf463bc |
No files matched your search
@@ -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
-261
@@ -1,268 +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)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
choice = get_list_input(
|
||||
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
|
||||
"Retry",
|
||||
["Retry", "Close"],
|
||||
mandatory=True,
|
||||
)
|
||||
close_interface(interface)
|
||||
if choice == "Close":
|
||||
return None
|
||||
|
||||
draw_splash(stdscr)
|
||||
|
||||
|
||||
def initialize_globals(seed_demo: bool = False) -> None:
|
||||
"""Initializes interface and shared globals."""
|
||||
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
ui_state.notifications = []
|
||||
ui_state.packet_buffer = []
|
||||
ui_state.node_list = []
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.selected_message = 0
|
||||
ui_state.selected_node = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
interface_state.myNodeNum = get_nodeNum()
|
||||
ui_state.channel_list = get_channels()
|
||||
ui_state.node_list = get_node_list()
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
init_nodedb()
|
||||
if seed_demo:
|
||||
seed_demo_messages()
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
"""Main entry point for the curses UI."""
|
||||
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
setup_colors()
|
||||
ensure_min_rows(stdscr)
|
||||
draw_splash(stdscr)
|
||||
|
||||
args = setup_parser().parse_args()
|
||||
|
||||
if getattr(args, "settings", False):
|
||||
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
||||
return
|
||||
|
||||
logging.info("Initializing interface...")
|
||||
with app_state.lock:
|
||||
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
|
||||
if interface_state.interface is None:
|
||||
return
|
||||
|
||||
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args, stdscr)
|
||||
|
||||
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
main_ui(stdscr)
|
||||
except Exception:
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("Uncaught exception inside main_ui")
|
||||
logging.error("Traceback:\n%s", traceback.format_exc())
|
||||
logging.error("Console output:\n%s", console_output)
|
||||
return
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
while True:
|
||||
rows, _ = stdscr.getmaxyx()
|
||||
if rows >= min_rows:
|
||||
return
|
||||
dialog(
|
||||
t("ui.dialog.resize_title", default="Resize Terminal"),
|
||||
t(
|
||||
"ui.dialog.resize_body",
|
||||
default="Please resize the terminal to at least {rows} rows.",
|
||||
rows=min_rows,
|
||||
),
|
||||
)
|
||||
curses.update_lines_cols()
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Entry point for the application."""
|
||||
|
||||
if "--help" in sys.argv or "-h" in sys.argv:
|
||||
setup_parser().print_help()
|
||||
sys.exit(0)
|
||||
|
||||
interrupted = False
|
||||
fatal_error = None
|
||||
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
logging.info("User exited with Ctrl+C")
|
||||
except Exception as e:
|
||||
fatal_error = e
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
try:
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
close_interface(interface_state.interface)
|
||||
|
||||
if fatal_error is not None:
|
||||
print("Fatal error:", fatal_error)
|
||||
traceback.print_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__":
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
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()
|
||||
@@ -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)
|
||||
+124
-81
@@ -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,26 +231,15 @@ def draw_window_arrows(window_id: int) -> None:
|
||||
nodes_win.refresh()
|
||||
|
||||
|
||||
def compute_widths(total_w: int, focus: int):
|
||||
# focus: 0=channel, 1=messages, 2=nodes
|
||||
if total_w < 3 * MIN_COL:
|
||||
# tiny terminals: allocate something, anything
|
||||
return max(1, total_w), 0, 0
|
||||
|
||||
if focus == 0:
|
||||
return total_w - 2 * MIN_COL, MIN_COL, MIN_COL
|
||||
if focus == 1:
|
||||
return MIN_COL, total_w - 2 * MIN_COL, MIN_COL
|
||||
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
|
||||
|
||||
|
||||
def paint_frame(win, selected: bool) -> None:
|
||||
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
|
||||
if win is not entry_win and ui_state.input_focused:
|
||||
selected = False
|
||||
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
|
||||
win.box()
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.refresh()
|
||||
if refresh:
|
||||
win.refresh()
|
||||
|
||||
|
||||
def get_channel_row_color(index: int) -> int:
|
||||
@@ -275,7 +262,7 @@ def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||
return get_color(color, reverse=reverse)
|
||||
|
||||
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh: bool = True) -> None:
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
@@ -294,8 +281,9 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
|
||||
pass
|
||||
|
||||
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
if refresh:
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
|
||||
|
||||
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
@@ -306,7 +294,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
|
||||
refresh_pad(0)
|
||||
elif window_id == 1:
|
||||
paint_frame(messages_win, selected=selected)
|
||||
paint_frame(messages_win, selected=selected, refresh=False)
|
||||
refresh_pad(1)
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
@@ -601,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))
|
||||
|
||||
@@ -687,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
|
||||
@@ -700,11 +694,27 @@ def draw_message_input(draft: str) -> None:
|
||||
used += text_width(content[start])
|
||||
visible = slice_to_width(content[start:], available)
|
||||
draw_text_field(entry_win, prompt + visible, get_color("input"))
|
||||
paint_frame(entry_win, selected=ui_state.input_focused)
|
||||
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:
|
||||
@@ -935,6 +945,28 @@ def handle_enter(input_text: str) -> str:
|
||||
return input_text
|
||||
|
||||
|
||||
def stage_node_details_background(stdscr, previous_window):
|
||||
"""Restore the panes behind a moved dialog without displaying an intermediate frame."""
|
||||
current_window = ui_state.current_window
|
||||
try:
|
||||
ui_state.current_window = previous_window
|
||||
stdscr.touchwin()
|
||||
stdscr.noutrefresh()
|
||||
windows = (channel_win, messages_win, nodes_win)
|
||||
for index, win in enumerate(windows):
|
||||
if ui_state.single_pane_mode and index != previous_window:
|
||||
continue
|
||||
win.touchwin()
|
||||
refresh_pad(index, commit=False)
|
||||
if ui_state.display_log and (not ui_state.single_pane_mode or previous_window == 1):
|
||||
packetlog_win.touchwin()
|
||||
packetlog_win.noutrefresh()
|
||||
entry_win.touchwin()
|
||||
entry_win.noutrefresh()
|
||||
finally:
|
||||
ui_state.current_window = current_window
|
||||
|
||||
|
||||
def handle_f5_key(stdscr: curses.window) -> None:
|
||||
if not ui_state.node_list:
|
||||
return
|
||||
@@ -947,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')}")
|
||||
|
||||
@@ -969,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}")
|
||||
@@ -983,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"]
|
||||
@@ -996,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}%")
|
||||
|
||||
@@ -1032,6 +1061,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
ui_state.current_window = 4
|
||||
scroll_offset = 0
|
||||
dialog_win = None
|
||||
previous_layout = None
|
||||
|
||||
curses.curs_set(0)
|
||||
refresh_node_selection(highlight=True)
|
||||
@@ -1051,11 +1081,24 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||
|
||||
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
|
||||
if previous_layout is not None and layout != previous_layout:
|
||||
if (height, width) != previous_layout[-2:]:
|
||||
# Rebuild the background before staging pads whose old
|
||||
# screen coordinates may now lie outside the terminal.
|
||||
ui_state.current_window = previous_window
|
||||
try:
|
||||
handle_resize(stdscr, False)
|
||||
finally:
|
||||
ui_state.current_window = 4
|
||||
curses.curs_set(0)
|
||||
stage_node_details_background(stdscr, previous_window)
|
||||
previous_layout = layout
|
||||
|
||||
if dialog_win is None:
|
||||
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||
else:
|
||||
dialog_win.erase()
|
||||
dialog_win.refresh()
|
||||
dialog_win.resize(dialog_height, dialog_width)
|
||||
dialog_win.mvwin(y, x)
|
||||
|
||||
@@ -1100,7 +1143,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
dialog_win.refresh()
|
||||
dialog_win.noutrefresh()
|
||||
msg_win.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
@@ -1120,12 +1163,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
elif char == curses.KEY_DOWN:
|
||||
old_selected_node = ui_state.selected_node
|
||||
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
|
||||
scroll_offset = 0
|
||||
refresh_node_selection(old_selected_node, highlight=True)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
elif char == curses.KEY_PPAGE:
|
||||
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||
elif char == curses.KEY_NPAGE:
|
||||
@@ -1611,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}")
|
||||
@@ -1637,12 +1683,12 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
message_ranges.append((start_line, len(rendered_lines), color))
|
||||
|
||||
msg_line_count = len(rendered_lines)
|
||||
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
|
||||
for row, (line, color) in enumerate(rendered_lines):
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
ui_state.message_line_ranges[channel] = message_ranges
|
||||
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
|
||||
for row, (line, color) in enumerate(rendered_lines):
|
||||
messages_pad.addstr(row, 1, line, color)
|
||||
ui_state.message_line_ranges[channel] = message_ranges
|
||||
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1))
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
|
||||
|
||||
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
|
||||
@@ -1657,7 +1703,6 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
|
||||
refresh_message_highlight()
|
||||
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
draw_packetlog_win()
|
||||
draw_window_arrows(1)
|
||||
@@ -1836,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
|
||||
@@ -1952,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
|
||||
@@ -1987,7 +2018,7 @@ def search(win: int) -> None:
|
||||
entry_win.erase()
|
||||
|
||||
|
||||
def refresh_pad(window: int) -> None:
|
||||
def refresh_pad(window: int, commit: bool = True) -> None:
|
||||
|
||||
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
|
||||
if ui_state.single_pane_mode and window != ui_state.current_window:
|
||||
@@ -2004,7 +2035,7 @@ def refresh_pad(window: int) -> None:
|
||||
|
||||
if ui_state.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
packetlog_win.noutrefresh()
|
||||
|
||||
elif window == 2:
|
||||
pad = nodes_pad
|
||||
@@ -2034,29 +2065,41 @@ def refresh_pad(window: int) -> None:
|
||||
|
||||
# Clamp start_index within the pad's height
|
||||
pad_h, pad_w = pad.getmaxyx()
|
||||
if pad_h <= 0:
|
||||
if pad_h <= 0 or pad_w <= 0:
|
||||
return
|
||||
start_index = max(0, min(start_index, max(0, pad_h - 1)))
|
||||
|
||||
top = box_y + 1
|
||||
left = box_x + 1
|
||||
bottom = box_y + min(inner_h, lines) # inclusive
|
||||
right = box_x + min(inner_w, box_w - 2)
|
||||
# A resize can leave the frame larger than its pad or the screen.
|
||||
# noutrefresh requires both source and destination rectangles to fit.
|
||||
bottom = min(top + lines - 1, top + pad_h - start_index - 1, curses.LINES - 1)
|
||||
right = min(left + inner_w - 1, left + pad_w - 1, curses.COLS - 1)
|
||||
|
||||
if bottom < top or right < left:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
try:
|
||||
box.noutrefresh()
|
||||
|
||||
pad.refresh(
|
||||
start_index,
|
||||
0,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
# The frame overlaps the pad's viewport. Recompose even unchanged pad
|
||||
# rows before committing, so its text cannot flash blank between draws.
|
||||
pad.touchwin()
|
||||
pad.noutrefresh(
|
||||
start_index,
|
||||
0,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
except curses.error:
|
||||
# The terminal can resize again between checking bounds and drawing.
|
||||
# Let the next resize/redraw rebuild the windows instead of exiting.
|
||||
ui_state.redraw_full_ui = True
|
||||
|
||||
|
||||
def add_notification(channel_number: int) -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -141,6 +139,12 @@ def display_menu() -> tuple[object, object]:
|
||||
current_value = field_info[1] if isinstance(field_info, tuple) else ""
|
||||
full_key = ".".join(transformed_path + [option])
|
||||
display_name = field_mapping.get(full_key, option)
|
||||
if menu_state.menu_path == ["Main Menu", "Channels"] and isinstance(field_info, dict):
|
||||
# Keep slot keys for navigation/saving, but label rows with the
|
||||
# configured name. Empty names retain their slot label.
|
||||
channel_name = field_info.get("name", (None, ""))[1]
|
||||
if isinstance(channel_name, str) and channel_name.strip():
|
||||
display_name = channel_name
|
||||
|
||||
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
|
||||
if isinstance(current_value, int):
|
||||
@@ -240,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
|
||||
@@ -354,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
|
||||
|
||||
@@ -502,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:
|
||||
@@ -756,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"]:
|
||||
@@ -779,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":
|
||||
@@ -820,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()
|
||||
@@ -846,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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ from contact.ui.colors import get_color
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.control_utils import transform_menu_path
|
||||
from typing import Any, Optional, List, Dict
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
from contact.utilities.shared_state import interface_state, ui_state
|
||||
|
||||
|
||||
def get_node_color(node_index: int, reverse: bool = False):
|
||||
|
||||
+17
-5
@@ -1,8 +1,10 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from contact.ui.colors import get_color
|
||||
from contact.version import __version__
|
||||
|
||||
|
||||
def draw_splash(stdscr: object) -> None:
|
||||
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
|
||||
"""Draw the splash screen with a logo and connecting message."""
|
||||
curses.curs_set(0)
|
||||
|
||||
@@ -13,16 +15,26 @@ def draw_splash(stdscr: object) -> None:
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
message_3 = "P W R D"
|
||||
message_4 = "connecting..."
|
||||
|
||||
start_x = width // 2 - len(message_1) // 2
|
||||
start_x2 = width // 2 - len(message_4) // 2
|
||||
start_y = height // 2 - 1
|
||||
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
|
||||
stdscr.move(start_y + 5, start_x2)
|
||||
if __version__:
|
||||
ver_line = f"v{__version__}"
|
||||
ver_x = max(1, (width - len(ver_line)) // 2)
|
||||
ver_y = start_y + 3
|
||||
if ver_y < height - 1:
|
||||
stdscr.addstr(ver_y, ver_x, ver_line, get_color("splash_text"))
|
||||
status_lines = []
|
||||
for line in status.splitlines():
|
||||
status_lines.extend(textwrap.wrap(line, max(1, width - 4)) or [""])
|
||||
for offset, line in enumerate(status_lines):
|
||||
row = start_y + 4 + offset
|
||||
if row >= height - 1:
|
||||
break
|
||||
stdscr.addstr(row, max(1, (width - len(line)) // 2), line, get_color("splash_text"))
|
||||
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
|
||||
@@ -68,3 +68,4 @@ class InterfaceState:
|
||||
@dataclass
|
||||
class AppState:
|
||||
lock: Any = None
|
||||
connection_args: Any = None
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import logging
|
||||
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:
|
||||
@@ -25,11 +51,11 @@ def interface_is_connected(interface) -> bool:
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
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:
|
||||
@@ -38,12 +64,15 @@ def initialize_interface(args):
|
||||
else:
|
||||
tcp_hostname = args.host
|
||||
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
|
||||
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
|
||||
return TCPInterface(tcp_hostname, portNumber=tcp_port)
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to {args.host}. {ex}")
|
||||
else:
|
||||
try:
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
# The library prints its fallback notice directly to stdout,
|
||||
# which would overwrite the curses splash screen.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
client = SerialInterface(args.port)
|
||||
except FileNotFoundError as ex:
|
||||
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
|
||||
except PermissionError as ex:
|
||||
@@ -56,7 +85,9 @@ def initialize_interface(args):
|
||||
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
|
||||
if client.devPath is None:
|
||||
try:
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
if status_callback:
|
||||
status_callback("No serial device found.\nConnecting via TCP to localhost…")
|
||||
client = TCPInterface("localhost")
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to localhost:{ex}")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.6.7"
|
||||
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"}
|
||||
|
||||
@@ -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__)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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")
|
||||
+197
-5
@@ -1,23 +1,149 @@
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui import contact_ui
|
||||
from contact.ui.nav_utils import text_width
|
||||
from contact.utilities.singleton import ui_state
|
||||
from contact.utilities.shared_state import ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
from tests.test_support import reset_shared_state, restore_config, snapshot_config
|
||||
|
||||
|
||||
class ContactUiTests(unittest.TestCase):
|
||||
def test_refresh_pad_clips_to_pad_and_resized_screen(self):
|
||||
for pad_size, screen_size, expected in (
|
||||
((2, 5), (24, 80), (1, 0, 1, 1, 1, 5)),
|
||||
((100, 100), (6, 8), (1, 0, 1, 1, 5, 7)),
|
||||
):
|
||||
with self.subTest(pad_size=pad_size, screen_size=screen_size), ExitStack() as stack:
|
||||
box = mock.Mock()
|
||||
box.getmaxyx.return_value = (40, 100)
|
||||
box.getbegyx.return_value = (0, 0)
|
||||
pad = mock.Mock()
|
||||
pad.getmaxyx.return_value = pad_size
|
||||
ui_state.selected_channel = 38
|
||||
stack.enter_context(mock.patch.object(contact_ui, "channel_win", box, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "channel_pad", pad, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", screen_size[0], create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", screen_size[1], create=True))
|
||||
contact_ui.refresh_pad(0, commit=False)
|
||||
pad.noutrefresh.assert_called_once_with(*expected)
|
||||
pad.noutrefresh.side_effect = contact_ui.curses.error("resize during refresh")
|
||||
contact_ui.refresh_pad(0, commit=False)
|
||||
self.assertTrue(ui_state.redraw_full_ui)
|
||||
|
||||
def test_f5_rebuilds_background_before_staging_after_terminal_resize(self):
|
||||
for size in ((24, 80), (50, 140)):
|
||||
with self.subTest(size=size), ExitStack() as stack:
|
||||
ui_state.node_list = [101]
|
||||
ui_state.current_window = 2
|
||||
interface = SimpleNamespace(nodesByNum={101: {"user": {"shortName": "NODE"}}})
|
||||
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", 40, create=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", 100, create=True))
|
||||
win = mock.Mock()
|
||||
def read_key():
|
||||
if (contact_ui.curses.LINES, contact_ui.curses.COLS) == (40, 100):
|
||||
contact_ui.curses.LINES, contact_ui.curses.COLS = size
|
||||
return contact_ui.curses.KEY_RESIZE
|
||||
return 27
|
||||
win.getch.side_effect = read_key
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
|
||||
for name in ("curs_set", "update_lines_cols", "doupdate"):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name))
|
||||
for name in ("get_color", "refresh_node_selection"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
|
||||
events = []
|
||||
def resize(*args):
|
||||
events.append(("resize", ui_state.current_window))
|
||||
def stage(*args):
|
||||
events.append(("stage", ui_state.current_window))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "handle_resize", side_effect=resize))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background", side_effect=stage))
|
||||
contact_ui.handle_f5_key(mock.Mock())
|
||||
self.assertEqual(events, [("resize", 2), ("stage", 4), ("resize", 2)])
|
||||
self.assertEqual(ui_state.current_window, 2)
|
||||
|
||||
def test_main_ui_accepts_keypad_and_regular_enter(self):
|
||||
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
|
||||
with self.subTest(key=key), ExitStack() as stack:
|
||||
entry = mock.Mock()
|
||||
entry.get_wch.side_effect = [key, "\x1b"]
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
|
||||
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name))
|
||||
enter = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
|
||||
contact_ui.main_ui(mock.Mock())
|
||||
enter.assert_called_once_with("")
|
||||
|
||||
def test_search_closes_on_keypad_and_regular_enter(self):
|
||||
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
|
||||
with self.subTest(key=key), ExitStack() as stack:
|
||||
entry = mock.Mock()
|
||||
entry.get_wch.side_effect = [key]
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=0))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "draw_centered_text_field"))
|
||||
contact_ui.search(2)
|
||||
entry.get_wch.assert_called_once_with()
|
||||
self.assertEqual(entry.timeout.call_args_list, [mock.call(-1), mock.call(200)])
|
||||
|
||||
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
|
||||
ui_state.node_list = [101, 102]
|
||||
ui_state.current_window = 2
|
||||
interface = SimpleNamespace(nodesByNum={
|
||||
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
|
||||
102: {"user": {"shortName": "SMALL", "longName": "B"}},
|
||||
})
|
||||
win = mock.Mock()
|
||||
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
|
||||
events = mock.Mock()
|
||||
events.attach_mock(win.noutrefresh, "dialog")
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
|
||||
for name, value in (("LINES", 40), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
|
||||
for name in ("curs_set", "update_lines_cols", "doupdate"):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
|
||||
for name in ("get_color", "refresh_node_selection", "handle_resize"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
|
||||
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
|
||||
events.attach_mock(restore, "restore")
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
self.assertEqual(events.mock_calls, [
|
||||
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
])
|
||||
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
|
||||
self.assertEqual(win.refresh.call_count, 1) # Only on close.
|
||||
|
||||
def test_background_restore_stages_panes_without_flushing(self):
|
||||
ui_state.current_window = 4
|
||||
ui_state.single_pane_mode = True
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
|
||||
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
|
||||
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
|
||||
contact_ui.stage_node_details_background(stdscr, 2)
|
||||
refresh.assert_called_once_with(2, commit=False)
|
||||
update.assert_not_called()
|
||||
stdscr.noutrefresh.assert_called_once()
|
||||
self.assertEqual(ui_state.current_window, 4)
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
@@ -167,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: ",
|
||||
@@ -437,6 +615,10 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(contact_ui.get_window_title(1), "Primary")
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(contact_ui.curses, "LINES", 24, create=True).start()
|
||||
mock.patch.object(contact_ui.curses, "COLS", 80, create=True).start()
|
||||
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -453,9 +635,16 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
|
||||
contact_ui.refresh_pad(1)
|
||||
with mock.patch.object(contact_ui.curses, "doupdate") as update:
|
||||
contact_ui.refresh_pad(1)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
contact_ui.messages_win.refresh.assert_not_called()
|
||||
contact_ui.messages_pad.refresh.assert_not_called()
|
||||
contact_ui.messages_win.noutrefresh.assert_called_once()
|
||||
contact_ui.messages_pad.touchwin.assert_called_once()
|
||||
contact_ui.messages_pad.noutrefresh.assert_called_once()
|
||||
update.assert_called_once()
|
||||
|
||||
def test_search_ignores_no_input_from_curses(self) -> None:
|
||||
ui_state.node_list = [101]
|
||||
@@ -508,6 +697,9 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
|
||||
self.assertEqual(dialog_win.getch.call_count, 2)
|
||||
# Idle polling must never present an erased dialog before its text.
|
||||
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
|
||||
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
|
||||
def test_f5_node_details_tolerates_none_metrics(self) -> None:
|
||||
|
||||
+40
-15
@@ -1,39 +1,64 @@
|
||||
from argparse import Namespace
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import control_ui
|
||||
from contact.utilities.singleton import interface_state
|
||||
from contact.utilities.shared_state import app_state, interface_state
|
||||
|
||||
from tests.test_support import reset_singletons
|
||||
from tests.test_support import reset_shared_state
|
||||
|
||||
|
||||
class ControlUiTests(unittest.TestCase):
|
||||
def test_channel_list_renders_names_without_changing_slot_keys(self):
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.utilities.shared_state import app_state, menu_state
|
||||
|
||||
channels = [channel_pb2.Channel(settings=channel_pb2.ChannelSettings(name=name))
|
||||
for name in ("Local", "Local", "", " ")]
|
||||
node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda i: channels[i] if i < len(channels) else None,
|
||||
)
|
||||
menu_state.menu_path = ["Main Menu", "Channels"]
|
||||
menu_state.current_menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"]
|
||||
win, pad = mock.Mock(), mock.Mock()
|
||||
win.getbegyx.return_value = (2, 2)
|
||||
win.getmaxyx.return_value = (10, 80)
|
||||
with ExitStack() as stack:
|
||||
for name, value in (("LINES", 24), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, name, value, create=True))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newwin", return_value=win))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newpad", return_value=pad))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "curs_set"))
|
||||
stack.enter_context(mock.patch.object(control_ui, "get_menu_width", return_value=80))
|
||||
for name in ("get_color", "draw_help_window", "draw_arrows"):
|
||||
stack.enter_context(mock.patch.object(control_ui, name, return_value=0))
|
||||
control_ui.display_menu()
|
||||
labels = [call.args[2].strip() for call in pad.addstr.call_args_list]
|
||||
self.assertEqual(labels, ["Local", "Local", "Channel 3", "Channel 4"])
|
||||
self.assertEqual(list(menu_state.current_menu), [f"Channel {i}" for i in range(1, 5)])
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
|
||||
old_interface = mock.Mock()
|
||||
new_interface = mock.Mock()
|
||||
stdscr = mock.Mock()
|
||||
parser = mock.Mock()
|
||||
parser.parse_args.return_value = Namespace()
|
||||
app_state.connection_args = Namespace(host="mesh.local", port=None, ble=None)
|
||||
|
||||
with mock.patch.object(control_ui, "setup_parser", return_value=parser):
|
||||
with mock.patch.object(control_ui, "draw_splash") as draw_splash:
|
||||
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||
|
||||
old_interface.close.assert_called_once_with()
|
||||
stdscr.clear.assert_called_once_with()
|
||||
stdscr.refresh.assert_called_once_with()
|
||||
draw_splash.assert_called_once_with(stdscr)
|
||||
reconnect.assert_called_once_with(parser.parse_args.return_value)
|
||||
reconnect.assert_called_once_with(app_state.connection_args)
|
||||
redraw.assert_called_once_with(stdscr)
|
||||
self.assertIs(result, new_interface)
|
||||
self.assertIs(interface_state.interface, new_interface)
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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,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())
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user