mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 16:21:57 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
36408a1f37 | ||
|
|
07b9ed22d8 | ||
|
|
bdba69677b | ||
|
|
1f88a37ac1 | ||
|
|
8f33cbc22d | ||
|
|
40665ff076 | ||
|
|
d1b6da6f26 | ||
|
|
ac90b4af3e | ||
|
|
7af693ddea | ||
|
|
dae6a43652 | ||
|
|
22e5e89828 | ||
|
|
c2db76d5ec | ||
|
|
d8dc4876b6 | ||
|
|
0ae644e3fb | ||
|
|
bfd20bfa49 | ||
|
|
fa2dbccf44 | ||
|
|
66256f0a4c | ||
|
|
e570ce4e48 | ||
|
|
d0d9c1dd55 | ||
|
|
043418bfde | ||
|
|
a8a1527a84 | ||
|
|
7d2092e25f | ||
|
|
ac23c2bbbb | ||
|
|
e378aba526 | ||
|
|
ec35cbd3e7 | ||
|
|
7f6fa49385 | ||
|
|
4ed3cbf370 | ||
|
|
7afc025b9d | ||
|
|
ece37f2a36 | ||
|
|
0cee9777bc | ||
|
|
72956321da | ||
|
|
6a61f7dcd5 | ||
|
|
b1b9f22203 | ||
|
|
f63c27e9ad | ||
|
|
8ea5646f6c | ||
|
|
41d703ad9b | ||
|
|
f4bd0c8614 | ||
|
|
68b5d81c1f | ||
|
|
61bbc24d29 | ||
|
|
0143d9bacc | ||
|
|
424e10af1e | ||
|
|
986cf817f3 | ||
|
|
c699845c81 | ||
|
|
7273e80639 | ||
|
|
954bd6ee82 | ||
|
|
2cebd6b659 | ||
|
|
72d959a3c1 | ||
|
|
73b31a5124 | ||
|
|
d403d2cea5 | ||
|
|
59a7b5b488 | ||
|
|
5baef73c3c | ||
|
|
46a73fbc65 | ||
|
|
0145d84f3b | ||
|
|
ff656f20ad | ||
|
|
eef321cac7 | ||
|
|
cc33716222 |
No files matched your search
Vendored
+8
@@ -17,6 +17,14 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","192.168.86.69"]
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: tcp-remote",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"module": "contact.__main__",
|
||||
"args": ["--host","100.89.187.29"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
|
||||
|
||||
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
|
||||
|
||||
### Notification sounds
|
||||
|
||||
Add audio files to Contact's `contact/sounds/` folder, then choose one in Settings → App Settings → Notification sound. `alert.mp3` is the default selection; choose `None` to disable notification audio. Supported file types are MP3, WAV, OGG, AIFF, and FLAC.
|
||||
|
||||
### Remote administration
|
||||
|
||||
With the Nodes pane selected, press `` ` `` or `F12` and choose **Remote admin** for the highlighted node. Contact requests the node's settings through Meshtastic Remote Admin and displays an error if the node does not authorize your public key. Remote App Settings are intentionally unavailable because they apply only to the local Contact client.
|
||||
|
||||
For smaller displays you may wish to enable `single_pane_mode`:
|
||||
|
||||
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
|
||||
@@ -60,7 +68,6 @@ For smaller displays you may wish to enable `single_pane_mode`:
|
||||
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
|
||||
- `F5` = Display a node's info
|
||||
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
|
||||
- `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong)
|
||||
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
|
||||
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
||||
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
|
||||
@@ -84,9 +91,17 @@ Optional arguments to specify a device to connect to and how.
|
||||
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed.
|
||||
- `--ble`, `-b`: The BLE device MAC address or name to connect to.
|
||||
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
|
||||
- `--help`, `-h`: Show usage and available options, then exit.
|
||||
- `--version`, `-V`: Show the application version, then exit.
|
||||
|
||||
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
|
||||
|
||||
Choose at most one connection type. Connection options also apply to control mode,
|
||||
for example `contact --control --host mesh.local`. The standalone
|
||||
`python -m contact.settings` command accepts the same options. Help and version
|
||||
exit with status 0; invalid arguments print an error to stderr and exit with
|
||||
status 2 before starting the UI.
|
||||
|
||||
### Example Usage
|
||||
|
||||
```sh
|
||||
|
||||
+8
-255
@@ -1,262 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Parse command-line options before initializing Contact's runtime."""
|
||||
|
||||
"""
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
|
||||
Meshtastic® is a registered trademark of Meshtastic LLC.
|
||||
Meshtastic software components are released under various licenses—see GitHub for details.
|
||||
No warranty is provided. Use at your own risk.
|
||||
"""
|
||||
|
||||
# Standard library
|
||||
import contextlib
|
||||
import curses
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
# Third-party
|
||||
from pubsub import pub
|
||||
|
||||
# Local application
|
||||
import contact.ui.default_config as config
|
||||
from contact.message_handlers.rx_handler import on_receive
|
||||
from contact.settings import set_region
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.contact_ui import main_ui
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||
from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Environment & Logging Setup
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
|
||||
os.environ["LANG"] = "C.UTF-8"
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
if os.environ.get("COLORTERM") == "gnome-terminal":
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
logging.basicConfig(
|
||||
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main Program Logic
|
||||
# ------------------------------------------------------------------------------
|
||||
def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> None:
|
||||
"""Prompt user to set region if it is unset."""
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
set_region(interface_state.interface)
|
||||
close_interface(interface_state.interface)
|
||||
if stdscr is not None:
|
||||
draw_splash(stdscr)
|
||||
interface_state.interface = reconnect_interface(args)
|
||||
|
||||
|
||||
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
|
||||
if interface is None:
|
||||
return True
|
||||
|
||||
close_errors = []
|
||||
|
||||
def _close_target() -> None:
|
||||
try:
|
||||
interface.close()
|
||||
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
|
||||
close_errors.append(error)
|
||||
|
||||
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
|
||||
close_thread.start()
|
||||
close_thread.join(timeout_seconds)
|
||||
|
||||
if close_thread.is_alive():
|
||||
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
|
||||
return False
|
||||
|
||||
if not close_errors:
|
||||
return True
|
||||
|
||||
error = close_errors[0]
|
||||
if isinstance(error, KeyboardInterrupt):
|
||||
logging.info("Interrupted while closing interface; continuing shutdown")
|
||||
return True
|
||||
|
||||
logging.warning("Ignoring error while closing interface: %r", error)
|
||||
return True
|
||||
|
||||
|
||||
def interface_is_ready(interface: object) -> bool:
|
||||
try:
|
||||
return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||
while True:
|
||||
interface = initialize_runtime_interface(args)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
choice = get_list_input(
|
||||
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
|
||||
"Retry",
|
||||
["Retry", "Close"],
|
||||
mandatory=True,
|
||||
)
|
||||
close_interface(interface)
|
||||
if choice == "Close":
|
||||
return None
|
||||
|
||||
draw_splash(stdscr)
|
||||
|
||||
|
||||
def initialize_globals(seed_demo: bool = False) -> None:
|
||||
"""Initializes interface and shared globals."""
|
||||
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
ui_state.notifications = []
|
||||
ui_state.packet_buffer = []
|
||||
ui_state.node_list = []
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.selected_message = 0
|
||||
ui_state.selected_node = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
interface_state.myNodeNum = get_nodeNum()
|
||||
ui_state.channel_list = get_channels()
|
||||
ui_state.node_list = get_node_list()
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
init_nodedb()
|
||||
if seed_demo:
|
||||
seed_demo_messages()
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
"""Main entry point for the curses UI."""
|
||||
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
setup_colors()
|
||||
ensure_min_rows(stdscr)
|
||||
draw_splash(stdscr)
|
||||
|
||||
args = setup_parser().parse_args()
|
||||
|
||||
if getattr(args, "settings", False):
|
||||
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
||||
return
|
||||
|
||||
logging.info("Initializing interface...")
|
||||
with app_state.lock:
|
||||
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
|
||||
if interface_state.interface is None:
|
||||
return
|
||||
|
||||
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args, stdscr)
|
||||
|
||||
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
main_ui(stdscr)
|
||||
except Exception:
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("Uncaught exception inside main_ui")
|
||||
logging.error("Traceback:\n%s", traceback.format_exc())
|
||||
logging.error("Console output:\n%s", console_output)
|
||||
return
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
while True:
|
||||
rows, _ = stdscr.getmaxyx()
|
||||
if rows >= min_rows:
|
||||
return
|
||||
dialog(
|
||||
t("ui.dialog.resize_title", default="Resize Terminal"),
|
||||
t(
|
||||
"ui.dialog.resize_body",
|
||||
default="Please resize the terminal to at least {rows} rows.",
|
||||
rows=min_rows,
|
||||
),
|
||||
)
|
||||
curses.update_lines_cols()
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Entry point for the application."""
|
||||
|
||||
if "--help" in sys.argv or "-h" in sys.argv:
|
||||
setup_parser().print_help()
|
||||
sys.exit(0)
|
||||
|
||||
interrupted = False
|
||||
fatal_error = None
|
||||
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
logging.info("User exited with Ctrl+C")
|
||||
except Exception as e:
|
||||
fatal_error = e
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
try:
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
close_interface(interface_state.interface)
|
||||
|
||||
if fatal_error is not None:
|
||||
print("Fatal error:", fatal_error)
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if interrupted:
|
||||
sys.exit(0)
|
||||
def start(argv=None) -> None:
|
||||
args = setup_parser().parse_args(argv)
|
||||
if args.settings:
|
||||
from contact.settings_runtime import start as start_runtime
|
||||
else:
|
||||
from contact.runtime import start as start_runtime
|
||||
start_runtime(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -81,7 +81,6 @@ help.traceroute, "Ctrl+T or F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Full node info", ""
|
||||
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
||||
help.favorite, "Ctrl+F = Favorite", ""
|
||||
help.bot_responder, "Ctrl+B = Toggle Bot Responder", ""
|
||||
help.ignore, "Ctrl+G = Ignore", ""
|
||||
help.search, "Ctrl+/ or / = Search", ""
|
||||
help.help, "Ctrl+K = Help", ""
|
||||
@@ -94,17 +93,18 @@ confirm.remove_ignored, "Remove {name} from Ignored?", ""
|
||||
confirm.region_unset, "Your region is UNSET. Set it now?", ""
|
||||
dialog.resize_title, "Resize Terminal", ""
|
||||
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
|
||||
bot.status.enabled, "Enabled", ""
|
||||
bot.status.disabled, "Disabled", ""
|
||||
bot.dialog.title, "Bot Responder", ""
|
||||
bot.dialog.body, "Bot responder is now {status}.", ""
|
||||
bot.status.message, "Bot responder is now {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "User"
|
||||
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
|
||||
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
|
||||
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
|
||||
isLicensed, "Enable licensed amateur (HAM) mode", "Shows HAM parameters for editing. Saving applies licensed operation, including radio and channel changes. Disabling does not restore previous settings."
|
||||
call_sign, "Callsign", "Licensed operator callsign."
|
||||
tx_power, "TX power in dBm", "Transmit power at the LoRa transceiver, before amplification. Use a value supported by your radio."
|
||||
frequency, "Frequency in MHz", "Operating frequency override. Zero uses automatic frequency selection."
|
||||
short_name, "HAM short name", "Optional short display name."
|
||||
long_name, "Name appended to callsign", "Optional name appended to the callsign. Requires supporting firmware."
|
||||
isUnmessagable, "Disable incoming messages", "When enabled, this node advertises that it cannot receive messages."
|
||||
|
||||
[app_settings]
|
||||
title, "App Settings", ""
|
||||
@@ -118,7 +118,8 @@ language, "Language", "UI language for labels and help text."
|
||||
message_prefix, "Message prefix", ""
|
||||
sent_message_prefix, "Sent message prefix", ""
|
||||
notification_symbol, "Notification symbol", ""
|
||||
notification_sound, "Notification sound", ""
|
||||
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
|
||||
view_log, "View Log", "Open a scrollable live view of Contact's log file."
|
||||
ack_implicit_str, "ACK (implicit)", ""
|
||||
ack_str, "ACK", ""
|
||||
nak_str, "NAK", ""
|
||||
@@ -132,6 +133,7 @@ COLOR_CONFIG_GREEN, "Theme colors (green)", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Ping Bot", ""
|
||||
enabled, "Enabled", "Enable automatic Ping Bot responses."
|
||||
catch_words, "Catch words", "Semicolon-separated bot trigger words."
|
||||
response_word, "Response word", "Bot response word."
|
||||
|
||||
@@ -163,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."
|
||||
@@ -171,7 +174,7 @@ uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT s
|
||||
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
|
||||
module_settings, "Module settings", "Position precision and Client Mute."
|
||||
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
|
||||
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
|
||||
module_settings.is_muted, "Is muted", "Controls whether or not the client or device should mute this channel. Useful for noisy public channels you do not necessarily want to disable."
|
||||
|
||||
[config.device]
|
||||
title, "Device"
|
||||
@@ -257,6 +260,8 @@ heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' o
|
||||
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
|
||||
compass_orientation, "Compass orientation", "Whether to rotate the compass."
|
||||
use_12h_clock, "Use 12 hour clock"
|
||||
use_long_node_name, "Use long node name", "Show each node's long name on the device display when space permits."
|
||||
enable_message_bubbles, "Enable message bubbles", "Display messages in chat-style bubbles on supported device displays."
|
||||
|
||||
[config.device_ui]
|
||||
title, "Device UI"
|
||||
@@ -275,6 +280,10 @@ node_filter, "Node Filter", ""
|
||||
node_highlight, "Node Highlight", ""
|
||||
calibration_data, "Calibration Data", ""
|
||||
map_data, "Map Data", ""
|
||||
compass_mode, "Compass mode", "Controls whether the compass is dynamic, fixed, or freezes its heading."
|
||||
screen_rgb_color, "Screen RGB color", "Sets the display color on supported RGB screens."
|
||||
is_clockface_analog, "Analog clock face", "Use an analog clock face on supported device displays."
|
||||
gps_format, "GPS coordinate format", "Select the coordinate format used by the device UI."
|
||||
|
||||
[config.device_ui.node_filter]
|
||||
title, "Node Filter"
|
||||
@@ -300,6 +309,12 @@ home, "Home", ""
|
||||
style, "Style", ""
|
||||
follow_gps, "Follow GPS", ""
|
||||
|
||||
[config.device_ui.map_data.home]
|
||||
title, "Home location", ""
|
||||
zoom, "Zoom", "Map zoom level for the home location."
|
||||
latitude, "Latitude", "Latitude of the home location."
|
||||
longitude, "Longitude", "Longitude of the home location."
|
||||
|
||||
[config.lora]
|
||||
title, "LoRa"
|
||||
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
|
||||
@@ -319,6 +334,8 @@ override_frequency, "Override frequency in MHz", "Overrides frequency slot. May
|
||||
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
|
||||
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
|
||||
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
|
||||
fem_lna_mode, "FEM LNA mode", "Configures the front-end module low-noise amplifier mode on supported hardware."
|
||||
serial_hal_only, "Serial HAL only", "Use only the serial hardware abstraction layer on supported hardware."
|
||||
|
||||
[config.bluetooth]
|
||||
title, "Bluetooth"
|
||||
@@ -335,6 +352,7 @@ serial_enabled, "Enable serial console", ""
|
||||
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
|
||||
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
|
||||
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
|
||||
packet_signature_policy, "Packet signature policy", "Controls how the node validates packet signatures."
|
||||
|
||||
[module.mqtt]
|
||||
title, "MQTT"
|
||||
@@ -366,6 +384,7 @@ override_console_serial_port, "Override console serial port", "If set to true, t
|
||||
|
||||
[module.external_notification]
|
||||
title, "External Notification"
|
||||
ringtone, "Ringtone", "RTTTL ringtone played by supported external-notification hardware."
|
||||
enabled, "Enabled", "Enables the module."
|
||||
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
|
||||
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
|
||||
@@ -396,6 +415,7 @@ title, "Range Test"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
|
||||
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
|
||||
clear_on_reboot, "Clear CSV on reboot", "Delete the RangeTest.csv file when the device reboots. [warning]Only available on ESP32-based devices.[/warning]"
|
||||
|
||||
[module.telemetry]
|
||||
title, "Telemetry"
|
||||
@@ -413,9 +433,11 @@ health_measurement_enabled, "Health telemetry interval", "This option is used to
|
||||
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
|
||||
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
|
||||
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
|
||||
air_quality_screen_enabled, "Air quality screen enabled", "Show air-quality telemetry on the device display."
|
||||
|
||||
[module.canned_message]
|
||||
title, "Canned Message"
|
||||
messages, "Messages", "Pipe-separated canned messages, for example: Hi|On my way|Yes|No."
|
||||
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
|
||||
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
|
||||
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
|
||||
@@ -473,5 +495,52 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
|
||||
title, "Paxcounter"
|
||||
enabled, "Enabled", "Enables the module."
|
||||
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
|
||||
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
|
||||
wifi_threshold, "Wi-Fi threshold", "Wi-Fi RSSI threshold. Defaults to -80."
|
||||
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
|
||||
|
||||
[module.statusmessage]
|
||||
title, "Status Message"
|
||||
node_status, "Node status", ""
|
||||
|
||||
[module.traffic_management]
|
||||
title, "Traffic Management"
|
||||
position_min_interval_secs, "Position minimum interval", "Minimum seconds between position updates from the same node. Set to 0 to disable position-update suppression."
|
||||
nodeinfo_direct_response_max_hops, "NodeInfo direct-response maximum hops", "Maximum hop distance at which cached NodeInfo requests receive a direct response. Set to 0 to disable direct responses."
|
||||
rate_limit_window_secs, "Rate-limit window", "Per-node rate-limit window in seconds. Set to 0 to disable rate limiting."
|
||||
rate_limit_max_packets, "Rate-limit maximum packets", "Maximum packets allowed per node during the rate-limit window. Set to 0 to disable rate limiting."
|
||||
unknown_packet_threshold, "Unknown-packet threshold", "Maximum unknown or undecryptable packets per rate-limit window before packets from that source are dropped. Set to 0 to disable filtering."
|
||||
|
||||
[module.tak]
|
||||
title, "Tak"
|
||||
team, "Team", "Your team color."
|
||||
role, "Role", "Your role in the team."
|
||||
|
||||
[module.mesh_beacon]
|
||||
title, "Mesh Beacon", ""
|
||||
flags, "Beacon options", "Bitwise combination of the listen, broadcast, and legacy-split options."
|
||||
broadcast_send_as_node, "Broadcast as node", "Optional node ID to use as the sender of beacon packets. Leave unset to send as this node."
|
||||
broadcast_message, "Broadcast message", "Message included in each beacon broadcast. Maximum 100 bytes."
|
||||
broadcast_offer_channel, "Offered channel", "Optional channel name and key to advertise in the beacon offer."
|
||||
broadcast_offer_region, "Offered region", "Optional LoRa region to advertise in the beacon offer."
|
||||
broadcast_offer_preset, "Offered modem preset", "Optional modem preset to advertise in the beacon offer."
|
||||
broadcast_on_channel, "Broadcast channel", "Optional channel settings to use when transmitting beacons. Uses the primary channel when unset."
|
||||
broadcast_on_region, "Broadcast region", "Optional LoRa region to use when transmitting beacons."
|
||||
broadcast_on_preset, "Broadcast modem preset", "Optional modem preset to use when transmitting beacons."
|
||||
broadcast_interval_secs, "Broadcast interval", "How often to broadcast beacons in seconds. The minimum and default is 3600 seconds."
|
||||
broadcast_targets, "Broadcast targets", "Optional list of channel, region, and modem-preset targets for beacon broadcasts."
|
||||
|
||||
[module.mesh_beacon.broadcast_offer_channel]
|
||||
title, "Offered channel", ""
|
||||
psk, "PSK", "Encryption key for the offered channel."
|
||||
name, "Name", "Name of the offered channel."
|
||||
|
||||
[module.mesh_beacon.broadcast_on_channel]
|
||||
title, "Broadcast channel", ""
|
||||
psk, "PSK", "Encryption key for the broadcast channel."
|
||||
name, "Name", "Name of the broadcast channel."
|
||||
|
||||
[module.mesh_beacon.broadcast_targets]
|
||||
title, "Broadcast targets", ""
|
||||
preset, "Modem preset", "Optional modem preset for this beacon target."
|
||||
region, "Region", "LoRa region for this beacon target."
|
||||
channel_index, "Channel index", "Index of the configured channel to use for this beacon target."
|
||||
@@ -78,16 +78,10 @@ help.traceroute, "Ctrl+T ou F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Informations complètes du nœud", ""
|
||||
help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", ""
|
||||
help.favorite, "Ctrl+F = Favori", ""
|
||||
help.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", ""
|
||||
help.ignore, "Ctrl+G = Ignorer", ""
|
||||
help.search, "Ctrl+/ ou / = Rechercher", ""
|
||||
help.help, "Ctrl+K = Aide", ""
|
||||
help.no_help, "Aucune aide disponible.", ""
|
||||
bot.status.enabled, "Activé", ""
|
||||
bot.status.disabled, "Désactivé", ""
|
||||
bot.dialog.title, "Bot répondeur", ""
|
||||
bot.dialog.body, "Le bot répondeur est maintenant {status}.", ""
|
||||
bot.status.message, "Le bot répondeur est maintenant {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Utilisateur", ""
|
||||
@@ -118,6 +112,7 @@ ping_bot, "Bot Ping", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Bot Ping", ""
|
||||
enabled, "Activé", "Activer les réponses automatiques du bot Ping."
|
||||
catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules."
|
||||
response_word, "Mot de réponse", "Mot de réponse du bot."
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Полная информация об узле", ""
|
||||
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
||||
help.favorite, "Ctrl+F = Избранное", ""
|
||||
help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", ""
|
||||
help.ignore, "Ctrl+G = Игнорировать", ""
|
||||
help.search, "Ctrl+/ или / = Поиск", ""
|
||||
help.help, "Ctrl+K = Справка", ""
|
||||
@@ -91,11 +90,6 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
|
||||
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
|
||||
dialog.resize_title, "Увеличьте окно", ""
|
||||
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
|
||||
bot.status.enabled, "Включен", ""
|
||||
bot.status.disabled, "Выключен", ""
|
||||
bot.dialog.title, "Автоответчик", ""
|
||||
bot.dialog.body, "Автоответчик теперь {status}.", ""
|
||||
bot.status.message, "Автоответчик теперь {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Пользователь"
|
||||
@@ -129,6 +123,7 @@ COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
|
||||
|
||||
[app_settings.ping_bot]
|
||||
title, "Пинг-бот", ""
|
||||
enabled, "Включен", "Включить автоматические ответы пинг-бота."
|
||||
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
|
||||
response_word, "Ответное слово", "Ответное слово бота."
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import time
|
||||
from typing import Any, Dict
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import app_state, interface_state, ui_state
|
||||
from contact.utilities.shared_state import app_state, interface_state
|
||||
from contact.utilities.utils import get_reply_context
|
||||
from contact.message_handlers.tx_handler import send_message
|
||||
|
||||
BOT_RESPONSE_DELAY_SECONDS = 2.3
|
||||
@@ -24,9 +25,15 @@ def is_bot_message(message: str) -> bool:
|
||||
"""Return True when the incoming message should trigger an automatic response."""
|
||||
return message.strip().casefold() in _get_bot_catch_words()
|
||||
|
||||
|
||||
def is_bot_enabled() -> bool:
|
||||
"""Return whether automatic responses are enabled in app settings."""
|
||||
value = getattr(config, "ping_bot_enabled", False)
|
||||
return value if isinstance(value, bool) else str(value).strip().casefold() == "true"
|
||||
|
||||
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
|
||||
"""Send a basic response when bot mode is enabled."""
|
||||
if not ui_state.bot_mode_enabled:
|
||||
if not is_bot_enabled():
|
||||
return False
|
||||
|
||||
if not is_bot_message(message):
|
||||
@@ -40,7 +47,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
return False
|
||||
snr = packet.get('rxSnr', -128)
|
||||
rssi = packet.get('rxRssi', -128)
|
||||
replyIDset = (packet.get('decoded') or {}).get('replyId', False)
|
||||
incoming_reply_id = (packet.get('decoded') or {}).get('replyId')
|
||||
packet_id = packet.get("id")
|
||||
hop_start = packet.get('hopStart', 0)
|
||||
hop_limit = packet.get('hopLimit', 0)
|
||||
transport_type = packet.get('transportMechanism', None)
|
||||
@@ -53,8 +61,8 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
details.append(f"RSSI: {rssi}")
|
||||
if hops != 0:
|
||||
details.append(f"Hops: {hops}")
|
||||
if replyIDset:
|
||||
details.append(f"Relay: {replyIDset}")
|
||||
if incoming_reply_id:
|
||||
details.append(f"Relay: {incoming_reply_id}")
|
||||
transport_text = str(transport_type).upper() if transport_type is not None else ""
|
||||
for transport_name in ("UDP", "MQTT"):
|
||||
if transport_name in transport_text:
|
||||
@@ -69,10 +77,18 @@ def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool
|
||||
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
|
||||
|
||||
with app_state.lock:
|
||||
if not ui_state.bot_mode_enabled:
|
||||
if not is_bot_enabled():
|
||||
return
|
||||
|
||||
send_message(response_data_string,channel=send_channel)
|
||||
# Use the triggering packet's ID so Meshtastic clients render this
|
||||
# automatic response as a native reply when that ID is available.
|
||||
reply_context = get_reply_context(packet_id) if packet_id is not None else ""
|
||||
send_message(
|
||||
response_data_string,
|
||||
channel=send_channel,
|
||||
reply_id=packet_id,
|
||||
reply_context=reply_context,
|
||||
)
|
||||
|
||||
# Import locally to avoid circular import at module import time.
|
||||
from contact.ui.contact_ui import request_ui_redraw
|
||||
|
||||
@@ -60,43 +60,45 @@ 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
|
||||
|
||||
|
||||
def play_sound():
|
||||
try:
|
||||
system = platform.system()
|
||||
sound_path = None
|
||||
executable = None
|
||||
selected_sound = str(getattr(config, "notification_sound", "None"))
|
||||
if selected_sound.casefold() in {"none", "false", ""}:
|
||||
return
|
||||
sound_path = os.path.join(config.parent_dir, "sounds", selected_sound)
|
||||
if not os.path.isfile(sound_path):
|
||||
logging.warning("Configured notification sound is unavailable: %s", sound_path)
|
||||
return
|
||||
|
||||
if system == "Darwin": # macOS
|
||||
sound_path = "/System/Library/Sounds/Ping.aiff"
|
||||
executable = "afplay"
|
||||
|
||||
elif system == "Linux":
|
||||
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
|
||||
|
||||
if shutil.which("paplay") and os.path.exists(ogg_path):
|
||||
executable = "paplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("ffplay") and os.path.exists(ogg_path):
|
||||
executable = "ffplay"
|
||||
sound_path = ogg_path
|
||||
elif shutil.which("aplay") and os.path.exists(wav_path):
|
||||
executable = "aplay"
|
||||
sound_path = wav_path
|
||||
else:
|
||||
logging.warning("No suitable sound player or sound file found on Linux")
|
||||
executable = "afplay" if platform.system() == "Darwin" else next(
|
||||
(shutil.which(player) for player in ("ffplay", "mpg123", "mpv", "cvlc", "paplay") if shutil.which(player)),
|
||||
None,
|
||||
)
|
||||
|
||||
if executable and sound_path:
|
||||
player = os.path.basename(executable)
|
||||
cmd = [executable, sound_path]
|
||||
if executable == "ffplay":
|
||||
if player == "ffplay":
|
||||
cmd = [executable, "-nodisp", "-autoexit", sound_path]
|
||||
elif player == "mpv":
|
||||
cmd = [executable, "--no-video", sound_path]
|
||||
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."
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.error(f"Sound playback failed: {e}")
|
||||
@@ -138,13 +140,17 @@ 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 config.notification_sound == "True":
|
||||
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
|
||||
schedule_notification_sound()
|
||||
|
||||
message_bytes = packet["decoded"]["payload"]
|
||||
@@ -192,6 +198,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:
|
||||
@@ -209,6 +216,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||
message_string,
|
||||
packet_id=packet.get("id"),
|
||||
reply_id=reply_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
except KeyError as e:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import time
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import google.protobuf.json_format
|
||||
from meshtastic import BROADCAST_NUM
|
||||
@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
|
||||
)
|
||||
import contact.ui.default_config as config
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||
from contact.utilities.shared_state import ui_state, interface_state, app_state
|
||||
|
||||
from contact.utilities.utils import add_new_message
|
||||
|
||||
@@ -168,7 +168,7 @@ def send_message(
|
||||
message: str,
|
||||
destination: int = BROADCAST_NUM,
|
||||
channel: int = 0,
|
||||
reply_id: int | None = None,
|
||||
reply_id: Optional[int] = None,
|
||||
reply_context: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
|
||||
Powered by Meshtastic.org
|
||||
|
||||
Meshtastic® is a registered trademark of Meshtastic LLC.
|
||||
Meshtastic software components are released under various licenses—see GitHub for details.
|
||||
No warranty is provided. Use at your own risk.
|
||||
"""
|
||||
|
||||
# Standard library
|
||||
import contextlib
|
||||
import curses
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
# Third-party
|
||||
from pubsub import pub
|
||||
|
||||
# Local application
|
||||
import contact.ui.default_config as config
|
||||
from contact.message_handlers.rx_handler import on_receive
|
||||
from contact.ui.control_ui import set_region
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.contact_ui import main_ui
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
|
||||
from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
from contact.utilities.logging_utils import configure_logging
|
||||
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
|
||||
from contact.utilities.shared_state import ui_state, interface_state, app_state
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Environment & Logging Setup
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
os.environ["NCURSES_NO_UTF8_ACS"] = "1"
|
||||
os.environ["LANG"] = "C.UTF-8"
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
if os.environ.get("COLORTERM") == "gnome-terminal":
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
configure_logging(logging.INFO)
|
||||
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main Program Logic
|
||||
# ------------------------------------------------------------------------------
|
||||
def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> None:
|
||||
"""Prompt user to set region if it is unset."""
|
||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||
if confirmation == "Yes":
|
||||
set_region(interface_state.interface)
|
||||
close_interface(interface_state.interface)
|
||||
if stdscr is not None:
|
||||
draw_splash(stdscr)
|
||||
interface_state.interface = reconnect_interface(args)
|
||||
|
||||
|
||||
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
|
||||
if interface is None:
|
||||
return True
|
||||
|
||||
close_errors = []
|
||||
|
||||
def _close_target() -> None:
|
||||
try:
|
||||
interface.close()
|
||||
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
|
||||
close_errors.append(error)
|
||||
|
||||
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
|
||||
close_thread.start()
|
||||
close_thread.join(timeout_seconds)
|
||||
|
||||
if close_thread.is_alive():
|
||||
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
|
||||
return False
|
||||
|
||||
if not close_errors:
|
||||
return True
|
||||
|
||||
error = close_errors[0]
|
||||
if isinstance(error, KeyboardInterrupt):
|
||||
logging.info("Interrupted while closing interface; continuing shutdown")
|
||||
return True
|
||||
|
||||
logging.warning("Ignoring error while closing interface: %r", error)
|
||||
return True
|
||||
|
||||
|
||||
def interface_is_ready(interface: object) -> bool:
|
||||
try:
|
||||
return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||
while True:
|
||||
interface = initialize_runtime_interface(args, stdscr)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
choice = get_list_input(
|
||||
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
|
||||
"Retry",
|
||||
["Retry", "Close"],
|
||||
mandatory=True,
|
||||
)
|
||||
close_interface(interface)
|
||||
if choice == "Close":
|
||||
return None
|
||||
|
||||
draw_splash(stdscr)
|
||||
|
||||
|
||||
def initialize_globals(seed_demo: bool = False) -> None:
|
||||
"""Initializes interface and shared globals."""
|
||||
|
||||
ui_state.channel_list = []
|
||||
ui_state.all_messages = {}
|
||||
ui_state.notifications = []
|
||||
ui_state.packet_buffer = []
|
||||
ui_state.node_list = []
|
||||
ui_state.selected_channel = 0
|
||||
ui_state.selected_message = 0
|
||||
ui_state.selected_node = 0
|
||||
ui_state.start_index = [0, 0, 0]
|
||||
interface_state.myNodeNum = get_nodeNum()
|
||||
ui_state.channel_list = get_channels()
|
||||
ui_state.node_list = get_node_list()
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
pub.subscribe(on_receive, "meshtastic.receive")
|
||||
|
||||
init_nodedb()
|
||||
if seed_demo:
|
||||
seed_demo_messages()
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object, stdscr=None):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
if stdscr is not None:
|
||||
return initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
def main(stdscr: curses.window, args: object) -> None:
|
||||
"""Main entry point for the curses UI."""
|
||||
|
||||
app_state.connection_args = args
|
||||
output_capture = io.StringIO()
|
||||
try:
|
||||
setup_colors()
|
||||
ensure_min_rows(stdscr)
|
||||
draw_splash(stdscr)
|
||||
|
||||
logging.info("Initializing interface...")
|
||||
with app_state.lock:
|
||||
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
|
||||
if interface_state.interface is None:
|
||||
return
|
||||
|
||||
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||
prompt_region_if_unset(args, stdscr)
|
||||
|
||||
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
|
||||
logging.info("Starting main UI")
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
main_ui(stdscr)
|
||||
except Exception:
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("Uncaught exception inside main_ui")
|
||||
logging.error("Traceback:\n%s", traceback.format_exc())
|
||||
logging.error("Console output:\n%s", console_output)
|
||||
return
|
||||
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
while True:
|
||||
rows, _ = stdscr.getmaxyx()
|
||||
if rows >= min_rows:
|
||||
return
|
||||
dialog(
|
||||
t("ui.dialog.resize_title", default="Resize Terminal"),
|
||||
t(
|
||||
"ui.dialog.resize_body",
|
||||
default="Please resize the terminal to at least {rows} rows.",
|
||||
rows=min_rows,
|
||||
),
|
||||
)
|
||||
curses.update_lines_cols()
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
def start(args: object) -> None:
|
||||
"""Entry point for the application."""
|
||||
|
||||
interrupted = False
|
||||
fatal_error = None
|
||||
|
||||
try:
|
||||
curses.wrapper(main, args)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
logging.info("User exited with Ctrl+C")
|
||||
except Exception as e:
|
||||
fatal_error = e
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
try:
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
close_interface(interface_state.interface)
|
||||
|
||||
if fatal_error is not None:
|
||||
print("Fatal error:", fatal_error)
|
||||
traceback.print_exception(type(fatal_error), fatal_error, fatal_error.__traceback__)
|
||||
sys.exit(1)
|
||||
|
||||
if interrupted:
|
||||
sys.exit(0)
|
||||
|
||||
+6
-98
@@ -1,106 +1,14 @@
|
||||
import contextlib
|
||||
import curses
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
"""Standalone command-line entry point for node settings."""
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui.colors import setup_colors
|
||||
from contact.ui.control_ui import set_region, settings_menu
|
||||
from contact.ui.dialog import dialog
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
|
||||
|
||||
def close_interface(interface: object) -> None:
|
||||
if interface is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
interface.close()
|
||||
def start(argv=None) -> None:
|
||||
args = setup_parser().parse_args(argv)
|
||||
from contact.settings_runtime import start as start_runtime
|
||||
|
||||
start_runtime(args)
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
output_capture = io.StringIO()
|
||||
interface = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
setup_colors()
|
||||
ensure_min_rows(stdscr)
|
||||
draw_splash(stdscr)
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
interface = initialize_interface(args)
|
||||
|
||||
if interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input(
|
||||
t("ui.confirm.region_unset", default="Your region is UNSET. Set it now?"),
|
||||
"Yes",
|
||||
["Yes", "No"],
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
close_interface(interface)
|
||||
draw_splash(stdscr)
|
||||
interface = reconnect_interface(args)
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
settings_menu(stdscr, interface)
|
||||
|
||||
except Exception as e:
|
||||
console_output = output_capture.getvalue()
|
||||
logging.error("An error occurred: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
logging.error("Console output before crash:\n%s", console_output)
|
||||
raise
|
||||
finally:
|
||||
close_interface(interface)
|
||||
|
||||
|
||||
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
|
||||
while True:
|
||||
rows, _ = stdscr.getmaxyx()
|
||||
if rows >= min_rows:
|
||||
return
|
||||
dialog(
|
||||
t("ui.dialog.resize_title", default="Resize Terminal"),
|
||||
t(
|
||||
"ui.dialog.resize_body",
|
||||
default="Please resize the terminal to at least {rows} rows.",
|
||||
rows=min_rows,
|
||||
),
|
||||
)
|
||||
curses.update_lines_cols()
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
|
||||
|
||||
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
|
||||
filename=config.log_file_path,
|
||||
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
log_file = config.log_file_path
|
||||
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
|
||||
|
||||
sys.stdout = log_f
|
||||
sys.stderr = log_f
|
||||
|
||||
with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f):
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging
|
||||
sys.exit(0) # Ensure a clean exit
|
||||
except Exception as e:
|
||||
logging.error("Fatal error in curses wrapper: %s", e)
|
||||
logging.error("Traceback: %s", traceback.format_exc())
|
||||
sys.exit(1) # Exit with an error code
|
||||
start()
|
||||
@@ -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)
|
||||
Binary file not shown.
+514
-111
@@ -1,5 +1,7 @@
|
||||
import curses
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
import time
|
||||
import traceback
|
||||
from numbers import Real
|
||||
@@ -10,15 +12,17 @@ from contact.utilities.utils import (
|
||||
get_readable_duration,
|
||||
get_time_ago,
|
||||
refresh_node_list,
|
||||
add_new_message,
|
||||
build_reply_prefix,
|
||||
)
|
||||
from contact.settings import settings_menu
|
||||
from contact.ui.control_ui import settings_menu
|
||||
from contact.ui.control_ui import RemoteAdminCancelled, verify_remote_admin
|
||||
from contact.ui.user_config import load_log_tail
|
||||
from contact.message_handlers.tx_handler import send_message, send_traceroute
|
||||
from contact.utilities.utils import parse_protobuf
|
||||
from contact.ui.colors import get_color
|
||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived, load_older_messages
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import interface_is_connected
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.emoji_utils import normalize_message_text
|
||||
import contact.ui.default_config as config
|
||||
@@ -29,15 +33,127 @@ from contact.ui.nav_utils import (
|
||||
get_msg_window_lines,
|
||||
wrap_text,
|
||||
truncate_with_ellipsis,
|
||||
pad_to_width,
|
||||
text_width,
|
||||
slice_to_width,
|
||||
)
|
||||
from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state
|
||||
from contact.utilities.shared_state import ui_state, interface_state, menu_state, app_state
|
||||
|
||||
|
||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||
RESIZE_DEBOUNCE_MS = 250
|
||||
root_win = None
|
||||
nodes_pad = None
|
||||
entry_win = None
|
||||
input_text = ""
|
||||
|
||||
|
||||
def refresh_log_viewer() -> None:
|
||||
"""Refresh the in-app log overlay only when the log file changes."""
|
||||
try:
|
||||
stat = os.stat(config.log_file_path)
|
||||
signature = (stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
signature = None
|
||||
|
||||
if not ui_state.log_viewer_loaded or signature != ui_state.log_viewer_signature:
|
||||
ui_state.log_viewer_lines = load_log_tail(config.log_file_path)
|
||||
ui_state.log_viewer_signature = signature
|
||||
ui_state.log_viewer_loaded = True
|
||||
|
||||
|
||||
def flush_log_handlers() -> None:
|
||||
"""Ensure a just-written status event is visible to the log tailer."""
|
||||
for handler in logging.getLogger().handlers:
|
||||
try:
|
||||
handler.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def draw_log_viewer(stdscr: curses.window) -> int:
|
||||
"""Draw the log overlay and return the number of visible log rows."""
|
||||
refresh_log_viewer()
|
||||
height, width = stdscr.getmaxyx()
|
||||
content_height = max(1, height - 4)
|
||||
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
|
||||
if ui_state.log_viewer_follow:
|
||||
ui_state.log_viewer_start_line = max_start
|
||||
else:
|
||||
ui_state.log_viewer_start_line = min(ui_state.log_viewer_start_line, max_start)
|
||||
|
||||
try:
|
||||
stdscr.erase()
|
||||
stdscr.bkgd(get_color("background"))
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.border()
|
||||
state = "Following" if ui_state.log_viewer_follow else "Paused"
|
||||
title = " View Log — {} ".format(state)
|
||||
stdscr.addstr(0, 2, title[: max(0, width - 4)], get_color("settings_breadcrumbs", bold=True))
|
||||
for row, line in enumerate(
|
||||
ui_state.log_viewer_lines[ui_state.log_viewer_start_line : ui_state.log_viewer_start_line + content_height],
|
||||
start=1,
|
||||
):
|
||||
stdscr.addstr(row, 1, line[: max(0, width - 2)], get_color("settings_default"))
|
||||
hint = " Esc Close Up/Down Scroll PgUp/PgDn Page F Follow "
|
||||
stdscr.addstr(height - 2, 1, hint[: max(0, width - 2)], get_color("commands"))
|
||||
stdscr.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
return content_height
|
||||
|
||||
|
||||
def handle_log_viewer_key(key: int, content_height: int) -> bool:
|
||||
"""Handle a log-overlay key. Returns True when the overlay closes."""
|
||||
if ui_state.reconnect_prompt_open:
|
||||
if key in (ord("r"), ord("R")):
|
||||
ui_state.reconnect_prompt_open = False
|
||||
ui_state.reconnect_attempted = False
|
||||
elif key in (ord("c"), ord("C"), 27):
|
||||
ui_state.reconnect_prompt_open = False
|
||||
return False
|
||||
|
||||
if key == 27:
|
||||
ui_state.log_viewer_open = False
|
||||
return True
|
||||
if key in (ord("f"), ord("F")):
|
||||
ui_state.log_viewer_follow = not ui_state.log_viewer_follow
|
||||
return False
|
||||
|
||||
max_start = max(0, len(ui_state.log_viewer_lines) - content_height)
|
||||
if key == curses.KEY_UP:
|
||||
ui_state.log_viewer_follow = False
|
||||
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - 1)
|
||||
elif key == curses.KEY_DOWN:
|
||||
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + 1)
|
||||
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
|
||||
elif key == curses.KEY_PPAGE:
|
||||
ui_state.log_viewer_follow = False
|
||||
ui_state.log_viewer_start_line = max(0, ui_state.log_viewer_start_line - content_height)
|
||||
elif key == curses.KEY_NPAGE:
|
||||
ui_state.log_viewer_start_line = min(max_start, ui_state.log_viewer_start_line + content_height)
|
||||
ui_state.log_viewer_follow = ui_state.log_viewer_start_line >= max_start
|
||||
return False
|
||||
|
||||
|
||||
def draw_log_reconnect_prompt(stdscr: curses.window) -> None:
|
||||
"""Draw a non-blocking reconnect choice over the live log overlay."""
|
||||
if not ui_state.reconnect_prompt_open:
|
||||
return
|
||||
try:
|
||||
height, width = stdscr.getmaxyx()
|
||||
message = "Could not reconnect after 20 seconds."
|
||||
hint = "R Retry C Cancel"
|
||||
box_width = min(width - 4, max(len(message) + 4, len(hint) + 4, 42))
|
||||
win = curses.newwin(6, box_width, max(0, (height - 6) // 2), max(0, (width - box_width) // 2))
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border()
|
||||
win.addstr(0, 2, " Disconnected ", get_color("settings_default"))
|
||||
win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
|
||||
win.addstr(4, 2, hint[: box_width - 4], get_color("commands"))
|
||||
win.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def request_ui_redraw(
|
||||
@@ -115,24 +231,15 @@ def draw_window_arrows(window_id: int) -> None:
|
||||
nodes_win.refresh()
|
||||
|
||||
|
||||
def compute_widths(total_w: int, focus: int):
|
||||
# focus: 0=channel, 1=messages, 2=nodes
|
||||
if total_w < 3 * MIN_COL:
|
||||
# tiny terminals: allocate something, anything
|
||||
return max(1, total_w), 0, 0
|
||||
|
||||
if focus == 0:
|
||||
return total_w - 2 * MIN_COL, MIN_COL, MIN_COL
|
||||
if focus == 1:
|
||||
return MIN_COL, total_w - 2 * MIN_COL, MIN_COL
|
||||
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
|
||||
|
||||
|
||||
def paint_frame(win, selected: bool) -> None:
|
||||
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
|
||||
if win is not entry_win and ui_state.input_focused:
|
||||
selected = False
|
||||
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
|
||||
win.box()
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.refresh()
|
||||
if refresh:
|
||||
win.refresh()
|
||||
|
||||
|
||||
def get_channel_row_color(index: int) -> int:
|
||||
@@ -155,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
|
||||
|
||||
@@ -174,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:
|
||||
@@ -186,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)
|
||||
@@ -377,16 +485,73 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
root_win = stdscr
|
||||
input_text = ""
|
||||
ui_state.input_cursor = 0
|
||||
ui_state.input_focused = False
|
||||
queued_char = None
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
handle_resize(stdscr, True)
|
||||
|
||||
while True:
|
||||
interface = interface_state.interface
|
||||
if not interface_is_connected(interface) and not ui_state.reconnect_attempted:
|
||||
ui_state.reconnect_attempted = True
|
||||
logging.warning("Meshtastic connection disconnected; attempting automatic reconnect")
|
||||
flush_log_handlers()
|
||||
if ui_state.log_viewer_open:
|
||||
# Reconnect is synchronous. Repaint once before entering it so
|
||||
# the disconnect event remains visible during the attempt.
|
||||
draw_log_viewer(stdscr)
|
||||
status_win = None
|
||||
if not ui_state.log_viewer_open:
|
||||
status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…")
|
||||
try:
|
||||
from contact.ui.control_ui import reconnect_interface_with_splash
|
||||
|
||||
reconnect_interface_with_splash(stdscr, interface)
|
||||
if status_win is not None:
|
||||
status_win.erase()
|
||||
status_win.refresh()
|
||||
ui_state.reconnect_attempted = False
|
||||
if not ui_state.log_viewer_open:
|
||||
handle_resize(stdscr, False)
|
||||
except Exception:
|
||||
if status_win is not None:
|
||||
status_win.erase()
|
||||
status_win.refresh()
|
||||
if not ui_state.log_viewer_open:
|
||||
handle_resize(stdscr, False)
|
||||
logging.exception("Automatic reconnect after transport disconnect failed")
|
||||
if ui_state.log_viewer_open:
|
||||
ui_state.reconnect_prompt_open = True
|
||||
continue
|
||||
retry = get_list_input(
|
||||
"Contact could not reconnect after 20 seconds.",
|
||||
"Retry",
|
||||
["Retry", "Cancel"],
|
||||
mandatory=True,
|
||||
)
|
||||
if retry == "Retry":
|
||||
ui_state.reconnect_attempted = False
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
if ui_state.log_viewer_open:
|
||||
content_height = draw_log_viewer(stdscr)
|
||||
draw_log_reconnect_prompt(stdscr)
|
||||
stdscr.timeout(200)
|
||||
try:
|
||||
key = stdscr.getch()
|
||||
except curses.error:
|
||||
continue
|
||||
if key != -1 and handle_log_viewer_key(key, content_height):
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
|
||||
with app_state.lock:
|
||||
process_pending_ui_updates(stdscr)
|
||||
entry_display = f"{ui_state.reply_context}{input_text or ''}"
|
||||
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
draw_message_input(input_text)
|
||||
|
||||
# Get user input from entry window
|
||||
try:
|
||||
@@ -426,6 +591,7 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||
input_text = handle_enter(input_text)
|
||||
ui_state.input_cursor = min(ui_state.input_cursor, len(input_text))
|
||||
|
||||
elif char in (curses.KEY_F4, chr(20)): # Ctrl + t and F4 for Traceroute
|
||||
handle_ctrl_t(stdscr)
|
||||
@@ -433,9 +599,14 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == curses.KEY_F5:
|
||||
handle_f5_key(stdscr)
|
||||
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127), chr(8)):
|
||||
input_text = handle_backspace(entry_win, input_text)
|
||||
|
||||
elif char == curses.KEY_DC:
|
||||
focus_message_input()
|
||||
position = ui_state.input_cursor
|
||||
input_text = input_text[:position] + input_text[position + 1:]
|
||||
|
||||
elif char in (curses.KEY_F12, "`"): # ` Launch the settings interface
|
||||
handle_backtick(stdscr)
|
||||
|
||||
@@ -449,7 +620,6 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
entry_win.erase()
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
queued_char = drain_resize_events(entry_win)
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
@@ -468,9 +638,6 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == chr(6): # Ctrl + F to toggle favorite
|
||||
handle_ctrl_f(stdscr)
|
||||
|
||||
elif char == chr(2): # Ctrl + B to toggle bot responder
|
||||
handle_ctrl_b(stdscr)
|
||||
|
||||
elif char == chr(7): # Ctrl + G to toggle ignored
|
||||
handle_ctlr_g(stdscr)
|
||||
|
||||
@@ -478,15 +645,88 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
break
|
||||
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if isinstance(char, str):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
if isinstance(char, str) and char.isprintable():
|
||||
input_text = insert_input_text(input_text, char)
|
||||
|
||||
|
||||
def focus_message_input() -> None:
|
||||
if ui_state.input_focused:
|
||||
return
|
||||
# Keep the conversation visible in single-pane mode while composing.
|
||||
handle_function_keys(curses.KEY_F2)
|
||||
ui_state.input_focused = True
|
||||
refresh_message_highlight()
|
||||
refresh_main_window(1, selected=False)
|
||||
|
||||
|
||||
def leave_message_input() -> None:
|
||||
ui_state.input_focused = False
|
||||
ui_state.current_window = 1
|
||||
refresh_message_highlight()
|
||||
refresh_main_window(1, selected=True)
|
||||
|
||||
|
||||
def insert_input_text(draft: str, text: str) -> str:
|
||||
focus_message_input()
|
||||
position = min(ui_state.input_cursor, len(draft))
|
||||
ui_state.input_cursor = position + len(text)
|
||||
return draft[:position] + text + draft[position:]
|
||||
|
||||
|
||||
def draw_message_input(draft: str) -> None:
|
||||
"""Redraw the whole input row and scroll it to keep the draft cursor visible."""
|
||||
# Получаем сразу высоту и ширину для правильного позиционирования счетчика
|
||||
height, width = entry_win.getmaxyx()
|
||||
prompt = slice_to_width("Message: ", max(0, width - 3))
|
||||
available = max(1, width - 2 - text_width(prompt))
|
||||
ui_state.input_cursor = max(0, min(ui_state.input_cursor, len(draft)))
|
||||
content = ui_state.reply_context + draft
|
||||
|
||||
# Считаем именно байты в UTF-8 (важно для кириллицы)
|
||||
byte_count = len(content.encode('utf-8'))
|
||||
counter_str = f" {byte_count}/220b "
|
||||
|
||||
cursor = len(ui_state.reply_context) + ui_state.input_cursor
|
||||
start = cursor
|
||||
used = 0
|
||||
while start > 0 and used + text_width(content[start - 1]) < available:
|
||||
start -= 1
|
||||
used += text_width(content[start])
|
||||
visible = slice_to_width(content[start:], available)
|
||||
draw_text_field(entry_win, prompt + visible, get_color("input"))
|
||||
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
|
||||
|
||||
try:
|
||||
hint = " Ctrl+K Help "
|
||||
if width >= len(hint) + 4:
|
||||
entry_win.addstr(0, width - len(hint) - 2, hint, get_color("commands"))
|
||||
|
||||
# меняем цвет, если превышен лимит
|
||||
if byte_count > 220:
|
||||
try:
|
||||
# Используем settings_warning с добавлением жирности шрифта
|
||||
counter_color = get_color("settings_warning", bold=True)
|
||||
except:
|
||||
counter_color = get_color("commands", bold=True, reverse=True)
|
||||
else:
|
||||
counter_color = get_color("commands")
|
||||
|
||||
# Отрисовка счетчика внизу с защитой от краша (height > 2)
|
||||
if width >= len(counter_str) + 4 and height > 2:
|
||||
entry_win.addstr(height - 1, width - len(counter_str) - 2, counter_str, counter_color)
|
||||
|
||||
curses.curs_set(1 if ui_state.input_focused else 0)
|
||||
entry_win.move(1, min(width - 2, 1 + text_width(prompt) + used))
|
||||
except curses.error:
|
||||
pass
|
||||
entry_win.refresh()
|
||||
|
||||
|
||||
def handle_up() -> None:
|
||||
"""Handle key up events to scroll the current window."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
scroll_channels(-1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -497,6 +737,9 @@ def handle_up() -> None:
|
||||
|
||||
def handle_down() -> None:
|
||||
"""Handle key down events to scroll the current window."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
scroll_channels(1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -507,6 +750,9 @@ def handle_down() -> None:
|
||||
|
||||
def handle_home() -> None:
|
||||
"""Handle home key events to select the first item in the current window."""
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = 0
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(0)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -521,6 +767,9 @@ def handle_home() -> None:
|
||||
|
||||
def handle_end() -> None:
|
||||
"""Handle end key events to select the last item in the current window."""
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = len(input_text)
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(len(ui_state.channel_list) - 1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -561,6 +810,9 @@ def handle_pagedown() -> None:
|
||||
def handle_leftright(char: int) -> None:
|
||||
"""Handle left/right key events to switch between windows."""
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = max(0, min(len(input_text), ui_state.input_cursor + delta))
|
||||
return
|
||||
old_window = ui_state.current_window
|
||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||
if ui_state.single_pane_mode:
|
||||
@@ -581,6 +833,8 @@ def handle_leftright(char: int) -> None:
|
||||
|
||||
def handle_function_keys(char: int) -> None:
|
||||
"""Switch windows using F1/F2/F3."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
if char == curses.KEY_F1:
|
||||
target = 0
|
||||
elif char == curses.KEY_F2:
|
||||
@@ -655,12 +909,27 @@ def handle_enter(input_text: str) -> str:
|
||||
)
|
||||
return input_text
|
||||
# Enter key pressed, send user input as message
|
||||
send_message(
|
||||
input_text,
|
||||
channel=ui_state.selected_channel,
|
||||
reply_id=ui_state.reply_id,
|
||||
reply_context=ui_state.reply_context,
|
||||
)
|
||||
try:
|
||||
send_message(
|
||||
input_text,
|
||||
channel=ui_state.selected_channel,
|
||||
reply_id=ui_state.reply_id,
|
||||
reply_context=ui_state.reply_context,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.warning("Message send failed; reconnecting interface: %s", exc)
|
||||
try:
|
||||
from contact.ui.control_ui import reconnect_interface_with_splash
|
||||
|
||||
reconnect_interface_with_splash(root_win, interface_state.interface)
|
||||
contact.ui.dialog.dialog("Reconnected", "Connection restored. Your message was not sent; press Enter to retry.")
|
||||
except Exception as reconnect_exc:
|
||||
logging.exception("Automatic reconnect failed")
|
||||
contact.ui.dialog.dialog(
|
||||
"Reconnect failed",
|
||||
"Contact could not reconnect after 20 seconds. Check the radio connection and retry your message.",
|
||||
)
|
||||
return input_text
|
||||
ui_state.reply_id = None
|
||||
ui_state.reply_context = ""
|
||||
ui_state.reply_id_unavailable = False
|
||||
@@ -676,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
|
||||
@@ -688,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')}")
|
||||
|
||||
@@ -773,6 +1067,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)
|
||||
@@ -792,11 +1087,15 @@ 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:
|
||||
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)
|
||||
|
||||
@@ -841,7 +1140,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
dialog_win.refresh()
|
||||
dialog_win.noutrefresh()
|
||||
msg_win.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
@@ -861,12 +1160,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:
|
||||
@@ -925,14 +1224,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||
|
||||
|
||||
def handle_backspace(entry_win: curses.window, input_text: str) -> str:
|
||||
"""Handle backspace key events to remove the last character from input text."""
|
||||
if input_text:
|
||||
input_text = input_text[:-1]
|
||||
y, x = entry_win.getyx()
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.addch(" ") #
|
||||
entry_win.move(y, x - 1)
|
||||
entry_win.refresh()
|
||||
"""Delete before the draft cursor; the next frame redraws the entire line."""
|
||||
focus_message_input()
|
||||
position = ui_state.input_cursor
|
||||
if position > 0:
|
||||
input_text = input_text[:position - 1] + input_text[position:]
|
||||
ui_state.input_cursor -= 1
|
||||
return input_text
|
||||
|
||||
|
||||
@@ -941,7 +1238,94 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
previous_window = ui_state.current_window
|
||||
ui_state.current_window = 4
|
||||
settings_menu(stdscr, interface_state.interface)
|
||||
interface = interface_state.interface
|
||||
if previous_window != 2:
|
||||
settings_menu(stdscr, interface)
|
||||
ui_state.current_window = previous_window
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
curses.curs_set(1)
|
||||
get_channels()
|
||||
refresh_node_list()
|
||||
handle_resize(stdscr, False)
|
||||
return
|
||||
|
||||
selected_node_num = None
|
||||
if ui_state.node_list:
|
||||
selected_node_num = ui_state.node_list[ui_state.selected_node]
|
||||
|
||||
# The local node is already the target for normal settings; do not offer a
|
||||
# misleading remote-admin choice when it is selected in the node pane.
|
||||
if selected_node_num == interface_state.myNodeNum:
|
||||
settings_menu(stdscr, interface)
|
||||
else:
|
||||
options = ["Local settings"]
|
||||
remote_node_num = None
|
||||
if selected_node_num is not None:
|
||||
remote_node_num = selected_node_num
|
||||
options.append(f"Remote admin: {get_name_from_database(remote_node_num)}")
|
||||
choice = get_list_input("Open settings for", None, options)
|
||||
if choice == "Local settings":
|
||||
settings_menu(stdscr, interface)
|
||||
elif remote_node_num is not None:
|
||||
wait_win = None
|
||||
|
||||
def remote_status(message: Optional[str]) -> None:
|
||||
nonlocal wait_win
|
||||
if message is None:
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
wait_win = None
|
||||
return
|
||||
if wait_win is None:
|
||||
wait_win = show_remote_admin_wait(stdscr, get_name_from_database(remote_node_num))
|
||||
update_remote_admin_wait(wait_win, message)
|
||||
|
||||
def remote_cancel_requested() -> bool:
|
||||
if wait_win is None:
|
||||
return False
|
||||
try:
|
||||
wait_win.timeout(0)
|
||||
return wait_win.getch() == 27
|
||||
finally:
|
||||
wait_win.timeout(-1)
|
||||
|
||||
try:
|
||||
remote_node = interface.getNode(remote_node_num, False)
|
||||
verify_remote_admin(
|
||||
remote_node,
|
||||
status_callback=remote_status,
|
||||
cancel_callback=remote_cancel_requested,
|
||||
)
|
||||
remote_status(None)
|
||||
settings_menu(
|
||||
stdscr,
|
||||
interface,
|
||||
# Settings requests configs explicitly; avoid getNode's
|
||||
# implicit channel request racing that setup.
|
||||
node=remote_node,
|
||||
remote=True,
|
||||
status_callback=remote_status,
|
||||
cancel_callback=remote_cancel_requested,
|
||||
)
|
||||
except RemoteAdminCancelled:
|
||||
logging.info("Remote admin request cancelled for %s", remote_node_num)
|
||||
remote_status(None)
|
||||
except (SystemExit, PermissionError) as exc:
|
||||
logging.warning("Remote admin was rejected for %s: %s", remote_node_num, exc)
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
contact.ui.dialog.dialog(
|
||||
"Remote admin rejected",
|
||||
"The selected node did not authorize this admin request.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("Remote admin failed for %s", remote_node_num)
|
||||
if wait_win is not None:
|
||||
wait_win.erase()
|
||||
wait_win.refresh()
|
||||
contact.ui.dialog.dialog("Remote admin failed", str(exc))
|
||||
ui_state.current_window = previous_window
|
||||
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||
curses.curs_set(1)
|
||||
@@ -950,6 +1334,55 @@ def handle_backtick(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> Optional[curses.window]:
|
||||
"""Show progress while Meshtastic retrieves remote-admin settings."""
|
||||
message = f"Attempting remote admin of {node_name}, waiting for response..."
|
||||
try:
|
||||
height, width = stdscr.getmaxyx()
|
||||
box_width = min(width - 4, max(len(message) + 4, 36))
|
||||
box_height = 5
|
||||
wait_win = curses.newwin(box_height, box_width, max(0, (height - box_height) // 2), max(0, (width - box_width) // 2))
|
||||
wait_win.bkgd(get_color("background"))
|
||||
wait_win.attrset(get_color("window_frame"))
|
||||
wait_win.border()
|
||||
wait_win.addstr(0, 2, " Remote Admin ", get_color("settings_default"))
|
||||
wait_win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
|
||||
wait_win.refresh()
|
||||
return wait_win
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
|
||||
def show_connection_status(stdscr: curses.window, title: str, message: str) -> Optional[curses.window]:
|
||||
"""Display a non-blocking connection-status dialog over the current UI."""
|
||||
try:
|
||||
height, width = stdscr.getmaxyx()
|
||||
box_width = min(width - 4, max(len(message) + 4, len(title) + 6, 32))
|
||||
win = curses.newwin(5, box_width, max(0, (height - 5) // 2), max(0, (width - box_width) // 2))
|
||||
win.bkgd(get_color("background"))
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.border()
|
||||
win.addstr(0, 2, f" {title} ", get_color("settings_default"))
|
||||
win.addstr(2, 2, message[: box_width - 4], get_color("settings_default"))
|
||||
win.refresh()
|
||||
return win
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
|
||||
def update_remote_admin_wait(wait_win: Optional[curses.window], message: str) -> None:
|
||||
"""Append the current remote-admin request to the waiting overlay."""
|
||||
if wait_win is None:
|
||||
return
|
||||
try:
|
||||
_, width = wait_win.getmaxyx()
|
||||
wait_win.addstr(3, 2, " " * (width - 4), get_color("settings_default"))
|
||||
wait_win.addstr(3, 2, message[: width - 4], get_color("settings_default"))
|
||||
wait_win.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def handle_ctrl_p() -> None:
|
||||
"""Handle Ctrl + P key events to toggle the packet log display."""
|
||||
# Display packet log
|
||||
@@ -1023,7 +1456,6 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
t("ui.help.node_info", default="F5 = Full node info"),
|
||||
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
|
||||
t("ui.help.favorite", default="Ctrl+F = Favorite"),
|
||||
t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"),
|
||||
t("ui.help.ignore", default="Ctrl+G = Ignore"),
|
||||
t("ui.help.search", default="Ctrl+/ = Search"),
|
||||
t("ui.help.help", default="Ctrl+K = Help"),
|
||||
@@ -1035,32 +1467,6 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_b(stdscr: curses.window) -> None:
|
||||
"""Handle Ctrl + B key events to toggle automatic bot responses."""
|
||||
ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled
|
||||
status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t(
|
||||
"ui.bot.status.disabled", default="Disabled"
|
||||
)
|
||||
|
||||
curses.curs_set(0)
|
||||
contact.ui.dialog.dialog(
|
||||
t("ui.bot.dialog.title", default="Bot Responder"),
|
||||
t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status),
|
||||
)
|
||||
|
||||
if ui_state.channel_list:
|
||||
channel_id = ui_state.channel_list[ui_state.selected_channel]
|
||||
add_new_message(
|
||||
channel_id,
|
||||
f"{config.message_prefix} Info: ",
|
||||
t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()),
|
||||
)
|
||||
draw_messages_window(True)
|
||||
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_d() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
|
||||
@@ -1245,14 +1651,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}")
|
||||
@@ -1271,12 +1680,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)
|
||||
|
||||
@@ -1291,7 +1700,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)
|
||||
@@ -1305,7 +1713,7 @@ def refresh_message_highlight() -> None:
|
||||
previous_range = ui_state.highlighted_message_range
|
||||
width = max(0, messages_win.getmaxyx()[1] - 2)
|
||||
|
||||
if not ui_state.channel_list or ui_state.current_window != 1:
|
||||
if not ui_state.channel_list or ui_state.current_window != 1 or ui_state.input_focused:
|
||||
if previous_range:
|
||||
start, end, color = previous_range
|
||||
for row in range(start, end):
|
||||
@@ -1470,20 +1878,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
|
||||
@@ -1621,7 +2015,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:
|
||||
@@ -1638,7 +2032,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
|
||||
@@ -1681,9 +2075,12 @@ def refresh_pad(window: int) -> None:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
box.noutrefresh()
|
||||
|
||||
pad.refresh(
|
||||
# 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,
|
||||
@@ -1691,6 +2088,8 @@ def refresh_pad(window: int) -> None:
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
|
||||
|
||||
def add_notification(channel_number: int) -> None:
|
||||
@@ -1705,21 +2104,25 @@ def remove_notification(channel_number: int) -> None:
|
||||
|
||||
def draw_text_field(win: curses.window, text: str, color: int) -> None:
|
||||
win.border()
|
||||
height, width = win.getmaxyx()
|
||||
|
||||
# Put a small hint in the border of the message entry field.
|
||||
# We key off the "Message:" prompt to avoid affecting other bordered fields.
|
||||
if isinstance(text, str) and text.startswith("Message:"):
|
||||
hint = " Ctrl+K Help "
|
||||
h, w = win.getmaxyx()
|
||||
x = max(2, w - len(hint) - 2)
|
||||
x = max(2, width - len(hint) - 2)
|
||||
try:
|
||||
win.addstr(0, x, hint, get_color("commands"))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Draw the actual field text
|
||||
# Clear the row before redrawing. Reply context can be longer than the
|
||||
# next value (or empty after Ctrl+R cancels it), so simply overwriting the
|
||||
# new text leaves trailing characters from the old context on screen.
|
||||
try:
|
||||
win.addstr(1, 1, text, color)
|
||||
if height > 2 and width > 2:
|
||||
win.addstr(1, 1, " " * (width - 2), color)
|
||||
win.addstr(1, 1, text[: max(0, width - 2)], color)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
+214
-33
@@ -3,7 +3,8 @@ import curses
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
from meshtastic.protobuf import admin_pb2
|
||||
|
||||
@@ -23,21 +24,25 @@ 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.splash import draw_splash
|
||||
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
|
||||
save_option = "Save Changes"
|
||||
max_help_lines = 0
|
||||
REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS = 15
|
||||
admin_target_label = ""
|
||||
help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
|
||||
class RemoteAdminCancelled(Exception):
|
||||
"""Raised when the user dismisses an in-progress remote-admin request."""
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
def get_menu_width() -> int:
|
||||
# Leave at least 2 columns for borders; clamp to >= 20 for usability
|
||||
@@ -86,7 +91,8 @@ def get_translated_header(menu_path: List[str]) -> str:
|
||||
continue
|
||||
full_key = ".".join(transformed_path[:idx])
|
||||
translated_parts.append(field_mapping.get(full_key, part))
|
||||
return " > ".join(translated_parts)
|
||||
breadcrumb = " > ".join(translated_parts)
|
||||
return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb
|
||||
|
||||
|
||||
def display_menu() -> tuple[object, object]:
|
||||
@@ -133,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):
|
||||
@@ -232,10 +244,7 @@ def reconnect_interface_with_splash(stdscr: object, interface: object) -> object
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stdscr.clear()
|
||||
stdscr.refresh()
|
||||
draw_splash(stdscr)
|
||||
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
|
||||
@@ -274,15 +283,126 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None:
|
||||
|
||||
get_channels()
|
||||
refresh_node_list()
|
||||
contact_ui.handle_resize(stdscr, False)
|
||||
if not contact_ui.ui_state.log_viewer_open:
|
||||
contact_ui.handle_resize(stdscr, False)
|
||||
except Exception:
|
||||
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
|
||||
|
||||
|
||||
def settings_menu(stdscr: object, interface: object) -> None:
|
||||
curses.update_lines_cols()
|
||||
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
|
||||
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
|
||||
failure = []
|
||||
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
def run_request() -> None:
|
||||
try:
|
||||
request(*args)
|
||||
except BaseException as exc:
|
||||
failure.append(exc)
|
||||
|
||||
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
|
||||
worker.start()
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while worker.is_alive() and time.monotonic() < deadline:
|
||||
worker.join(0.1)
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if worker.is_alive():
|
||||
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
|
||||
if failure:
|
||||
raise failure[0]
|
||||
|
||||
|
||||
def _request_remote_channels_with_timeout(node: object, cancel_callback=None) -> None:
|
||||
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
|
||||
node.requestChannels()
|
||||
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
|
||||
while getattr(node, "channels", None) is None:
|
||||
if cancel_callback and cancel_callback():
|
||||
raise RemoteAdminCancelled()
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Timed out waiting for the remote node's channel list.")
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def verify_remote_admin(node: object, status_callback=None, cancel_callback=None) -> None:
|
||||
"""Establish and verify an authorized remote-admin session.
|
||||
|
||||
Config reads alone are not a sufficient authorization check. The
|
||||
Meshtastic admin-session exchange only provides a session passkey when
|
||||
the remote node accepts this client's administrative credentials.
|
||||
"""
|
||||
if status_callback:
|
||||
status_callback("Checking remote-admin permission…")
|
||||
_request_remote_with_timeout(node.ensureSessionKey, cancel_callback=cancel_callback)
|
||||
|
||||
try:
|
||||
remote_session = node.iface._getOrCreateByNum(node.nodeNum).get("adminSessionPassKey")
|
||||
except (AttributeError, TypeError):
|
||||
remote_session = None
|
||||
if not remote_session:
|
||||
raise PermissionError("The selected node did not authorize this admin request.")
|
||||
|
||||
|
||||
def _request_remote_section(
|
||||
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
|
||||
) -> bool:
|
||||
"""Fetch exactly the remote-admin section the user is opening.
|
||||
|
||||
Meshtastic returns radio and module settings one protobuf section at a
|
||||
time. Asking for all of them on menu entry is both slow and unreliable on
|
||||
long paths, so leave the top-level menu immediately available.
|
||||
"""
|
||||
if menu_path == ["Main Menu"] and selected_option == "Channels":
|
||||
if status_callback:
|
||||
status_callback("Requesting channel list…")
|
||||
_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
|
||||
|
||||
category = menu_path[-1]
|
||||
if category == "Radio Settings":
|
||||
config = node.localConfig
|
||||
elif category == "Module Settings":
|
||||
config = node.moduleConfig
|
||||
else:
|
||||
return False
|
||||
|
||||
field = config.DESCRIPTOR.fields_by_name.get(selected_option)
|
||||
if field is None or field.name in {"version", "sessionkey"}:
|
||||
return False
|
||||
if status_callback:
|
||||
status_callback(f"Requesting {selected_option} config…")
|
||||
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
|
||||
return True
|
||||
|
||||
|
||||
def settings_menu(
|
||||
stdscr: object,
|
||||
interface: object,
|
||||
node: object = None,
|
||||
remote: bool = False,
|
||||
status_callback=None,
|
||||
cancel_callback=None,
|
||||
) -> None:
|
||||
curses.update_lines_cols()
|
||||
global admin_target_label
|
||||
node = node or interface.localNode
|
||||
node_num = getattr(node, "nodeNum", 0)
|
||||
node_info = interface.getMyNodeInfo() if not remote else getattr(interface, "nodesByNum", {}).get(node_num, {})
|
||||
node_name = node_info.get("user", {}).get("longName") if isinstance(node_info, dict) else None
|
||||
admin_target_label = node_name or f"!{node_num:08x}"
|
||||
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
menu_state.menu_path = ["Main Menu"]
|
||||
|
||||
@@ -387,12 +507,15 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
help_win.refresh()
|
||||
|
||||
if menu_state.show_save_option and menu_state.selected_index == len(options):
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state)
|
||||
reconnect_required = save_changes(interface, modified_settings, menu_state, node=node)
|
||||
modified_settings.clear()
|
||||
logging.info("Changes Saved")
|
||||
if remote:
|
||||
break
|
||||
if reconnect_required:
|
||||
interface = reconnect_interface_with_splash(stdscr, interface)
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
node = interface.localNode
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
|
||||
|
||||
if len(menu_state.menu_path) > 1:
|
||||
menu_state.menu_path.pop()
|
||||
@@ -422,7 +545,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
filename += ".yaml"
|
||||
|
||||
try:
|
||||
config_text = config_export(interface)
|
||||
config_text = config_export(interface, node=node)
|
||||
yaml_file_path = os.path.join(config_folder, filename)
|
||||
|
||||
if os.path.exists(yaml_file_path):
|
||||
@@ -487,12 +610,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
config_import(interface, file_path)
|
||||
config_import(interface, file_path, node=node)
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
|
||||
elif selected_option == "Config URL":
|
||||
current_value = interface.localNode.getURL()
|
||||
current_value = node.getURL()
|
||||
new_value = get_text_input(
|
||||
t(
|
||||
"ui.prompt.config_url_current",
|
||||
@@ -510,7 +633,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
["Yes", "No"],
|
||||
)
|
||||
if overwrite == "Yes":
|
||||
interface.localNode.setURL(new_value)
|
||||
node.setURL(new_value)
|
||||
logging.info(f"New Config URL sent to node")
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
@@ -521,7 +644,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
|
||||
stdscr, interface, node.reboot, "Node Reboot Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
@@ -535,7 +658,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu"
|
||||
stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu"
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
@@ -546,7 +669,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface.localNode.shutdown()
|
||||
node.shutdown()
|
||||
logging.info(f"Node Shutdown Requested by menu")
|
||||
menu_state.start_index.pop()
|
||||
continue
|
||||
@@ -561,7 +684,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=True),
|
||||
lambda: request_factory_reset(node, full=True),
|
||||
"Factory Reset Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
@@ -578,7 +701,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, full=False),
|
||||
lambda: request_factory_reset(node, full=False),
|
||||
"Factory Reset Config Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
@@ -590,12 +713,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
menu_win.refresh()
|
||||
menu_state.menu_path.append("App Settings")
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
json_editor(stdscr, menu_state) # Open the App Settings menu
|
||||
open_log_viewer = json_editor(stdscr, menu_state) # Open the App Settings menu
|
||||
reload_translations()
|
||||
menu_state.current_menu = menu["Main Menu"]
|
||||
menu_state.menu_path = ["Main Menu"]
|
||||
menu_state.start_index.pop()
|
||||
menu_state.selected_index = 4
|
||||
if open_log_viewer:
|
||||
break
|
||||
continue
|
||||
|
||||
field_info = menu_state.current_menu.get(selected_option)
|
||||
@@ -609,7 +734,25 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
# Fetch human-readable name from field_mapping
|
||||
human_readable_name = field_mapping.get(full_key, selected_option)
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||
if field is None and selected_option in ("ringtone", "messages"):
|
||||
is_ringtone = selected_option == "ringtone"
|
||||
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
|
||||
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
|
||||
getter = getattr(node, getter_name, None)
|
||||
setter = getattr(node, setter_name, None)
|
||||
fetched_value = getter() if callable(getter) else None
|
||||
current_value = fetched_value if fetched_value is not None else current_value
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
)
|
||||
if new_value is not None and new_value != current_value and callable(setter):
|
||||
setter(new_value)
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
menu_state.start_index.pop()
|
||||
menu_state.need_redraw = True
|
||||
continue
|
||||
|
||||
if selected_option in ["longName", "shortName", "isLicensed", "isUnmessagable"]:
|
||||
if selected_option in ["longName", "shortName"]:
|
||||
new_value = get_text_input(
|
||||
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||
@@ -617,7 +760,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
new_value = current_value if new_value is None else new_value
|
||||
menu_state.current_menu[selected_option] = (field, new_value)
|
||||
|
||||
elif selected_option == "isLicensed":
|
||||
elif selected_option in ["isLicensed", "isUnmessagable"]:
|
||||
new_value = get_list_input(
|
||||
f"{human_readable_name} is currently: {current_value}",
|
||||
str(current_value),
|
||||
@@ -626,9 +769,6 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
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"]:
|
||||
@@ -649,6 +789,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
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":
|
||||
@@ -690,8 +835,17 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
|
||||
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()
|
||||
@@ -716,8 +870,35 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
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:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
requested_remote_section = False
|
||||
if remote:
|
||||
try:
|
||||
requested_remote_section = _request_remote_section(
|
||||
node, menu_state.menu_path, selected_option, status_callback, cancel_callback
|
||||
)
|
||||
finally:
|
||||
if status_callback:
|
||||
status_callback(None)
|
||||
if requested_remote_section:
|
||||
# The response updates the node's protobufs. Rebuild
|
||||
# the menu before entering the requested section so
|
||||
# the newly received values are what the user sees.
|
||||
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=False)
|
||||
refreshed_menu = menu["Main Menu"]
|
||||
for path_step in menu_state.menu_path[1:]:
|
||||
refreshed_menu = refreshed_menu[path_step]
|
||||
menu_state.current_menu = refreshed_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
else:
|
||||
menu_state.current_menu = menu_state.current_menu[selected_option]
|
||||
menu_state.menu_path.append(selected_option)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
menu_state.selected_index = 0
|
||||
|
||||
@@ -235,7 +235,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"message_prefix": ">>",
|
||||
"sent_message_prefix": ">> Sent",
|
||||
"notification_symbol": "*",
|
||||
"notification_sound": "True",
|
||||
"notification_sound": "alert.mp3",
|
||||
"ack_implicit_str": "[◌]",
|
||||
"ack_str": "[✓]",
|
||||
"nak_str": "[x]",
|
||||
@@ -243,6 +243,7 @@ def initialize_config() -> Dict[str, object]:
|
||||
"node_sort": "lastHeard",
|
||||
"theme": "dark",
|
||||
"ping_bot": {
|
||||
"enabled": "False",
|
||||
"catch_words": "ping; test",
|
||||
"response_word": "Pong!",
|
||||
},
|
||||
@@ -263,6 +264,15 @@ def initialize_config() -> Dict[str, object]:
|
||||
# Check and add missing variables
|
||||
updated = update_dict(default_config_variables, loaded_config)
|
||||
|
||||
# Migrate the legacy boolean notification preference to the file-based
|
||||
# sound selection introduced in 1.5.12.
|
||||
if loaded_config.get("notification_sound") == "True":
|
||||
loaded_config["notification_sound"] = "alert.mp3"
|
||||
updated = True
|
||||
elif loaded_config.get("notification_sound") == "False":
|
||||
loaded_config["notification_sound"] = "None"
|
||||
updated = True
|
||||
|
||||
# Update the JSON file if any variables were missing
|
||||
if updated:
|
||||
formatted_json = format_json_single_line_arrays(loaded_config)
|
||||
@@ -280,7 +290,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||
global node_list_16ths, channel_list_16ths, single_pane_mode
|
||||
global theme, COLOR_CONFIG, language
|
||||
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
|
||||
global node_sort, notification_sound, ping_bot_enabled, ping_bot_catch_words, ping_bot_response_word
|
||||
|
||||
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||
node_list_16ths = loaded_config["node_list_16ths"]
|
||||
@@ -300,6 +310,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||
node_sort = loaded_config["node_sort"]
|
||||
theme = loaded_config["theme"]
|
||||
ping_bot = loaded_config.get("ping_bot", {})
|
||||
ping_bot_enabled = ping_bot.get("enabled", "False")
|
||||
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
|
||||
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
|
||||
if theme == "dark":
|
||||
|
||||
@@ -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:
|
||||
|
||||
+67
-13
@@ -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,14 +57,49 @@ def extract_fields(
|
||||
return menu
|
||||
|
||||
|
||||
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
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.
|
||||
"""
|
||||
menu_structure = {"Main Menu": {}}
|
||||
|
||||
# Add User Settings
|
||||
current_node_info = interface.getMyNodeInfo() if interface else None
|
||||
node = node or (interface.localNode if interface else None)
|
||||
current_node_info = interface.getMyNodeInfo() if interface and node is interface.localNode else None
|
||||
if current_node_info is None and interface and node is not None:
|
||||
current_node_info = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None))
|
||||
|
||||
if current_node_info:
|
||||
current_user_config = current_node_info.get("user", None)
|
||||
@@ -73,34 +108,40 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
"longName": (None, current_user_config.get("longName", "Not Set")),
|
||||
"shortName": (None, current_user_config.get("shortName", "Not Set")),
|
||||
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
|
||||
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
|
||||
}
|
||||
update_ham_fields(menu_structure["Main Menu"]["User Settings"], node)
|
||||
else:
|
||||
logging.info("User settings not found in Node Info")
|
||||
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
|
||||
else:
|
||||
logging.info("Node Info not available")
|
||||
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
|
||||
menu_structure["Main Menu"]["User Settings"] = {}
|
||||
|
||||
# Add Channels
|
||||
channel = channel_pb2.ChannelSettings()
|
||||
menu_structure["Main Menu"]["Channels"] = {}
|
||||
if interface:
|
||||
if node:
|
||||
for i in range(8):
|
||||
current_channel = interface.localNode.getChannelByChannelIndex(i)
|
||||
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
|
||||
radio = config_pb2.Config()
|
||||
current_radio_config = interface.localNode.localConfig if interface else None
|
||||
current_radio_config = node.localConfig if node else None
|
||||
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
|
||||
|
||||
# Add Lat/Lon/Alt
|
||||
position_data = {
|
||||
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
|
||||
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
|
||||
"altitude": (None, current_node_info["position"].get("altitude", 0)),
|
||||
"latitude": (None, (current_node_info or {}).get("position", {}).get("latitude", 0.0)),
|
||||
"longitude": (None, (current_node_info or {}).get("position", {}).get("longitude", 0.0)),
|
||||
"altitude": (None, (current_node_info or {}).get("position", {}).get("altitude", 0)),
|
||||
}
|
||||
|
||||
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
|
||||
@@ -117,11 +158,24 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
|
||||
# Add Module Settings
|
||||
module = module_config_pb2.ModuleConfig()
|
||||
current_module_config = interface.localNode.moduleConfig if interface else None
|
||||
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
|
||||
current_module_config = node.moduleConfig if node else None
|
||||
module_settings = extract_fields(module, current_module_config)
|
||||
if node:
|
||||
# These values use dedicated admin requests rather than ModuleConfig
|
||||
# fields, so expose them alongside their related module settings.
|
||||
module_settings.setdefault("external_notification", {})["ringtone"] = (
|
||||
None,
|
||||
getattr(node, "ringtone", "") or "",
|
||||
)
|
||||
module_settings.setdefault("canned_message", {})["messages"] = (
|
||||
None,
|
||||
getattr(node, "cannedPluginMessage", "") or "",
|
||||
)
|
||||
menu_structure["Main Menu"]["Module Settings"] = module_settings
|
||||
|
||||
# Add App Settings
|
||||
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
|
||||
if include_app_settings:
|
||||
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
|
||||
|
||||
# Additional settings options
|
||||
menu_structure["Main Menu"].update(
|
||||
|
||||
@@ -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()
|
||||
|
||||
+11
-2
@@ -25,10 +25,10 @@ class ChatUIState:
|
||||
selected_message: int = 0
|
||||
selected_node: int = 0
|
||||
current_window: int = 0
|
||||
input_focused: bool = False
|
||||
input_cursor: int = 0
|
||||
last_sent_time: float = 0.0
|
||||
last_traceroute_time: float = 0.0
|
||||
bot_mode_enabled: bool = False
|
||||
|
||||
selected_index: int = 0
|
||||
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||
show_save_option: bool = False
|
||||
@@ -41,6 +41,14 @@ class ChatUIState:
|
||||
redraw_full_ui: bool = False
|
||||
scroll_messages_to_bottom: bool = False
|
||||
preserve_message_selection: bool = False
|
||||
reconnect_attempted: bool = False
|
||||
reconnect_prompt_open: bool = False
|
||||
log_viewer_open: bool = False
|
||||
log_viewer_follow: bool = True
|
||||
log_viewer_start_line: int = 0
|
||||
log_viewer_lines: List[str] = field(default_factory=list)
|
||||
log_viewer_signature: Any = None
|
||||
log_viewer_loaded: bool = False
|
||||
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
|
||||
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
|
||||
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
|
||||
@@ -60,3 +68,4 @@ class InterfaceState:
|
||||
@dataclass
|
||||
class AppState:
|
||||
lock: Any = None
|
||||
connection_args: Any = None
|
||||
+69
-26
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import json
|
||||
import curses
|
||||
from typing import Any, List, Dict, Optional
|
||||
|
||||
@@ -9,12 +8,14 @@ from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
||||
from contact.utilities.ini_utils import parse_ini_file
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.singleton import menu_state
|
||||
from contact.utilities.shared_state import menu_state, ui_state
|
||||
|
||||
|
||||
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
max_help_lines = 6
|
||||
save_option = "Save Changes"
|
||||
VIEW_LOG_OPTION = "__view_log__"
|
||||
LOG_VIEWER_LINE_LIMIT = 500
|
||||
translation_file = config.get_localisation_file(config.language)
|
||||
field_mapping, help_text = parse_ini_file(translation_file)
|
||||
translation_language = config.language
|
||||
@@ -86,6 +87,32 @@ def get_effective_width() -> int:
|
||||
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
|
||||
|
||||
|
||||
def load_log_tail(path: str, max_lines: int = LOG_VIEWER_LINE_LIMIT) -> List[str]:
|
||||
"""Read the final log lines without loading an arbitrarily large log file."""
|
||||
if max_lines <= 0:
|
||||
return []
|
||||
try:
|
||||
with open(path, "rb") as log_file:
|
||||
log_file.seek(0, os.SEEK_END)
|
||||
position = log_file.tell()
|
||||
chunks = []
|
||||
newline_count = 0
|
||||
while position > 0 and newline_count <= max_lines:
|
||||
read_size = min(8192, position)
|
||||
position -= read_size
|
||||
log_file.seek(position)
|
||||
chunk = log_file.read(read_size)
|
||||
chunks.append(chunk)
|
||||
newline_count += chunk.count(b"\n")
|
||||
data = b"".join(reversed(chunks)).decode("utf-8", errors="replace")
|
||||
lines = data.splitlines()
|
||||
if position > 0 and lines:
|
||||
lines = lines[1:]
|
||||
return lines[-max_lines:]
|
||||
except OSError as exc:
|
||||
return [f"Unable to read log: {exc}"]
|
||||
|
||||
|
||||
def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> List[str]:
|
||||
"""
|
||||
Allows the user to select a foreground and background color for a key.
|
||||
@@ -115,6 +142,23 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L
|
||||
|
||||
def edit_value(key: str, display_label: str, current_value: str) -> str:
|
||||
|
||||
if key in ("single_pane_mode", "enabled"):
|
||||
return get_list_input(display_label, current_value, ["True", "False"])
|
||||
|
||||
if key == "notification_sound":
|
||||
sounds_dir = os.path.join(config.parent_dir, "sounds")
|
||||
try:
|
||||
sound_options = sorted(
|
||||
filename for filename in os.listdir(sounds_dir)
|
||||
if filename.lower().endswith((".mp3", ".wav", ".ogg", ".aiff", ".flac"))
|
||||
)
|
||||
except OSError:
|
||||
sound_options = []
|
||||
sound_options = ["None"] + sound_options
|
||||
if current_value not in sound_options:
|
||||
sound_options.append(current_value)
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
w = get_effective_width()
|
||||
height = 10
|
||||
input_width = w - 16 # Allow space for "New Value: "
|
||||
@@ -170,14 +214,6 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
|
||||
sort_options = ["lastHeard", "name", "hops"]
|
||||
return get_list_input(display_label, current_value, sort_options)
|
||||
|
||||
elif key == "notification_sound":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
elif key == "single_pane_mode":
|
||||
sound_options = ["True", "False"]
|
||||
return get_list_input(display_label, current_value, sound_options)
|
||||
|
||||
# Standard Input Mode (Scrollable)
|
||||
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
|
||||
curses.curs_set(1)
|
||||
@@ -264,6 +300,8 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
# Determine menu items based on the type of current_menu
|
||||
if isinstance(menu_state.current_menu, dict):
|
||||
options = list(menu_state.current_menu.keys())
|
||||
if len(menu_state.menu_path) <= 2:
|
||||
options.insert(0, VIEW_LOG_OPTION)
|
||||
elif isinstance(menu_state.current_menu, list):
|
||||
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
|
||||
else:
|
||||
@@ -302,10 +340,13 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
menu_state.current_menu[key]
|
||||
if isinstance(menu_state.current_menu, dict)
|
||||
else menu_state.current_menu[int(key.strip("[]"))]
|
||||
)
|
||||
) if key != VIEW_LOG_OPTION else ""
|
||||
if isinstance(menu_state.current_menu, dict):
|
||||
full_key = get_app_settings_key(menu_state.menu_path, key)
|
||||
display_key = lookup_app_settings_label(full_key, key)
|
||||
if key == VIEW_LOG_OPTION:
|
||||
display_key = field_mapping.get("app_settings.view_log", "View Log")
|
||||
else:
|
||||
full_key = get_app_settings_key(menu_state.menu_path, key)
|
||||
display_key = lookup_app_settings_label(full_key, key)
|
||||
else:
|
||||
display_key = key
|
||||
display_key = f"{display_key}"[: w // 2 - 2]
|
||||
@@ -382,27 +423,20 @@ def update_app_settings_help(menu_win: curses.window, options: List[str]) -> Non
|
||||
)
|
||||
|
||||
|
||||
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
|
||||
|
||||
menu_state.selected_index = 0 # Track the selected option
|
||||
made_changes = False # Track if any changes were made
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||
file_path = os.path.join(parent_dir, "config.json")
|
||||
file_path = config.json_file_path
|
||||
|
||||
menu_state.show_save_option = True # Always show the Save button
|
||||
menu_state.help_win = None
|
||||
menu_state.help_text = {}
|
||||
|
||||
# Ensure the file exists
|
||||
if not os.path.exists(file_path):
|
||||
with open(file_path, "w") as f:
|
||||
json.dump({}, f)
|
||||
|
||||
# Load JSON data
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
original_data = json.load(f)
|
||||
# Use the same path and defaults as startup, including the writable
|
||||
# fallback for shared installations and CONTACT_CONFIG_ROOT overrides.
|
||||
original_data = config.initialize_config()
|
||||
|
||||
data = original_data # Reference to the original data
|
||||
menu_state.current_menu = data # Track the current level of the menu
|
||||
@@ -461,6 +495,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
|
||||
if menu_state.selected_index < len(options): # Handle selection of a menu item
|
||||
selected_key = options[menu_state.selected_index]
|
||||
if selected_key == VIEW_LOG_OPTION:
|
||||
ui_state.log_viewer_open = True
|
||||
ui_state.log_viewer_follow = True
|
||||
ui_state.log_viewer_start_line = 0
|
||||
ui_state.log_viewer_lines = []
|
||||
ui_state.log_viewer_signature = None
|
||||
ui_state.log_viewer_loaded = False
|
||||
return True
|
||||
menu_state.menu_path.append(str(selected_key))
|
||||
menu_state.start_index.append(0)
|
||||
menu_state.menu_index.append(menu_state.selected_index)
|
||||
@@ -564,6 +606,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||
|
||||
break
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
||||
formatted_json = config.format_json_single_line_arrays(data)
|
||||
@@ -574,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",
|
||||
|
||||
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def config_import(interface, filename):
|
||||
def config_import(interface, filename, node=None):
|
||||
"""Apply a configuration file to the selected local or remote node."""
|
||||
node = node or interface.localNode
|
||||
with open(filename, encoding="utf8") as file:
|
||||
configuration = yaml.safe_load(file)
|
||||
closeNow = True
|
||||
|
||||
interface.getNode("^local", False).beginSettingsTransaction()
|
||||
node.beginSettingsTransaction()
|
||||
|
||||
if "owner" in configuration:
|
||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(configuration["owner"])
|
||||
node.setOwner(configuration["owner"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "owner_short" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
node.setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "ownerShort" in configuration:
|
||||
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
||||
waitForAckNak = True
|
||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channel_url" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||
interface.getNode("^local").setURL(configuration["channel_url"])
|
||||
node.setURL(configuration["channel_url"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "channelUrl" in configuration:
|
||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||
interface.getNode("^local").setURL(configuration["channelUrl"])
|
||||
node.setURL(configuration["channelUrl"])
|
||||
time.sleep(0.5)
|
||||
|
||||
if "location" in configuration:
|
||||
alt = 0
|
||||
lat = 0.0
|
||||
lon = 0.0
|
||||
localConfig = interface.localNode.localConfig
|
||||
|
||||
if "alt" in configuration["location"]:
|
||||
alt = int(configuration["location"]["alt"] or 0)
|
||||
logging.info(f"Fixing altitude at {alt} meters")
|
||||
@@ -185,39 +185,44 @@ def config_import(interface, filename):
|
||||
lon = float(configuration["location"]["lon"] or 0)
|
||||
logging.info(f"Fixing longitude at {lon} degrees")
|
||||
logging.info("Setting device position")
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
node.setFixedPosition(lat, lon, alt)
|
||||
time.sleep(0.5)
|
||||
|
||||
if "config" in configuration:
|
||||
localConfig = interface.getNode("^local").localConfig
|
||||
localConfig = node.localConfig
|
||||
for section in configuration["config"]:
|
||||
traverseConfig(section, configuration["config"][section], localConfig)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
if "module_config" in configuration:
|
||||
moduleConfig = interface.getNode("^local").moduleConfig
|
||||
moduleConfig = node.moduleConfig
|
||||
for section in configuration["module_config"]:
|
||||
traverseConfig(
|
||||
section,
|
||||
configuration["module_config"][section],
|
||||
moduleConfig,
|
||||
)
|
||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||
node.writeConfig(camel_to_snake(section))
|
||||
time.sleep(0.5)
|
||||
|
||||
interface.getNode("^local", False).commitSettingsTransaction()
|
||||
node.commitSettingsTransaction()
|
||||
logging.info("Writing modified configuration to device")
|
||||
|
||||
|
||||
def config_export(interface) -> str:
|
||||
"""used in --export-config"""
|
||||
def config_export(interface, node=None) -> str:
|
||||
"""Export the selected node's configuration as Meshtastic configure YAML."""
|
||||
configObj = {}
|
||||
node = node or interface.localNode
|
||||
|
||||
owner = interface.getLongName()
|
||||
owner_short = interface.getShortName()
|
||||
channel_url = interface.localNode.getURL()
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
if node is interface.localNode:
|
||||
myinfo = interface.getMyNodeInfo()
|
||||
else:
|
||||
myinfo = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None), {})
|
||||
user = myinfo.get("user", {}) if isinstance(myinfo, dict) else {}
|
||||
owner = user.get("longName")
|
||||
owner_short = user.get("shortName")
|
||||
channel_url = node.getURL()
|
||||
pos = myinfo.get("position")
|
||||
lat = None
|
||||
lon = None
|
||||
@@ -242,7 +247,7 @@ def config_export(interface) -> str:
|
||||
if alt:
|
||||
configObj["location"]["alt"] = alt
|
||||
|
||||
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
|
||||
if config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
@@ -265,7 +270,7 @@ def config_export(interface) -> str:
|
||||
else:
|
||||
configObj["config"] = config
|
||||
|
||||
module_config = MessageToDict(interface.localNode.moduleConfig)
|
||||
module_config = MessageToDict(node.moduleConfig)
|
||||
if module_config:
|
||||
# Convert inner keys to correct snake/camelCase
|
||||
prefs = {}
|
||||
|
||||
@@ -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,13 +1,61 @@
|
||||
import logging
|
||||
import contextlib
|
||||
import io
|
||||
import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
from meshtastic.protobuf import mesh_pb2
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
class _StatusMessageConfigMixin:
|
||||
"""Read status-message config omitted by Meshtastic 2.7.11's receiver."""
|
||||
|
||||
def _handleFromRadio(self, fromRadioBytes):
|
||||
packet = mesh_pb2.FromRadio()
|
||||
packet.ParseFromString(fromRadioBytes)
|
||||
if packet.HasField("moduleConfig") and packet.moduleConfig.HasField("statusmessage"):
|
||||
self.localNode.moduleConfig.statusmessage.CopyFrom(packet.moduleConfig.statusmessage)
|
||||
return super()._handleFromRadio(fromRadioBytes)
|
||||
|
||||
|
||||
class SerialInterface(_StatusMessageConfigMixin, meshtastic.serial_interface.SerialInterface):
|
||||
pass
|
||||
|
||||
|
||||
class TCPInterface(_StatusMessageConfigMixin, meshtastic.tcp_interface.TCPInterface):
|
||||
pass
|
||||
|
||||
|
||||
class BLEInterface(_StatusMessageConfigMixin, meshtastic.ble_interface.BLEInterface):
|
||||
pass
|
||||
|
||||
|
||||
def interface_is_connected(interface) -> bool:
|
||||
"""Return the transport-independent connection state for an interface.
|
||||
|
||||
TCPInterface deliberately leaves ``stream`` set to ``None`` and uses its
|
||||
``socket`` instead. Checking only ``stream`` therefore treats every TCP
|
||||
connection (including meshtasticd) as disconnected.
|
||||
"""
|
||||
if interface is None:
|
||||
return False
|
||||
|
||||
connected = getattr(interface, "isConnected", None)
|
||||
if hasattr(connected, "is_set"):
|
||||
return connected.is_set()
|
||||
if isinstance(connected, bool):
|
||||
return connected
|
||||
if hasattr(interface, "socket"):
|
||||
return getattr(interface, "socket", None) is not None
|
||||
if hasattr(interface, "stream"):
|
||||
return getattr(interface, "stream", None) is not None
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args, status_callback=None):
|
||||
try:
|
||||
|
||||
if args.ble:
|
||||
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
|
||||
return BLEInterface(args.ble if args.ble != "any" else None)
|
||||
|
||||
elif args.host:
|
||||
try:
|
||||
@@ -16,12 +64,15 @@ def initialize_interface(args):
|
||||
else:
|
||||
tcp_hostname = args.host
|
||||
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
|
||||
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
|
||||
return TCPInterface(tcp_hostname, portNumber=tcp_port)
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to {args.host}. {ex}")
|
||||
else:
|
||||
try:
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
# The library prints its fallback notice directly to stdout,
|
||||
# which would overwrite the curses splash screen.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
client = SerialInterface(args.port)
|
||||
except FileNotFoundError as ex:
|
||||
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
|
||||
except PermissionError as ex:
|
||||
@@ -34,7 +85,9 @@ def initialize_interface(args):
|
||||
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
|
||||
if client.devPath is None:
|
||||
try:
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
if status_callback:
|
||||
status_callback("No serial device found.\nConnecting via TCP to localhost…")
|
||||
client = TCPInterface("localhost")
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to localhost:{ex}")
|
||||
|
||||
@@ -44,19 +97,34 @@ def initialize_interface(args):
|
||||
logging.critical(f"Fatal error initializing interface: {ex}")
|
||||
|
||||
|
||||
def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0):
|
||||
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
|
||||
last_error = None
|
||||
target = getattr(args, "host", None) or getattr(args, "port", None) or getattr(args, "ble", None) or "auto"
|
||||
|
||||
for attempt in range(attempts):
|
||||
attempt_number = attempt + 1
|
||||
logging.info("Reconnect attempt %d/%d to %s", attempt_number, attempts, target)
|
||||
try:
|
||||
interface = initialize_interface(args)
|
||||
if interface is not None:
|
||||
if interface_is_connected(interface) and getattr(interface, "localNode", None) is not None and getattr(
|
||||
interface.localNode, "localConfig", None
|
||||
) is not None:
|
||||
node_num = getattr(interface.localNode, "nodeNum", None)
|
||||
node_label = f" !{node_num:08x}" if isinstance(node_num, int) else ""
|
||||
logging.info("Reconnected to Meshtastic node%s on attempt %d/%d", node_label, attempt_number, attempts)
|
||||
return interface
|
||||
last_error = RuntimeError("initialize_interface returned None")
|
||||
last_error = RuntimeError("interface did not complete connection setup")
|
||||
try:
|
||||
interface.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as ex:
|
||||
last_error = ex
|
||||
|
||||
logging.warning("Reconnect attempt %d/%d failed: %s", attempt_number, attempts, last_error)
|
||||
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
logging.error("Unable to reconnect to Meshtastic node after %d attempts", attempts)
|
||||
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
|
||||
@@ -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,8 +1,7 @@
|
||||
from meshtastic.protobuf import channel_pb2
|
||||
from meshtastic.protobuf import admin_pb2, channel_pb2
|
||||
from google.protobuf.message import Message
|
||||
import logging
|
||||
import base64
|
||||
import time
|
||||
|
||||
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
|
||||
POWER_REBOOT_KEYS = {
|
||||
@@ -29,7 +28,7 @@ LORA_REBOOT_KEYS = {
|
||||
"sx126x_rx_boosted_gain",
|
||||
}
|
||||
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
|
||||
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
|
||||
|
||||
|
||||
def _collect_changed_keys(modified_settings):
|
||||
@@ -78,7 +77,7 @@ def _requires_reconnect(menu_state, modified_settings) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def save_changes(interface, modified_settings, menu_state):
|
||||
def save_changes(interface, modified_settings, menu_state, node=None):
|
||||
"""
|
||||
Save changes to the device based on modified settings.
|
||||
:param interface: Meshtastic interface instance
|
||||
@@ -90,7 +89,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
logging.info("No changes to save. modified_settings is empty.")
|
||||
return False
|
||||
|
||||
node = interface.getNode("^local")
|
||||
node = node or interface.getNode("^local")
|
||||
admin_key_backup = None
|
||||
if "admin_key" in modified_settings:
|
||||
# Get reference to security config
|
||||
@@ -100,22 +99,14 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
# Filter out empty keys
|
||||
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
|
||||
|
||||
if not valid_keys:
|
||||
logging.warning("No valid admin keys provided. Skipping admin key update.")
|
||||
else:
|
||||
# Clear existing keys if needed
|
||||
if security_config.admin_key:
|
||||
logging.info("Clearing existing admin keys...")
|
||||
del security_config.admin_key[:]
|
||||
node.writeConfig("security")
|
||||
time.sleep(2) # Give time for device to process
|
||||
|
||||
# Append new keys
|
||||
for key in valid_keys:
|
||||
logging.info(f"Adding admin key: {key}")
|
||||
security_config.admin_key.append(key)
|
||||
node.writeConfig("security")
|
||||
logging.info("Admin keys updated successfully!")
|
||||
# Update the repeated field in memory, then send one complete
|
||||
# security config. Sending an empty list first can revoke the key
|
||||
# authorizing this remote-admin session before a second write can
|
||||
# restore the remaining keys.
|
||||
del security_config.admin_key[:]
|
||||
security_config.admin_key.extend(valid_keys)
|
||||
node.writeConfig("security")
|
||||
logging.info("Admin keys updated successfully (%d key(s)).", len(valid_keys))
|
||||
|
||||
# Backup 'admin_key' before removing it
|
||||
admin_key_backup = modified_settings.get("admin_key", None)
|
||||
@@ -134,7 +125,7 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
lon = float(modified_settings.get("longitude", 0.0))
|
||||
alt = int(modified_settings.get("altitude", 0))
|
||||
|
||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||
node.setFixedPosition(lat, lon, alt)
|
||||
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
|
||||
return False
|
||||
|
||||
@@ -143,12 +134,31 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
long_name = modified_settings.get("longName")
|
||||
short_name = modified_settings.get("shortName")
|
||||
is_licensed = modified_settings.get("isLicensed")
|
||||
is_unmessagable = modified_settings.get("isUnmessagable")
|
||||
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
|
||||
if is_unmessagable is not None:
|
||||
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed)
|
||||
if is_licensed and "call_sign" in modified_settings:
|
||||
message = admin_pb2.AdminMessage()
|
||||
for field in message.set_ham_mode.DESCRIPTOR.fields:
|
||||
if field.name in modified_settings:
|
||||
setattr(message.set_ham_mode, field.name, modified_settings[field.name])
|
||||
node.ensureSessionKey()
|
||||
# Keep the separate user flag and HAM command in one settings transaction.
|
||||
node.beginSettingsTransaction()
|
||||
if is_unmessagable is not None:
|
||||
node.setOwner(None, None, True, is_unmessagable)
|
||||
callback = None if node is interface.localNode else node.onAckNak
|
||||
node._sendAdmin(message, onResponse=callback)
|
||||
node.commitSettingsTransaction()
|
||||
return True
|
||||
|
||||
node.setOwner(long_name, short_name, is_licensed, is_unmessagable)
|
||||
|
||||
logging.info(
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
|
||||
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
|
||||
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
|
||||
)
|
||||
|
||||
return _requires_reconnect(menu_state, modified_settings)
|
||||
@@ -157,25 +167,31 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
config_category = "Channels"
|
||||
|
||||
try:
|
||||
channel = menu_state.menu_path[-1]
|
||||
channels_index = menu_state.menu_path.index("Channels")
|
||||
channel = menu_state.menu_path[channels_index + 1]
|
||||
channel_num = int(channel.split()[-1]) - 1
|
||||
except (IndexError, ValueError) as e:
|
||||
channel_num = None
|
||||
|
||||
channel = node.channels[channel_num]
|
||||
for key, value in modified_settings.items():
|
||||
if key == "psk": # Special case: decode Base64 for psk
|
||||
if key == "enabled":
|
||||
if not value:
|
||||
channel.role = channel_pb2.Channel.Role.DISABLED
|
||||
elif channel.role == channel_pb2.Channel.Role.DISABLED:
|
||||
channel.role = (
|
||||
channel_pb2.Channel.Role.PRIMARY if channel_num == 0
|
||||
else channel_pb2.Channel.Role.SECONDARY
|
||||
)
|
||||
elif key == "psk": # Special case: decode Base64 for psk
|
||||
channel.settings.psk = base64.b64decode(value)
|
||||
elif key == "position_precision": # Special case: module_settings
|
||||
channel.settings.module_settings.position_precision = value
|
||||
elif key in {"position_precision", "is_muted"}:
|
||||
# These fields belong to ChannelSettings.module_settings,
|
||||
# not ChannelSettings itself.
|
||||
setattr(channel.settings.module_settings, key, value)
|
||||
else:
|
||||
setattr(channel.settings, key, value) # Use setattr for other fields
|
||||
|
||||
if channel_num == 0:
|
||||
channel.role = channel_pb2.Channel.Role.PRIMARY
|
||||
else:
|
||||
channel.role = channel_pb2.Channel.Role.SECONDARY
|
||||
|
||||
node.writeChannel(channel_num)
|
||||
|
||||
logging.info(f"Updated Channel {channel_num} in {config_category}")
|
||||
@@ -232,7 +248,15 @@ def save_changes(interface, modified_settings, menu_state):
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
+63
-14
@@ -1,23 +1,52 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
from functools import lru_cache
|
||||
from typing import Union
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic import protocols
|
||||
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
|
||||
from meshtastic.protobuf import config_pb2, portnums_pb2
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import ui_state, interface_state
|
||||
from contact.utilities.shared_state import ui_state, interface_state
|
||||
import contact.utilities.telemetry_beautifier as tb
|
||||
|
||||
NEW_MODEM_PRESET_NAMES = {
|
||||
14: "TINY_FAST",
|
||||
15: "TINY_SLOW",
|
||||
16: "MEDIUM_TURBO",
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _get_modem_preset_name(modem_preset_enum: int) -> str:
|
||||
try:
|
||||
return config_pb2.Config.LoRaConfig.ModemPreset.Name(modem_preset_enum)
|
||||
except ValueError:
|
||||
compatibility_name = NEW_MODEM_PRESET_NAMES.get(modem_preset_enum)
|
||||
if compatibility_name is not None:
|
||||
logging.warning(
|
||||
"Modem preset %s is newer than the installed Meshtastic protobuf schema; "
|
||||
"using compatibility name %s",
|
||||
modem_preset_enum,
|
||||
compatibility_name,
|
||||
)
|
||||
return compatibility_name
|
||||
|
||||
logging.warning(
|
||||
"Modem preset %s is not recognized by the installed Meshtastic protobuf schema",
|
||||
modem_preset_enum,
|
||||
)
|
||||
return f"UNKNOWN_PRESET_{modem_preset_enum}"
|
||||
|
||||
|
||||
def _get_channel_name(device_channel, node):
|
||||
if device_channel.settings.name:
|
||||
return device_channel.settings.name
|
||||
|
||||
lora_config = node.localConfig.lora
|
||||
modem_preset_enum = lora_config.modem_preset
|
||||
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
|
||||
modem_preset_enum = node.localConfig.lora.modem_preset
|
||||
modem_preset_string = _get_modem_preset_name(modem_preset_enum)
|
||||
return convert_to_camel_case(modem_preset_string)
|
||||
|
||||
|
||||
@@ -160,13 +189,28 @@ def get_time_ago(timestamp):
|
||||
REPLY_EXCERPT_LENGTH = 5
|
||||
|
||||
|
||||
def _local_short_name() -> str:
|
||||
"""Return this node's short name without depending on the message database."""
|
||||
nodes = getattr(interface_state.interface, "nodes", {}) or {}
|
||||
for node in nodes.values():
|
||||
if node.get("num") == interface_state.myNodeNum:
|
||||
short_name = node.get("user", {}).get("shortName")
|
||||
if short_name:
|
||||
return str(short_name)
|
||||
return ""
|
||||
|
||||
|
||||
def build_reply_prefix(prefix: str, message: str) -> str:
|
||||
"""Create Contact's local display marker for a native Meshtastic reply."""
|
||||
sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip()
|
||||
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
|
||||
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
|
||||
sender = sender_match.group(1).strip() if sender_match else "me"
|
||||
excerpt = " ".join(message.replace("\x00", "").split())[:REPLY_EXCERPT_LENGTH]
|
||||
if sender_text.startswith(config.sent_message_prefix.strip()):
|
||||
sender = _local_short_name()
|
||||
else:
|
||||
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
|
||||
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
|
||||
sender = sender_match.group(1).strip() if sender_match else ""
|
||||
message_without_reply_marker = re.sub(r"^<Re:\s*[^>]*>\s*", "", message.replace("\x00", ""))
|
||||
excerpt = " ".join(message_without_reply_marker.split())[:REPLY_EXCERPT_LENGTH]
|
||||
return f"<Re: {sender}: {excerpt}> "
|
||||
|
||||
|
||||
@@ -180,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] = []
|
||||
|
||||
@@ -189,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]
|
||||
@@ -211,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)
|
||||
|
||||
@@ -252,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.5.8"
|
||||
version = "1.7.2"
|
||||
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,6 +5,8 @@ import types
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.shared_state import interface_state
|
||||
from tests.test_support import reset_shared_state
|
||||
|
||||
|
||||
class BotHandlerTests(unittest.TestCase):
|
||||
@@ -16,6 +18,13 @@ class BotHandlerTests(unittest.TestCase):
|
||||
)
|
||||
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_shared_state()
|
||||
self.bot_handler.send_message.reset_mock()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
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"):
|
||||
self.assertTrue(self.bot_handler.is_bot_message("PING"))
|
||||
@@ -26,6 +35,30 @@ class BotHandlerTests(unittest.TestCase):
|
||||
with mock.patch.object(config, "ping_bot_catch_words", " ; ; "):
|
||||
self.assertTrue(self.bot_handler.is_bot_message("ping"))
|
||||
|
||||
def test_bot_response_replies_to_triggering_packet(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
packet = {"id": 900, "from": 222, "decoded": {}}
|
||||
|
||||
with mock.patch.object(config, "ping_bot_enabled", "True"):
|
||||
with mock.patch.object(self.bot_handler, "get_reply_context", return_value="<Re: NODE: ping> "):
|
||||
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
|
||||
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
|
||||
send_response = thread.call_args.kwargs["target"]
|
||||
with mock.patch.object(self.bot_handler.time, "sleep"):
|
||||
with mock.patch.dict(
|
||||
sys.modules,
|
||||
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
|
||||
):
|
||||
send_response()
|
||||
|
||||
self.bot_handler.send_message.assert_called_once_with(
|
||||
"Pong!", channel=0, reply_id=900, reply_context="<Re: NODE: ping> "
|
||||
)
|
||||
|
||||
def test_bot_response_requires_enabled_app_setting(self) -> None:
|
||||
with mock.patch.object(config, "ping_bot_enabled", "False"):
|
||||
self.assertFalse(self.bot_handler.bot_respond({"from": 222}, "ping", 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.__main__ as cli
|
||||
import contact.settings as settings_cli
|
||||
from contact.version import __version__
|
||||
|
||||
|
||||
class CliTests(unittest.TestCase):
|
||||
def test_help_version_and_errors_work_without_runtime_configuration(self) -> None:
|
||||
cases = [
|
||||
(["--help"], 0, "usage:"),
|
||||
(["-h"], 0, "usage:"),
|
||||
(["--version"], 0, __version__),
|
||||
(["-V"], 0, __version__),
|
||||
(["--unknown"], 2, "unrecognized arguments"),
|
||||
(["--host", "localhost", "--ble"], 2, "not allowed with argument"),
|
||||
(["--", "--version"], 2, "unrecognized arguments"),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config_root = Path(directory) / "missing"
|
||||
env = dict(os.environ, CONTACT_CONFIG_ROOT=str(config_root))
|
||||
for command in (["-m", "contact"], ["-m", "contact.settings"],
|
||||
["-c", "from contact.__main__ import start; start()"]):
|
||||
for flags, code, expected in cases:
|
||||
with self.subTest(command=command, flags=flags):
|
||||
result = subprocess.run(
|
||||
[sys.executable, *command, *flags], env=env,
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
self.assertEqual(result.returncode, code, result.stderr)
|
||||
self.assertIn(expected, result.stderr if code else result.stdout)
|
||||
self.assertEqual(result.stdout if code else result.stderr, "")
|
||||
self.assertFalse(config_root.exists())
|
||||
|
||||
def test_control_aliases_preserve_connection_options(self) -> None:
|
||||
for alias in ("--settings", "--set", "--control", "-c"):
|
||||
for connection, value, attribute in (
|
||||
("--host", "mesh.local", "host"),
|
||||
("--port", "/dev/ttyUSB0", "port"),
|
||||
("--ble", "my-radio", "ble"),
|
||||
):
|
||||
with self.subTest(alias=alias, connection=connection):
|
||||
runtime = SimpleNamespace(start=mock.Mock())
|
||||
with mock.patch.dict(sys.modules, {"contact.settings_runtime": runtime}):
|
||||
cli.start([alias, connection, value])
|
||||
runtime.start.assert_called_once()
|
||||
self.assertEqual(getattr(runtime.start.call_args.args[0], attribute), value)
|
||||
|
||||
def test_main_and_standalone_settings_pass_parsed_options(self) -> None:
|
||||
for entrypoint, module in ((cli, "contact.runtime"),
|
||||
(settings_cli, "contact.settings_runtime")):
|
||||
runtime = SimpleNamespace(start=mock.Mock())
|
||||
with mock.patch.dict(sys.modules, {module: runtime}):
|
||||
entrypoint.start(["--host=mesh.local"])
|
||||
runtime.start.assert_called_once()
|
||||
self.assertEqual(runtime.start.call_args.args[0].host, "mesh.local")
|
||||
+46
-1
@@ -1,6 +1,12 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from tempfile import NamedTemporaryFile
|
||||
from unittest.mock import Mock
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
|
||||
import yaml
|
||||
from meshtastic.protobuf import config_pb2, module_config_pb2
|
||||
|
||||
from contact.utilities.config_io import _is_repeated_field, config_export, config_import, splitCompoundName
|
||||
|
||||
|
||||
class ConfigIoTests(unittest.TestCase):
|
||||
@@ -19,3 +25,42 @@ class ConfigIoTests(unittest.TestCase):
|
||||
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
|
||||
|
||||
self.assertTrue(_is_repeated_field(field_type()))
|
||||
|
||||
def test_config_export_uses_selected_remote_node(self) -> None:
|
||||
local_node = SimpleNamespace(nodeNum=1)
|
||||
remote_node = SimpleNamespace(
|
||||
nodeNum=2,
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getURL=lambda: "remote-channel-url",
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
nodesByNum={
|
||||
2: {
|
||||
"user": {"longName": "Remote Node", "shortName": "REM"},
|
||||
"position": {"latitude": 1.25, "longitude": 2.5, "altitude": 12},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
exported = yaml.safe_load(config_export(interface, node=remote_node))
|
||||
|
||||
self.assertEqual(exported["owner"], "Remote Node")
|
||||
self.assertEqual(exported["owner_short"], "REM")
|
||||
self.assertEqual(exported["channel_url"], "remote-channel-url")
|
||||
self.assertEqual(exported["location"], {"lat": 1.25, "lon": 2.5, "alt": 12})
|
||||
|
||||
def test_config_import_uses_selected_remote_node(self) -> None:
|
||||
remote_node = Mock()
|
||||
interface = SimpleNamespace(localNode=Mock())
|
||||
with NamedTemporaryFile("w", suffix=".yaml") as config_file:
|
||||
yaml.safe_dump({"owner": "Remote Node"}, config_file)
|
||||
config_file.flush()
|
||||
|
||||
config_import(interface, config_file.name, node=remote_node)
|
||||
|
||||
remote_node.beginSettingsTransaction.assert_called_once_with()
|
||||
remote_node.setOwner.assert_called_once_with("Remote Node")
|
||||
remote_node.commitSettingsTransaction.assert_called_once_with()
|
||||
interface.localNode.setOwner.assert_not_called()
|
||||
+207
-5
@@ -1,22 +1,70 @@
|
||||
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_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()
|
||||
@@ -38,6 +86,70 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
|
||||
def test_handle_backtick_shows_error_when_remote_admin_is_rejected(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.current_window = 2
|
||||
ui_state.node_list = [123]
|
||||
ui_state.selected_node = 0
|
||||
contact_ui.interface_state.interface = mock.Mock()
|
||||
contact_ui.interface_state.interface.getNode.return_value = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "get_channels"):
|
||||
with mock.patch.object(contact_ui, "refresh_node_list"):
|
||||
with mock.patch.object(contact_ui, "handle_resize"):
|
||||
with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"):
|
||||
with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"):
|
||||
with mock.patch.object(contact_ui, "verify_remote_admin", side_effect=SystemExit(1)):
|
||||
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
|
||||
with mock.patch("contact.ui.dialog.dialog") as dialog:
|
||||
contact_ui.handle_backtick(stdscr)
|
||||
|
||||
dialog.assert_called_once_with(
|
||||
"Remote admin rejected",
|
||||
"The selected node did not authorize this admin request.",
|
||||
)
|
||||
settings_menu.assert_not_called()
|
||||
|
||||
def test_handle_backtick_opens_local_settings_for_selected_local_node(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.current_window = 2
|
||||
ui_state.node_list = [123]
|
||||
ui_state.selected_node = 0
|
||||
contact_ui.interface_state.myNodeNum = 123
|
||||
contact_ui.interface_state.interface = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
with mock.patch.object(contact_ui, "get_list_input") as chooser:
|
||||
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
|
||||
with mock.patch.object(contact_ui, "get_channels"):
|
||||
with mock.patch.object(contact_ui, "refresh_node_list"):
|
||||
with mock.patch.object(contact_ui, "handle_resize"):
|
||||
contact_ui.handle_backtick(stdscr)
|
||||
|
||||
chooser.assert_not_called()
|
||||
settings_menu.assert_called_once_with(stdscr, contact_ui.interface_state.interface)
|
||||
|
||||
def test_show_remote_admin_wait_draws_status(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
stdscr.getmaxyx.return_value = (24, 100)
|
||||
wait_win = mock.Mock()
|
||||
|
||||
with mock.patch.object(contact_ui.curses, "newwin", return_value=wait_win):
|
||||
result = contact_ui.show_remote_admin_wait(stdscr, "Remote")
|
||||
|
||||
self.assertIs(result, wait_win)
|
||||
self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2])
|
||||
wait_win.refresh.assert_called_once()
|
||||
|
||||
def test_update_remote_admin_wait_replaces_status_line(self) -> None:
|
||||
wait_win = mock.Mock()
|
||||
wait_win.getmaxyx.return_value = (5, 40)
|
||||
|
||||
contact_ui.update_remote_admin_wait(wait_win, "Requesting lora config…")
|
||||
|
||||
self.assertIn("Requesting lora config", wait_win.addstr.call_args_list[-1].args[2])
|
||||
|
||||
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
ui_state.redraw_channels = True
|
||||
@@ -102,6 +214,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: ",
|
||||
@@ -110,6 +274,23 @@ class ContactUiTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(reply, "<Re: B1G1: This > ")
|
||||
|
||||
def test_build_reply_prefix_uses_me_for_an_outgoing_message(self) -> None:
|
||||
contact_ui.interface_state.myNodeNum = 123
|
||||
contact_ui.interface_state.interface = SimpleNamespace(
|
||||
nodes={"!0000007b": {"num": 123, "user": {"shortName": "LRY"}}}
|
||||
)
|
||||
reply = contact_ui.build_reply_prefix("[06:27:25] >> Sent[☼]: ", "Thanks Lairy")
|
||||
|
||||
self.assertEqual(reply, "<Re: LRY: Thank> ")
|
||||
|
||||
def test_build_reply_prefix_does_not_nest_an_existing_reply_marker(self) -> None:
|
||||
reply = contact_ui.build_reply_prefix(
|
||||
"[06:27:25] >> xT1e: ",
|
||||
"<Re: CATS: Woot!> Woot indeed",
|
||||
)
|
||||
|
||||
self.assertEqual(reply, "<Re: xT1e: Woot > ")
|
||||
|
||||
def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -134,6 +315,15 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(ui_state.reply_context, "")
|
||||
self.assertFalse(ui_state.reply_id_unavailable)
|
||||
|
||||
def test_draw_text_field_clears_stale_reply_context(self) -> None:
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 30)
|
||||
|
||||
contact_ui.draw_text_field(win, "Message: ", 1)
|
||||
|
||||
self.assertIn(mock.call(1, 1, " " * 28, 1), win.addstr.call_args_list)
|
||||
self.assertIn(mock.call(1, 1, "Message: ", 1), win.addstr.call_args_list)
|
||||
|
||||
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -346,6 +536,8 @@ 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, "packetlog_win", mock.Mock(), create=True).start()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -362,9 +554,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]
|
||||
@@ -417,6 +616,9 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
|
||||
self.assertEqual(dialog_win.getch.call_count, 2)
|
||||
# Idle polling must never present an erased dialog before its text.
|
||||
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
|
||||
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
|
||||
def test_f5_node_details_tolerates_none_metrics(self) -> None:
|
||||
|
||||
+66
-15
@@ -1,39 +1,64 @@
|
||||
from argparse import Namespace
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import control_ui
|
||||
from contact.utilities.singleton import interface_state
|
||||
from contact.utilities.shared_state import app_state, interface_state
|
||||
|
||||
from tests.test_support import reset_singletons
|
||||
from tests.test_support import reset_shared_state
|
||||
|
||||
|
||||
class ControlUiTests(unittest.TestCase):
|
||||
def test_channel_list_renders_names_without_changing_slot_keys(self):
|
||||
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.utilities.shared_state import app_state, menu_state
|
||||
|
||||
channels = [channel_pb2.Channel(settings=channel_pb2.ChannelSettings(name=name))
|
||||
for name in ("Local", "Local", "", " ")]
|
||||
node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda i: channels[i] if i < len(channels) else None,
|
||||
)
|
||||
menu_state.menu_path = ["Main Menu", "Channels"]
|
||||
menu_state.current_menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"]
|
||||
win, pad = mock.Mock(), mock.Mock()
|
||||
win.getbegyx.return_value = (2, 2)
|
||||
win.getmaxyx.return_value = (10, 80)
|
||||
with ExitStack() as stack:
|
||||
for name, value in (("LINES", 24), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, name, value, create=True))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newwin", return_value=win))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "newpad", return_value=pad))
|
||||
stack.enter_context(mock.patch.object(control_ui.curses, "curs_set"))
|
||||
stack.enter_context(mock.patch.object(control_ui, "get_menu_width", return_value=80))
|
||||
for name in ("get_color", "draw_help_window", "draw_arrows"):
|
||||
stack.enter_context(mock.patch.object(control_ui, name, return_value=0))
|
||||
control_ui.display_menu()
|
||||
labels = [call.args[2].strip() for call in pad.addstr.call_args_list]
|
||||
self.assertEqual(labels, ["Local", "Local", "Channel 3", "Channel 4"])
|
||||
self.assertEqual(list(menu_state.current_menu), [f"Channel {i}" for i in range(1, 5)])
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
|
||||
old_interface = mock.Mock()
|
||||
new_interface = mock.Mock()
|
||||
stdscr = mock.Mock()
|
||||
parser = mock.Mock()
|
||||
parser.parse_args.return_value = Namespace()
|
||||
app_state.connection_args = Namespace(host="mesh.local", port=None, ble=None)
|
||||
|
||||
with mock.patch.object(control_ui, "setup_parser", return_value=parser):
|
||||
with mock.patch.object(control_ui, "draw_splash") as draw_splash:
|
||||
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
|
||||
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
|
||||
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
|
||||
|
||||
old_interface.close.assert_called_once_with()
|
||||
stdscr.clear.assert_called_once_with()
|
||||
stdscr.refresh.assert_called_once_with()
|
||||
draw_splash.assert_called_once_with(stdscr)
|
||||
reconnect.assert_called_once_with(parser.parse_args.return_value)
|
||||
reconnect.assert_called_once_with(app_state.connection_args)
|
||||
redraw.assert_called_once_with(stdscr)
|
||||
self.assertIs(result, new_interface)
|
||||
self.assertIs(interface_state.interface, new_interface)
|
||||
@@ -53,6 +78,32 @@ class ControlUiTests(unittest.TestCase):
|
||||
reconnect.assert_called_once_with(stdscr, interface)
|
||||
self.assertIs(result, new_interface)
|
||||
|
||||
def test_verify_remote_admin_establishes_an_authorized_session(self) -> None:
|
||||
node = SimpleNamespace(
|
||||
nodeNum=123,
|
||||
ensureSessionKey=mock.Mock(),
|
||||
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={"adminSessionPassKey": b"key"})),
|
||||
)
|
||||
status_callback = mock.Mock()
|
||||
|
||||
with mock.patch.object(control_ui, "_request_remote_with_timeout") as request:
|
||||
control_ui.verify_remote_admin(node, status_callback=status_callback)
|
||||
|
||||
status_callback.assert_called_once_with("Checking remote-admin permission…")
|
||||
request.assert_called_once_with(node.ensureSessionKey, cancel_callback=None)
|
||||
node.iface._getOrCreateByNum.assert_called_once_with(123)
|
||||
|
||||
def test_verify_remote_admin_rejects_missing_session_key(self) -> None:
|
||||
node = SimpleNamespace(
|
||||
nodeNum=123,
|
||||
ensureSessionKey=mock.Mock(),
|
||||
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={})),
|
||||
)
|
||||
|
||||
with mock.patch.object(control_ui, "_request_remote_with_timeout"):
|
||||
with self.assertRaises(PermissionError):
|
||||
control_ui.verify_remote_admin(node)
|
||||
|
||||
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
|
||||
|
||||
@@ -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)
|
||||
+2
-7
@@ -57,15 +57,10 @@ class I18nTests(unittest.TestCase):
|
||||
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
|
||||
self.assertEqual(parse_ini_file.call_count, 2)
|
||||
|
||||
def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:
|
||||
def test_ping_bot_settings_translation_keys_exist_in_all_locales(self) -> None:
|
||||
required_keys = {
|
||||
"ui.help.bot_responder",
|
||||
"ui.bot.status.enabled",
|
||||
"ui.bot.status.disabled",
|
||||
"ui.bot.dialog.title",
|
||||
"ui.bot.dialog.body",
|
||||
"ui.bot.status.message",
|
||||
"app_settings.ping_bot",
|
||||
"app_settings.ping_bot.enabled",
|
||||
"app_settings.ping_bot.catch_words",
|
||||
"app_settings.ping_bot.response_word",
|
||||
}
|
||||
|
||||
@@ -1,21 +1,58 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.utilities.interfaces import reconnect_interface
|
||||
from meshtastic.protobuf import localonly_pb2, mesh_pb2
|
||||
|
||||
from contact.ui.menus import generate_menu_from_protobuf
|
||||
from contact.utilities.interfaces import BLEInterface, SerialInterface, TCPInterface, reconnect_interface
|
||||
|
||||
|
||||
class InterfacesTests(unittest.TestCase):
|
||||
def test_received_status_message_appears_in_settings_after_reconnect(self):
|
||||
for transport in (SerialInterface, TCPInterface, BLEInterface):
|
||||
with self.subTest(transport=transport.__name__):
|
||||
# A fresh interface has no cached value from the previous save.
|
||||
interface = transport.__new__(transport)
|
||||
interface.configId = 123
|
||||
interface.localNode = SimpleNamespace(
|
||||
localConfig=localonly_pb2.LocalConfig(),
|
||||
moduleConfig=localonly_pb2.LocalModuleConfig(),
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface.getMyNodeInfo = lambda: {"position": {}}
|
||||
for status in ("Available", ""):
|
||||
packet = mesh_pb2.FromRadio()
|
||||
packet.moduleConfig.statusmessage.node_status = status
|
||||
interface._handleFromRadio(packet.SerializeToString())
|
||||
|
||||
# Other module packets must still pass through normally,
|
||||
# without clearing the status message already received.
|
||||
packet = mesh_pb2.FromRadio()
|
||||
packet.moduleConfig.mqtt.enabled = True
|
||||
interface._handleFromRadio(packet.SerializeToString())
|
||||
|
||||
self.assertTrue(interface.localNode.moduleConfig.mqtt.enabled)
|
||||
menu = generate_menu_from_protobuf(interface)
|
||||
value = menu["Main Menu"]["Module Settings"]["statusmessage"]["node_status"][1]
|
||||
self.assertEqual(value, status)
|
||||
|
||||
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
|
||||
args = Namespace()
|
||||
|
||||
with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize:
|
||||
ready_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=object(), nodeNum=123))
|
||||
with mock.patch(
|
||||
"contact.utilities.interfaces.initialize_interface", side_effect=[None, None, ready_interface]
|
||||
) as initialize:
|
||||
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
|
||||
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
|
||||
with mock.patch("contact.utilities.interfaces.logging.info") as info:
|
||||
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
|
||||
|
||||
self.assertEqual(result, "iface")
|
||||
self.assertIs(result, ready_interface)
|
||||
self.assertEqual(initialize.call_count, 3)
|
||||
self.assertEqual(sleep.call_count, 2)
|
||||
self.assertTrue(any("Reconnected to Meshtastic node" in call.args[0] for call in info.call_args_list))
|
||||
|
||||
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
|
||||
args = Namespace()
|
||||
|
||||
+20
-32
@@ -3,21 +3,21 @@ from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.__main__ as entrypoint
|
||||
import contact.runtime as entrypoint
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
from contact.utilities.shared_state import interface_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
from tests.test_support import reset_shared_state, restore_config, snapshot_config
|
||||
|
||||
|
||||
class MainRuntimeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
|
||||
args = Namespace(demo_screenshot=True)
|
||||
@@ -162,28 +162,15 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
stdscr.clear.assert_called_once_with()
|
||||
stdscr.refresh.assert_called_once_with()
|
||||
|
||||
def test_start_prints_help_and_exits_zero(self) -> None:
|
||||
parser = mock.Mock()
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact", "--help"]):
|
||||
with mock.patch.object(entrypoint, "setup_parser", return_value=parser):
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
parser.print_help.assert_called_once_with()
|
||||
exit_mock.assert_called_once_with(0)
|
||||
|
||||
def test_start_runs_curses_wrapper_and_closes_interface(self) -> None:
|
||||
interface = mock.Mock()
|
||||
interface_state.interface = interface
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
|
||||
entrypoint.start()
|
||||
entrypoint.start(Namespace())
|
||||
|
||||
wrapper.assert_called_once_with(entrypoint.main)
|
||||
wrapper.assert_called_once_with(entrypoint.main, Namespace())
|
||||
interface.close.assert_called_once_with()
|
||||
|
||||
def test_start_does_not_crash_when_wrapper_returns_without_interface(self) -> None:
|
||||
@@ -191,9 +178,9 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
|
||||
entrypoint.start()
|
||||
entrypoint.start(Namespace())
|
||||
|
||||
wrapper.assert_called_once_with(entrypoint.main)
|
||||
wrapper.assert_called_once_with(entrypoint.main, Namespace())
|
||||
|
||||
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
@@ -202,11 +189,9 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
with mock.patch.object(entrypoint, "setup_colors"):
|
||||
with mock.patch.object(entrypoint, "ensure_min_rows"):
|
||||
with mock.patch.object(entrypoint, "draw_splash"):
|
||||
with mock.patch.object(entrypoint, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
|
||||
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
entrypoint.main(stdscr)
|
||||
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
|
||||
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
|
||||
entrypoint.main(stdscr, args)
|
||||
|
||||
initialize_globals.assert_not_called()
|
||||
|
||||
@@ -218,7 +203,7 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
entrypoint.start(Namespace())
|
||||
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
interface.close.assert_called_once_with()
|
||||
@@ -231,7 +216,7 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
entrypoint.start(Namespace())
|
||||
|
||||
self.assertEqual(raised.exception.code, 0)
|
||||
exit_mock.assert_called_once_with(0)
|
||||
@@ -240,14 +225,17 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
|
||||
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
|
||||
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
|
||||
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
|
||||
with mock.patch.object(entrypoint.traceback, "print_exception") as print_exception:
|
||||
with mock.patch("builtins.print") as print_mock:
|
||||
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
|
||||
with self.assertRaises(SystemExit) as raised:
|
||||
entrypoint.start()
|
||||
entrypoint.start(Namespace())
|
||||
|
||||
self.assertEqual(raised.exception.code, 1)
|
||||
endwin.assert_called_once_with()
|
||||
print_exc.assert_called_once_with()
|
||||
exception_type, exception, exception_traceback = print_exception.call_args_list[-1].args
|
||||
self.assertIs(exception_type, RuntimeError)
|
||||
self.assertEqual(str(exception), "boom")
|
||||
self.assertIsNotNone(exception_traceback)
|
||||
print_mock.assert_any_call("Fatal error:", mock.ANY)
|
||||
exit_mock.assert_called_once_with(1)
|
||||
@@ -26,3 +26,38 @@ class MenusTests(unittest.TestCase):
|
||||
|
||||
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
|
||||
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
|
||||
|
||||
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
ringtone="tone",
|
||||
cannedPluginMessage="Hi|Bye",
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
|
||||
)
|
||||
|
||||
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
|
||||
|
||||
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
|
||||
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
|
||||
|
||||
def test_user_settings_include_unmessageable_toggle(self) -> None:
|
||||
local_node = SimpleNamespace(
|
||||
localConfig=config_pb2.Config(),
|
||||
moduleConfig=module_config_pb2.ModuleConfig(),
|
||||
getChannelByChannelIndex=lambda _: None,
|
||||
)
|
||||
interface = SimpleNamespace(
|
||||
localNode=local_node,
|
||||
getMyNodeInfo=lambda: {
|
||||
"user": {"longName": "Test User", "shortName": "TU", "isUnmessagable": True},
|
||||
"position": {},
|
||||
},
|
||||
)
|
||||
|
||||
user_settings = generate_menu_from_protobuf(interface)["Main Menu"]["User Settings"]
|
||||
self.assertEqual(user_settings["isUnmessagable"], (None, True))
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import contact_ui
|
||||
from contact.utilities.shared_state import ui_state
|
||||
from tests.test_support import reset_shared_state
|
||||
|
||||
|
||||
class MessageInputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
reset_shared_state()
|
||||
ui_state.current_window = 1
|
||||
for name in ("refresh_message_highlight", "refresh_main_window", "get_color"):
|
||||
patcher = mock.patch.object(contact_ui, name, return_value=1)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(reset_shared_state)
|
||||
|
||||
def test_keyboard_loop_sends_edited_draft_after_resize(self):
|
||||
win = mock.Mock()
|
||||
win.get_wch.side_effect = [
|
||||
"a", "b", "c", contact_ui.curses.KEY_LEFT,
|
||||
contact_ui.curses.KEY_DC, "d", contact_ui.curses.KEY_BACKSPACE,
|
||||
"e", contact_ui.curses.KEY_RESIZE, "\n", "\x1b",
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", win))
|
||||
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "drain_resize_events", return_value=None))
|
||||
send = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
|
||||
contact_ui.main_ui(mock.Mock())
|
||||
send.assert_called_once_with("abe")
|
||||
self.assertEqual(contact_ui.input_text, "")
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
|
||||
def test_typing_focuses_input_and_edits_at_cursor(self):
|
||||
draft = contact_ui.insert_input_text("", "helo")
|
||||
self.assertTrue(ui_state.input_focused)
|
||||
with mock.patch.object(contact_ui, "input_text", draft):
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
|
||||
draft = contact_ui.insert_input_text(draft, "l")
|
||||
self.assertEqual(draft, "hello")
|
||||
self.assertEqual(ui_state.input_cursor, 4)
|
||||
draft = contact_ui.handle_backspace(mock.Mock(), draft)
|
||||
self.assertEqual(draft, "helo")
|
||||
self.assertEqual(ui_state.input_cursor, 3)
|
||||
|
||||
def test_up_and_down_return_to_messages_without_scrolling(self):
|
||||
for handler in (contact_ui.handle_up, contact_ui.handle_down):
|
||||
ui_state.input_focused = True
|
||||
ui_state.input_cursor = 2
|
||||
with mock.patch.object(contact_ui, "scroll_messages") as scroll:
|
||||
handler()
|
||||
self.assertFalse(ui_state.input_focused)
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
self.assertEqual(ui_state.input_cursor, 2)
|
||||
scroll.assert_not_called()
|
||||
|
||||
def test_cursor_stops_at_draft_boundaries(self):
|
||||
ui_state.input_focused = True
|
||||
with mock.patch.object(contact_ui, "input_text", "abc"):
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
self.assertEqual(contact_ui.handle_backspace(mock.Mock(), "abc"), "abc")
|
||||
contact_ui.handle_end()
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
|
||||
self.assertEqual(ui_state.input_cursor, 3)
|
||||
contact_ui.handle_home()
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
|
||||
def test_redraw_clears_line_and_shows_text_after_cursor(self):
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 30)
|
||||
ui_state.input_focused = True
|
||||
ui_state.input_cursor = 2
|
||||
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
contact_ui.draw_message_input("hello")
|
||||
win.addstr.assert_any_call(1, 1, " " * 28, 1)
|
||||
win.addstr.assert_any_call(1, 1, "Message: hello", 1)
|
||||
win.move.assert_called_once_with(1, 12)
|
||||
contact_ui.get_color.assert_any_call("window_frame_selected")
|
||||
|
||||
def test_long_draft_scrolls_to_cursor_with_reply_context(self):
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 24)
|
||||
ui_state.reply_context = "Reply to someone: "
|
||||
ui_state.input_cursor = 20
|
||||
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
contact_ui.draw_message_input("x" * 20)
|
||||
self.assertLess(win.move.call_args.args[1], 23)
|
||||
rendered = [call.args[2] for call in win.addstr.call_args_list if call.args[:2] == (1, 1)][-1]
|
||||
self.assertEqual(rendered, "Message: " + "x" * 12)
|
||||
@@ -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,23 @@
|
||||
from datetime import datetime
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.message_handlers import rx_handler
|
||||
from contact.utilities.singleton import interface_state, menu_state, ui_state
|
||||
from contact.utilities.shared_state import interface_state, menu_state, ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
from tests.test_support import reset_shared_state, restore_config, snapshot_config
|
||||
|
||||
|
||||
class RxHandlerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
self.saved_config = snapshot_config("notification_sound", "message_prefix")
|
||||
config.notification_sound = "False"
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
@@ -52,7 +53,7 @@ class RxHandlerTests(unittest.TestCase):
|
||||
],
|
||||
)
|
||||
add_notification.assert_not_called()
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None)
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None, timestamp=mock.ANY)
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
|
||||
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
|
||||
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
|
||||
@@ -111,7 +112,7 @@ class RxHandlerTests(unittest.TestCase):
|
||||
request_ui_redraw.assert_called_once_with(channels=True)
|
||||
add_notification.assert_called_once_with(1)
|
||||
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
|
||||
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None)
|
||||
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None, timestamp=mock.ANY)
|
||||
|
||||
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
@@ -137,7 +138,32 @@ class RxHandlerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: SAT2: hello> hi")
|
||||
self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901)
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900)
|
||||
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900, timestamp=mock.ANY)
|
||||
|
||||
def test_node_receive_time_is_used_for_display_and_storage(self) -> None:
|
||||
interface_state.myNodeNum = 111
|
||||
ui_state.channel_list = ["Primary"]
|
||||
ui_state.selected_channel = 0
|
||||
for rx_time in (1700000000, 1700007200, None, 0):
|
||||
with self.subTest(rx_time=rx_time):
|
||||
ui_state.all_messages = {"Primary": []}
|
||||
packet = {
|
||||
"from": 222, "to": 999, "rxTime": rx_time,
|
||||
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"queued"},
|
||||
}
|
||||
expected = rx_time or 1800000000
|
||||
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
|
||||
mock.patch.object(rx_handler, "request_ui_redraw"), \
|
||||
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
|
||||
mock.patch.object(rx_handler, "save_message_to_db") as save, \
|
||||
mock.patch.object(rx_handler.time, "time", return_value=1800000000):
|
||||
rx_handler.on_receive(packet, interface=None)
|
||||
self.assertEqual(save.call_args.kwargs["timestamp"], expected)
|
||||
message_time = datetime.fromtimestamp(expected)
|
||||
self.assertEqual(ui_state.all_messages["Primary"][0][0],
|
||||
message_time.strftime("-- %Y-%m-%d %H:00 --"))
|
||||
self.assertTrue(ui_state.all_messages["Primary"][-1][0].startswith(
|
||||
message_time.strftime("[%H:%M:%S] ")))
|
||||
|
||||
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
|
||||
ui_state.packet_buffer = list(range(25))
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from meshtastic.protobuf import channel_pb2, localonly_pb2, module_config_pb2
|
||||
|
||||
from contact.utilities.save_to_radio import save_changes
|
||||
|
||||
|
||||
class SaveToRadioTests(unittest.TestCase):
|
||||
def test_status_message_saves_for_local_and_remote_nodes(self):
|
||||
for remote in (False, True):
|
||||
with self.subTest(remote=remote):
|
||||
interface, node = self.build_interface()
|
||||
node.localConfig = localonly_pb2.LocalConfig()
|
||||
node.moduleConfig = module_config_pb2.ModuleConfig()
|
||||
node.moduleConfig.statusmessage.node_status = "Old status"
|
||||
interface.localNode = mock.Mock() if remote else node
|
||||
node.writeConfig.side_effect = SystemExit(1)
|
||||
menu_state = SimpleNamespace(
|
||||
menu_path=["Main Menu", "Module Settings", "Statusmessage"]
|
||||
)
|
||||
|
||||
reconnect = save_changes(
|
||||
interface, {"node_status": "Available"}, menu_state, node=node
|
||||
)
|
||||
|
||||
self.assertTrue(reconnect)
|
||||
node.writeConfig.assert_not_called()
|
||||
node._sendAdmin.assert_called_once()
|
||||
message = node._sendAdmin.call_args.args[0]
|
||||
self.assertEqual(message.WhichOneof("payload_variant"), "set_module_config")
|
||||
self.assertEqual(message.set_module_config.WhichOneof("payload_variant"), "statusmessage")
|
||||
self.assertEqual(message.set_module_config.statusmessage.node_status, "Available")
|
||||
self.assertEqual(
|
||||
node._sendAdmin.call_args.kwargs["onResponse"],
|
||||
node.onAckNak if remote else None,
|
||||
)
|
||||
|
||||
def test_channel_enable_disable_and_unrelated_edits_preserve_role(self):
|
||||
for index in (0, 1):
|
||||
interface, node = self.build_interface()
|
||||
node.channels = [channel_pb2.Channel(index=i) for i in range(2)]
|
||||
channel = node.channels[index]
|
||||
path = SimpleNamespace(menu_path=["Main Menu", "Channels", f"Channel {index + 1}"])
|
||||
save_changes(interface, {"name": "Test"}, path)
|
||||
self.assertEqual(channel.role, channel_pb2.Channel.DISABLED)
|
||||
save_changes(interface, {"enabled": True}, path)
|
||||
expected = channel_pb2.Channel.PRIMARY if index == 0 else channel_pb2.Channel.SECONDARY
|
||||
self.assertEqual(channel.role, expected)
|
||||
save_changes(interface, {"enabled": False}, path)
|
||||
self.assertEqual(channel.role, channel_pb2.Channel.DISABLED)
|
||||
self.assertEqual(channel.settings.name, "Test")
|
||||
self.assertEqual(node.writeChannel.call_args_list, [mock.call(index)] * 3)
|
||||
|
||||
def build_interface(self):
|
||||
node = mock.Mock()
|
||||
node.localConfig = SimpleNamespace(
|
||||
@@ -55,6 +101,20 @@ class SaveToRadioTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
|
||||
def test_save_changes_replaces_remote_admin_keys_in_one_write(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
key_one = b"1" * 32
|
||||
key_two = b"2" * 32
|
||||
key_three = b"3" * 32
|
||||
node.localConfig.security.admin_key = [key_one, key_two, key_three]
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"admin_key": [key_one, key_two]}, menu_state, node=node)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertEqual(node.localConfig.security.admin_key, [key_one, key_two])
|
||||
node.writeConfig.assert_called_once_with("security")
|
||||
|
||||
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
|
||||
@@ -95,6 +155,18 @@ class SaveToRadioTests(unittest.TestCase):
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertTrue(node.moduleConfig.mqtt.enabled)
|
||||
|
||||
def test_save_changes_writes_channel_is_muted_to_module_settings(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
channel = SimpleNamespace(settings=SimpleNamespace(module_settings=SimpleNamespace(is_muted=False)), role=None)
|
||||
node.channels = [channel]
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "Channels", "Channel 1", "module_settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"is_muted": True}, menu_state)
|
||||
|
||||
self.assertFalse(reconnect_required)
|
||||
self.assertTrue(channel.settings.module_settings.is_muted)
|
||||
node.writeChannel.assert_called_once_with(0)
|
||||
|
||||
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
@@ -112,3 +184,12 @@ class SaveToRadioTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
node.setOwner.assert_called_once()
|
||||
|
||||
def test_save_changes_sends_user_unmessageable_setting(self) -> None:
|
||||
interface, node = self.build_interface()
|
||||
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
|
||||
|
||||
reconnect_required = save_changes(interface, {"isUnmessagable": True}, menu_state)
|
||||
|
||||
self.assertTrue(reconnect_required)
|
||||
self.assertEqual(node.setOwner.call_args.args[3], True)
|
||||
+16
-22
@@ -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()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
import json
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config # Initialize config before the color helpers.
|
||||
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")
|
||||
|
||||
self.assertEqual(result, "True")
|
||||
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
|
||||
|
||||
def test_load_log_tail_returns_only_requested_recent_lines(self) -> None:
|
||||
with NamedTemporaryFile("w") as log_file:
|
||||
log_file.write("".join(f"line {index}\n" for index in range(600)))
|
||||
log_file.flush()
|
||||
|
||||
lines = user_config.load_log_tail(log_file.name, max_lines=500)
|
||||
|
||||
self.assertEqual(len(lines), 500)
|
||||
self.assertEqual(lines[0], "line 100")
|
||||
self.assertEqual(lines[-1], "line 599")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+100
-13
@@ -1,22 +1,71 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from meshtastic.protobuf import config_pb2, mesh_pb2, telemetry_pb2
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
|
||||
from contact.utilities.shared_state import interface_state, ui_state
|
||||
from contact.utilities.utils import (
|
||||
_get_channel_name,
|
||||
_get_modem_preset_name,
|
||||
add_new_message,
|
||||
get_channels,
|
||||
get_node_list,
|
||||
parse_protobuf,
|
||||
)
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
from tests.test_support import reset_shared_state, restore_config, snapshot_config
|
||||
|
||||
|
||||
class UtilsTests(unittest.TestCase):
|
||||
def test_parse_protobuf_beautifies_local_stats(self) -> None:
|
||||
telemetry = telemetry_pb2.Telemetry(time=1788677531)
|
||||
telemetry.local_stats.uptime_seconds = 7200
|
||||
telemetry.local_stats.channel_utilization = 2.90333319
|
||||
telemetry.local_stats.num_packets_tx = 12
|
||||
telemetry.local_stats.num_packets_rx_bad = 3
|
||||
telemetry.local_stats.num_online_nodes = 4
|
||||
telemetry.local_stats.heap_free_bytes = 1024
|
||||
|
||||
rendered = parse_protobuf({"decoded": {
|
||||
"portnum": "TELEMETRY_APP", "payload": telemetry.SerializeToString(),
|
||||
}})
|
||||
|
||||
self.assertIn("🆙 2.0h", rendered)
|
||||
self.assertIn("ChUtil:2.9%", rendered)
|
||||
self.assertIn("📤 Tx:12", rendered)
|
||||
self.assertIn("⚠️ RxBad:3", rendered)
|
||||
self.assertIn("📡 Online:4", rendered)
|
||||
self.assertIn("💾 Free:1024B", rendered)
|
||||
self.assertNotIn("local_stats", rendered)
|
||||
|
||||
def test_parse_protobuf_beautifies_position_details(self) -> None:
|
||||
position = mesh_pb2.Position(
|
||||
latitude_i=454230020, longitude_i=-1228472320,
|
||||
location_source=mesh_pb2.Position.LOC_INTERNAL,
|
||||
ground_speed=2, ground_track=9000, PDOP=211, sats_in_view=8,
|
||||
)
|
||||
rendered = parse_protobuf({"decoded": {
|
||||
"portnum": "POSITION_APP", "payload": position.SerializeToString(),
|
||||
}})
|
||||
|
||||
self.assertIn("🌍 45.423002", rendered)
|
||||
self.assertIn("-122.847232", rendered)
|
||||
self.assertIn("📍 LOC_INTERNAL", rendered)
|
||||
self.assertIn("💨 2m/s", rendered)
|
||||
self.assertIn("🧭 90.0°", rendered)
|
||||
self.assertIn("🎯 PDOP:2.11", rendered)
|
||||
self.assertIn("🛰️ 8", rendered)
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
_get_modem_preset_name.cache_clear()
|
||||
self.saved_config = snapshot_config("node_sort")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
restore_config(self.saved_config)
|
||||
reset_singletons()
|
||||
reset_shared_state()
|
||||
|
||||
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
|
||||
config.node_sort = "lastHeard"
|
||||
@@ -32,19 +81,18 @@ class UtilsTests(unittest.TestCase):
|
||||
def test_add_new_message_groups_messages_by_hour(self) -> None:
|
||||
ui_state.all_messages = {"MediumFast": []}
|
||||
|
||||
with mock.patch("contact.utilities.utils.time.time", side_effect=[1000, 1000]):
|
||||
with mock.patch("contact.utilities.utils.time.strftime", return_value="[00:16:40] "):
|
||||
with mock.patch("contact.utilities.utils.datetime.datetime") as mocked_datetime:
|
||||
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "2025-02-04 17:00"
|
||||
add_new_message("MediumFast", ">> Test: ", "First")
|
||||
add_new_message("MediumFast", ">> Test: ", "Second")
|
||||
from datetime import datetime
|
||||
timestamp = datetime(2025, 2, 4, 17, 16, 40).timestamp()
|
||||
with mock.patch("contact.utilities.utils.time.time", return_value=timestamp):
|
||||
add_new_message("MediumFast", ">> Test: ", "First")
|
||||
add_new_message("MediumFast", ">> Test: ", "Second")
|
||||
|
||||
self.assertEqual(
|
||||
ui_state.all_messages["MediumFast"],
|
||||
[
|
||||
("-- 2025-02-04 17:00 --", ""),
|
||||
("[00:16:40] >> Test: ", "First"),
|
||||
("[00:16:40] >> Test: ", "Second"),
|
||||
("[17:16:40] >> Test: ", "First"),
|
||||
("[17:16:40] >> Test: ", "Second"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -60,6 +108,45 @@ class UtilsTests(unittest.TestCase):
|
||||
self.assertIn("MediumFast", ui_state.all_messages)
|
||||
self.assertIn("Another Channel", ui_state.all_messages)
|
||||
|
||||
def test_get_channel_name_handles_newer_known_modem_preset(self) -> None:
|
||||
device_channel = mock.Mock()
|
||||
device_channel.settings.name = ""
|
||||
node = mock.Mock()
|
||||
node.localConfig.lora.modem_preset = 14
|
||||
|
||||
with mock.patch("contact.utilities.utils.logging.warning") as warning:
|
||||
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
|
||||
|
||||
warning.assert_called_once()
|
||||
|
||||
def test_get_channel_name_handles_unknown_modem_preset(self) -> None:
|
||||
device_channel = mock.Mock()
|
||||
device_channel.settings.name = ""
|
||||
node = mock.Mock()
|
||||
node.localConfig.lora.modem_preset = 99
|
||||
|
||||
with mock.patch("contact.utilities.utils.logging.warning") as warning:
|
||||
self.assertEqual(_get_channel_name(device_channel, node), "UnknownPreset99")
|
||||
|
||||
warning.assert_called_once()
|
||||
|
||||
def test_get_channel_name_prefers_public_protobuf_enum_over_compatibility_map(self) -> None:
|
||||
device_channel = mock.Mock()
|
||||
device_channel.settings.name = ""
|
||||
node = mock.Mock()
|
||||
node.localConfig.lora.modem_preset = 14
|
||||
|
||||
with mock.patch.object(
|
||||
config_pb2.Config.LoRaConfig.ModemPreset,
|
||||
"Name",
|
||||
return_value="TINY_FAST",
|
||||
) as enum_name:
|
||||
with mock.patch("contact.utilities.utils.logging.warning") as warning:
|
||||
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
|
||||
|
||||
enum_name.assert_called_once_with(14)
|
||||
warning.assert_not_called()
|
||||
|
||||
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
|
||||
interface = build_demo_interface()
|
||||
interface.localNode.channels[0].settings.name = "Renamed Channel"
|
||||
|
||||
Reference in new issue
Block a user