Compare commits

..
Author SHA1 Message Date
pdxlocations a57823591a bump version to 1.4.23 2026-03-18 21:37:32 -07:00
66 changed files with 953 additions and 6859 deletions

No files matched your search

-8
View File
@@ -17,14 +17,6 @@
"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"]
}
]
}
+2 -17
View File
@@ -16,7 +16,8 @@ pip install contact
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.
<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<br><br>
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
@@ -45,14 +46,6 @@ 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" />
@@ -91,17 +84,9 @@ 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
+162 -8
View File
@@ -1,15 +1,169 @@
"""Parse command-line options before initializing Contact's runtime."""
#!/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 subprocess
import sys
import threading
import traceback
# 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.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
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()
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)
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
def prompt_region_if_unset(args: object) -> 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)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
def initialize_globals() -> None:
"""Initializes interface and shared globals."""
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()
load_messages_from_db()
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_interface(args)
if interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args)
initialize_globals()
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)
try:
curses.wrapper(main)
interface_state.interface.close()
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
interface_state.interface.close()
sys.exit(0)
except Exception as e:
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
print("Fatal error:", e)
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
+5 -91
View File
@@ -12,7 +12,6 @@ Reboot, "Reboot", ""
Reset Node DB, "Reset Node DB", ""
Shutdown, "Shutdown", ""
Factory Reset, "Factory Reset", ""
factory_reset_config, "Factory Reset Config", ""
Exit, "Exit", ""
Yes, "Yes", ""
No, "No", ""
@@ -56,7 +55,6 @@ confirm.reboot, "Are you sure you want to Reboot?", ""
confirm.reset_node_db, "Are you sure you want to Reset Node DB?", ""
confirm.shutdown, "Are you sure you want to Shutdown?", ""
confirm.factory_reset, "Are you sure you want to Factory Reset?", ""
confirm.factory_reset_config, "Are you sure you want to Factory Reset Config?", ""
confirm.save_before_exit_section, "You have unsaved changes in {section}. Save before exiting?", ""
prompt.select_region, "Select your region:", ""
dialog.slow_down_title, "Slow down", ""
@@ -67,8 +65,6 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
dialog.reply_unavailable_title, "Reply unavailable", ""
dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
@@ -76,13 +72,12 @@ help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.reply, "Ctrl+R = Reply to message at cursor", ""
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.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ or / = Search", ""
help.search, "Ctrl+/ = Search", ""
help.help, "Ctrl+K = Help", ""
help.no_help, "No help available.", ""
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
@@ -98,13 +93,7 @@ dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
user, "User"
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
isLicensed, "Enable licensed amateur (HAM) mode", "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."
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
[app_settings]
title, "App Settings", ""
@@ -118,25 +107,17 @@ 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", "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."
notification_sound, "Notification sound", ""
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
ping_bot, "Ping Bot", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
COLOR_CONFIG_LIGHT, "Theme colors (light)", ""
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."
[app_settings.color_config]
default, "Default", ""
background, "Background", ""
@@ -165,7 +146,6 @@ 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."
@@ -174,7 +154,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_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."
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."
[config.device]
title, "Device"
@@ -260,8 +240,6 @@ 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"
@@ -280,10 +258,6 @@ 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"
@@ -309,12 +283,6 @@ 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."
@@ -334,8 +302,6 @@ 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"
@@ -352,7 +318,6 @@ 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"
@@ -384,7 +349,6 @@ 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."
@@ -415,7 +379,6 @@ 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"
@@ -433,11 +396,9 @@ 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."
@@ -495,52 +456,5 @@ 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."
wifi_threshold, "Wi-Fi threshold", "Wi-Fi RSSI threshold. Defaults to -80."
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi 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."
-204
View File
@@ -1,204 +0,0 @@
##field_name, "Nom du champ lisible avec première lettre en majuscule", "Texte d'aide avec [warning]avertissements[/warning], [note]notes[/note], [underline]soulignements[/underline], \033[31mcodes couleur ANSI\033[0m et \nsauts de ligne."
Main Menu, "Menu principal", ""
User Settings, "Paramètres utilisateur", ""
Channels, "Canaux", ""
Radio Settings, "Paramètres radio", ""
Module Settings, "Paramètres des modules", ""
App Settings, "Paramètres de l'application", ""
Export Config File, "Exporter le fichier de configuration", ""
Load Config File, "Charger le fichier de configuration", ""
Config URL, "URL de configuration", ""
Reboot, "Redémarrer", ""
Reset Node DB, "Réinitialiser la base de données des nœuds", ""
Shutdown, "Éteindre", ""
Factory Reset, "Réinitialisation d'usine", ""
factory_reset_config, "Réinitialiser la configuration d'usine", ""
Exit, "Quitter", ""
Yes, "Oui", ""
No, "Non", ""
Cancel, "Annuler", ""
[ui]
save_changes, "Enregistrer les modifications", ""
dialog.invalid_input, "Entrée invalide", ""
prompt.enter_new_value, "Entrer une nouvelle valeur : ", ""
error.value_empty, "La valeur ne peut pas être vide.", ""
error.value_exact_length, "La valeur doit comporter exactement {length} caractères.", ""
error.value_min_length, "La valeur doit comporter au moins {length} caractères.", ""
error.value_max_length, "La valeur ne doit pas dépasser {length} caractères.", ""
error.digits_only, "Seuls les chiffres (0-9) sont autorisés.", ""
error.number_range, "Entrez un nombre entre {min_value} et {max_value}.", ""
error.float_invalid, "Doit être un nombre à virgule flottante valide.", ""
prompt.edit_admin_keys, "Modifier jusqu'à 3 clés administrateur :", ""
label.admin_key, "Clé administrateur", ""
error.admin_key_invalid, "Erreur : chaque clé doit être en Base64 valide et faire 32 octets !", ""
prompt.edit_values, "Modifier jusqu'à 3 valeurs :", ""
label.value, "Valeur", ""
prompt.enter_ip, "Entrez une adresse IP (xxx.xxx.xxx.xxx) :", ""
label.current, "Actuel", ""
label.new_value, "Nouvelle valeur", ""
label.editing, "Modification de {label}", ""
label.current_value, "Valeur actuelle :", ""
error.ip_invalid, "Adresse IP invalide. Réessayez.", ""
prompt.select_foreground_color, "Sélectionner la couleur de premier plan pour {label}", ""
prompt.select_background_color, "Sélectionner la couleur d'arrière-plan pour {label}", ""
prompt.select_value, "Sélectionner {label}", ""
confirm.save_before_exit, "Vous avez des modifications non enregistrées. Enregistrer avant de quitter ?", ""
prompt.config_filename, "Entrez un nom de fichier pour le fichier de configuration", ""
confirm.overwrite_file, "{filename} existe déjà. Écraser ?", ""
dialog.config_saved_title, "Fichier de configuration enregistré :", ""
dialog.no_config_files, " Aucun fichier de configuration trouvé. Exportez-en un d'abord.", ""
prompt.choose_config_file, "Choisissez un fichier de configuration", ""
confirm.load_config_file, "Êtes-vous sûr de vouloir charger {filename} ?", ""
prompt.config_url_current, "L'URL de configuration est actuellement : {value}", ""
confirm.load_config_url, "Êtes-vous sûr de vouloir charger cette configuration ?", ""
confirm.reboot, "Êtes-vous sûr de vouloir redémarrer ?", ""
confirm.reset_node_db, "Êtes-vous sûr de vouloir réinitialiser la base de données des nœuds ?", ""
confirm.shutdown, "Êtes-vous sûr de vouloir éteindre ?", ""
confirm.factory_reset, "Êtes-vous sûr de vouloir effectuer une réinitialisation d'usine ?", ""
confirm.factory_reset_config, "Êtes-vous sûr de vouloir réinitialiser la configuration d'usine ?", ""
confirm.save_before_exit_section, "Vous avez des modifications non enregistrées dans {section}. Enregistrer avant de quitter ?", ""
prompt.select_region, "Sélectionnez votre région :", ""
dialog.slow_down_title, "Ralentissez", ""
dialog.slow_down_body, "Veuillez attendre 2 secondes entre les messages.", ""
dialog.node_details_title, "📡 Détails du nœud : {name}", ""
dialog.traceroute_not_sent_title, "Traceroute non envoyé", ""
dialog.traceroute_not_sent_body, "Veuillez attendre {seconds} secondes avant d'envoyer un autre traceroute.", ""
dialog.traceroute_sent_title, "Traceroute envoyé à : {name}", ""
dialog.traceroute_sent_body, "Les résultats apparaîtront dans la fenêtre des messages.", ""
dialog.help_title, "Aide - Raccourcis clavier", ""
help.scroll, "Haut/Bas = Défilement", ""
help.switch_window, "Gauche/Droite = Changer de fenêtre", ""
help.jump_windows, "F1/F2/F3 = Aller à Canal/Messages/Nœuds", ""
help.enter, "ENTRÉE = Envoyer / Sélectionner", ""
help.settings, "` ou F12 = Paramètres", ""
help.quit, "ESC = Quitter", ""
help.packet_log, "Ctrl+P = Activer/désactiver le journal des paquets", ""
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.ignore, "Ctrl+G = Ignorer", ""
help.search, "Ctrl+/ ou / = Rechercher", ""
help.help, "Ctrl+K = Aide", ""
help.no_help, "Aucune aide disponible.", ""
[User Settings]
user, "Utilisateur", ""
longName, "Nom long du nœud", "Si vous êtes un opérateur radioamateur agréé et avez activé le mode HAM, cela doit être votre indicatif."
shortName, "Nom court du nœud", "Doit contenir jusqu'à 4 octets."
isLicensed, "Activer le mode radioamateur (HAM)", "IMPORTANT : lire la documentation Meshtastic avant d'activer."
[app_settings]
title, "Paramètres de l'application", ""
channel_list_16ths, "Largeur de la liste des canaux", ""
node_list_16ths, "Largeur de la liste des nœuds", ""
single_pane_mode, "Mode panneau unique", ""
db_file_path, "Chemin du fichier de base de données", ""
log_file_path, "Chemin du fichier journal", ""
node_configs_file_path, "Chemin des configurations des nœuds", ""
language, "Langue", ""
message_prefix, "Préfixe des messages", ""
sent_message_prefix, "Préfixe des messages envoyés", ""
notification_symbol, "Symbole de notification", ""
notification_sound, "Son de notification", ""
ack_implicit_str, "ACK (implicite)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (inconnu)", ""
node_sort, "Tri des nœuds", ""
theme, "Thème", ""
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."
[config.device]
title, "Appareil", ""
role, "Rôle", ""
serial_enabled, "Activer la console série", ""
button_gpio, "GPIO bouton", ""
buzzer_gpio, "GPIO buzzer", ""
rebroadcast_mode, "Mode de rediffusion", ""
node_info_broadcast_secs, "Intervalle diffusion infos nœud", ""
double_tap_as_button_press, "Double tap = bouton", ""
is_managed, "Activer mode géré", ""
disable_triple_click, "Désactiver triple clic", ""
tzdef, "Fuseau horaire", ""
led_heartbeat_disabled, "Désactiver LED heartbeat", ""
buzzer_mode, "Mode buzzer", ""
[config.network]
title, "Réseau", ""
wifi_enabled, "Wi-Fi activé", ""
wifi_ssid, "SSID Wi-Fi", ""
wifi_psk, "Mot de passe Wi-Fi", ""
ntp_server, "Serveur NTP", ""
eth_enabled, "Ethernet activé", ""
address_mode, "Mode IPv4", ""
ip, "Adresse IP", ""
gateway, "Passerelle", ""
subnet, "Sous-réseau", ""
dns, "DNS", ""
[config.display]
title, "Affichage", ""
screen_on_secs, "Durée écran actif", ""
gps_format, "Format GPS", ""
auto_screen_carousel_secs, "Intervalle carrousel", ""
compass_north_top, "Toujours nord en haut", ""
flip_screen, "Retourner écran", ""
units, "Unités préférées", ""
[config.bluetooth]
title, "Bluetooth", ""
enabled, "Activé", ""
mode, "Mode d'appairage", ""
fixed_pin, "Code PIN fixe", ""
[module.mqtt]
title, "MQTT", ""
enabled, "Activé", ""
address, "Adresse serveur", ""
username, "Nom d'utilisateur", ""
password, "Mot de passe", ""
encryption_enabled, "Chiffrement activé", ""
json_enabled, "JSON activé", ""
tls_enabled, "TLS activé", ""
[module.serial]
title, "Série", ""
enabled, "Activé", ""
echo, "Écho", ""
rxd, "GPIO réception", ""
txd, "GPIO transmission", ""
baud, "Débit en bauds", ""
timeout, "Délai", ""
[module.telemetry]
title, "Télémétrie", ""
device_update_interval, "Intervalle métriques appareil", ""
environment_update_interval, "Intervalle métriques environnement", ""
environment_measurement_enabled, "Télémétrie environnement activée", ""
[module.audio]
title, "Audio", ""
codec2_enabled, "Activé", ""
ptt_pin, "GPIO PTT", ""
bitrate, "Débit audio", ""
[module.remote_hardware]
title, "Matériel distant", ""
enabled, "Activé", ""
available_pins, "Broches disponibles", ""
[module.ambient_lighting]
title, "Éclairage ambiant", ""
led_state, "État LED", ""
current, "Courant", ""
red, "Rouge", ""
green, "Vert", ""
blue, "Bleu", ""
+1 -10
View File
@@ -12,7 +12,6 @@ Reboot, "Перезагрузить", ""
Reset Node DB, "Сбросить БД узлов", ""
Shutdown, "Выключить", ""
Factory Reset, "Сброс до заводских", ""
factory_reset_config, "Сбросить только конфигурацию", ""
Exit, "Выход", ""
Yes, "Да", ""
No, "Нет", ""
@@ -56,7 +55,6 @@ confirm.reboot, "Перезагрузить устройство?", ""
confirm.reset_node_db, "Сбросить БД узлов?", ""
confirm.shutdown, "Выключить устройство?", ""
confirm.factory_reset, "Сбросить до заводских настроек?", ""
confirm.factory_reset_config, "Сбросить только конфигурацию?", ""
confirm.save_before_exit_section, "Есть несохраненные изменения в {section}. Сохранить перед выходом?", ""
prompt.select_region, "Выберите ваш регион:", ""
dialog.slow_down_title, "Подождите", ""
@@ -79,7 +77,7 @@ help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ или / = Поиск", ""
help.search, "Ctrl+/ = Поиск", ""
help.help, "Ctrl+K = Справка", ""
help.no_help, "Нет справки.", ""
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
@@ -116,17 +114,10 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (неизвестный)", ""
node_sort, "Сортировка нод", ""
theme, "Тема", ""
ping_bot, "Пинг-бот", ""
COLOR_CONFIG_DARK, "Цвета темы (темная)", ""
COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", ""
COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.ping_bot]
title, "Пинг-бот", ""
enabled, "Включен", "Включить автоматические ответы пинг-бота."
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
response_word, "Ответное слово", "Ответное слово бота."
[app_settings.color_config]
default, "По умолчанию", ""
background, "Фон", ""
-103
View File
@@ -1,103 +0,0 @@
# A basic auto-responder bot that replies to specific messages when bot mode is enabled.
import logging
import threading
import time
from typing import Any, Dict
import contact.ui.default_config as config
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
def _get_bot_catch_words() -> set[str]:
"""Return normalized bot trigger words from app settings."""
raw_words = getattr(config, "ping_bot_catch_words", "ping; test")
words = {
word.strip().casefold()
for word in raw_words.replace(";", ",").split(",")
if word.strip()
}
return words or {"ping"}
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 is_bot_enabled():
return False
if not is_bot_message(message):
""" Only respond to specific messages. """
return False
from_node = packet.get("from")
if from_node is None:
return False
if from_node == interface_state.myNodeNum:
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
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)
hops = hop_start - hop_limit
details = []
if snr != -128:
details.append(f"SNR: {snr}")
if rssi != -128:
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
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:
details.append(f"Via: {transport_name}")
response_data_string = getattr(config, "ping_bot_response_word", "Pong!")
if details:
response_data_string += f" {', '.join(details)}"
def send_response_delayed() -> None:
try:
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not is_bot_enabled():
return
# 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
request_ui_redraw(channels=True, messages=True, scroll_messages_to_bottom=True)
logging.info("Bot response sent to %s on channel index %s", from_node, send_channel)
except Exception:
logging.exception("Bot response send failed for destination %s", from_node)
threading.Thread(target=send_response_delayed, name="bot-response", daemon=True).start()
return True
+37 -76
View File
@@ -13,14 +13,6 @@ _sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
def chat_is_selected() -> bool:
return (
ui_state.current_window == 1
and not ui_state.display_log
and not ui_state.log_viewer_open
)
def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
"""Schedule a notification sound after a short quiet period.
@@ -46,8 +38,7 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
with _sound_timer_lock:
if expected_request_time != _last_sound_request:
return
if not chat_is_selected():
play_sound()
play_sound()
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
@@ -55,11 +46,13 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
get_reply_context,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
request_ui_redraw,
)
from contact.utilities.db_handler import (
save_message_to_db,
@@ -69,45 +62,42 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.shared_state import ui_state, interface_state, app_state, menu_state
from contact.message_handlers.bot_handler import bot_respond
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
def play_sound():
try:
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
system = platform.system()
sound_path = None
executable = None
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 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")
if executable and sound_path:
player = os.path.basename(executable)
cmd = [executable, sound_path]
if player == "ffplay":
if executable == "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]
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)
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}")
@@ -131,7 +121,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
request_ui_redraw(packetlog=True)
draw_packetlog_win()
if ui_state.current_window == 4:
menu_state.need_redraw = True
@@ -142,27 +132,20 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if changed:
request_ui_redraw(nodes=True)
draw_node_list()
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
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 (
not chat_is_selected()
and str(config.notification_sound).casefold() not in {"none", "false", ""}
):
if config.notification_sound == "True":
schedule_notification_sound()
message_bytes = packet["decoded"]["payload"]
@@ -188,8 +171,6 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
channel_number = ui_state.channel_list.index(packet["from"])
bot_respond(packet, message_string, channel_number)
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
@@ -202,34 +183,14 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
# replyId is a field of Meshtastic's decrypted Data payload.
reply_id = packet["decoded"].get("replyId")
reply_context = get_reply_context(reply_id) if reply_id is not None else ""
add_new_message(
channel_id,
f"{config.message_prefix} [{hops}] {message_from_string} ",
f"{reply_context}{message_string}",
packet_id=packet.get("id"),
timestamp=timestamp,
)
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
if refresh_channels:
request_ui_redraw(channels=True)
draw_channel_list()
if refresh_messages:
request_ui_redraw(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=(ui_state.current_window == 1),
)
draw_messages_window(True)
save_message_to_db(
channel_id,
message_from_id,
message_string,
packet_id=packet.get("id"),
reply_id=reply_id,
timestamp=timestamp,
)
save_message_to_db(channel_id, message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")
+111 -122
View File
@@ -1,6 +1,6 @@
import time
from typing import Any, Dict, Optional
from typing import Any, Dict
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.shared_state import ui_state, interface_state, app_state
from contact.utilities.singleton import ui_state, interface_state
from contact.utilities.utils import add_new_message
@@ -28,149 +28,147 @@ def onAckNak(packet: Dict[str, Any]) -> None:
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import request_ui_redraw
from contact.ui.contact_ui import draw_messages_window
with app_state.lock:
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.ack_str
ack_type = "Ack"
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.nak_str
ack_type = "Nak"
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.nak_str
ack_type = "Nak"
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
request_ui_redraw(messages=True)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
draw_messages_window()
def on_response_traceroute(packet: Dict[str, Any]) -> None:
"""
Handle traceroute response packets and render the route visually in the UI.
"""
from contact.ui.contact_ui import add_notification, request_ui_redraw
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
with app_state.lock:
refresh_channels = False
refresh_messages = False
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
msg_str = "Traceroute to:\n"
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with origin of response
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
) # Start with origin of response
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0:
for idx, node_num in enumerate(msg_dict["route"]):
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
# End with destination of response (us)
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n"
msg_str += route_str + "\n" # Print the route back to us
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
if lenBack > 0:
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
msg_str += route_str + "\n"
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
save_message_to_db(channel_id, packet["from"], msg_str)
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if refresh_channels:
request_ui_redraw(channels=True)
if refresh_messages:
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: Optional[int] = None,
reply_context: str = "",
) -> None:
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
"""
Sends a chat message using the selected channel.
"""
@@ -183,32 +181,23 @@ def send_message(
elif isinstance(channel_id, str):
send_on_channel = channel
send_kwargs = {
"text": message,
"destinationId": destination,
"wantAck": True,
"wantResponse": False,
"onResponse": onAckNak,
"channelIndex": send_on_channel,
}
if reply_id is not None:
send_kwargs["replyId"] = reply_id
sent_message_data = interface_state.interface.sendText(**send_kwargs)
add_new_message(
channel_id,
config.sent_message_prefix + config.ack_unknown_str + ": ",
f"{reply_context}{message}",
packet_id=sent_message_data.id,
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
)
timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id)
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
timestamp = save_message_to_db(channel_id, myid, message)
ack_naks[sent_message_data.id] = {
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
"dbMessage": message,
}
-250
View File
@@ -1,250 +0,0 @@
#!/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)
+88 -6
View File
@@ -1,14 +1,96 @@
"""Standalone command-line entry point for node settings."""
import contextlib
import curses
import io
import logging
import sys
import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
from contact.utilities.arg_parser import setup_parser
from contact.utilities.interfaces import initialize_interface
def start(argv=None) -> None:
args = setup_parser().parse_args(argv)
from contact.settings_runtime import start as start_runtime
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
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)
start_runtime(args)
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)
interface.close()
interface = initialize_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
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__":
start()
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
-94
View File
@@ -1,94 +0,0 @@
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.
+273 -1154
View File
File diff suppressed because it is too large. Load diff
+29 -298
View File
@@ -3,15 +3,12 @@ import curses
import ipaddress
import logging
import os
import threading
import time
import sys
from typing import List
from meshtastic.protobuf import admin_pb2
from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import
from contact.utilities.interfaces import reconnect_interface
from contact.utilities.control_utils import transform_menu_path
from contact.utilities.i18n import t
from contact.utilities.ini_utils import parse_ini_file
@@ -24,23 +21,17 @@ 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, update_ham_fields
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor
from contact.utilities.shared_state import app_state, interface_state, menu_state
from contact.utilities.singleton import 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."""
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Compute the effective menu width for the current terminal
@@ -49,7 +40,7 @@ def get_menu_width() -> int:
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -91,8 +82,7 @@ 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))
breadcrumb = " > ".join(translated_parts)
return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb
return " > ".join(translated_parts)
def display_menu() -> tuple[object, object]:
@@ -139,12 +129,6 @@ 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):
@@ -238,171 +222,10 @@ def get_input_type_for_field(field) -> type:
return str
def reconnect_interface_with_splash(stdscr: object, interface: object) -> object:
try:
interface.close()
except Exception:
pass
new_interface = reconnect_interface(app_state.connection_args)
interface_state.interface = new_interface
redraw_main_ui_after_reconnect(stdscr)
return new_interface
def reconnect_after_admin_action(stdscr: object, interface: object, action, log_message: str) -> object:
action()
logging.info(log_message)
return reconnect_interface_with_splash(stdscr, interface)
def request_factory_reset(node: object, full: bool = False):
try:
return node.factoryReset(full=full)
except TypeError as ex:
field_name = "factory_reset_device" if full else "factory_reset_config"
field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name]
if field.cpp_type != field.CPPTYPE_INT32:
raise
node.ensureSessionKey()
message = admin_pb2.AdminMessage()
setattr(message, field_name, 1)
if node == node.iface.localNode:
on_response = None
else:
on_response = node.onAckNak
return node._sendAdmin(message, onResponse=on_response)
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
try:
from contact.ui import contact_ui
from contact.utilities.utils import get_channels, refresh_node_list
get_channels()
refresh_node_list()
if not contact_ui.ui_state.log_viewer_open:
contact_ui.handle_resize(stdscr, False)
except Exception:
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
failure = []
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:
def settings_menu(stdscr: object, interface: object) -> 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 = generate_menu_from_protobuf(interface)
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
@@ -507,15 +330,9 @@ def settings_menu(
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, node=node)
save_changes(interface, modified_settings, menu_state)
modified_settings.clear()
logging.info("Changes Saved")
if remote:
break
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
node = interface.localNode
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
if len(menu_state.menu_path) > 1:
menu_state.menu_path.pop()
@@ -545,7 +362,7 @@ def settings_menu(
filename += ".yaml"
try:
config_text = config_export(interface, node=node)
config_text = config_export(interface)
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
@@ -610,12 +427,12 @@ def settings_menu(
["Yes", "No"],
)
if overwrite == "Yes":
config_import(interface, file_path, node=node)
config_import(interface, file_path)
menu_state.start_index.pop()
continue
elif selected_option == "Config URL":
current_value = node.getURL()
current_value = interface.localNode.getURL()
new_value = get_text_input(
t(
"ui.prompt.config_url_current",
@@ -633,7 +450,7 @@ def settings_menu(
["Yes", "No"],
)
if overwrite == "Yes":
node.setURL(new_value)
interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node")
menu_state.start_index.pop()
continue
@@ -643,10 +460,8 @@ def settings_menu(
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr, interface, node.reboot, "Node Reboot Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
menu_state.start_index.pop()
continue
@@ -657,10 +472,8 @@ def settings_menu(
["Yes", "No"],
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
menu_state.start_index.pop()
continue
@@ -669,7 +482,7 @@ def settings_menu(
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
node.shutdown()
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
menu_state.start_index.pop()
continue
@@ -681,30 +494,8 @@ def settings_menu(
["Yes", "No"],
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(node, full=True),
"Factory Reset Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
elif selected_option == "factory_reset_config":
confirmation = get_list_input(
t("ui.confirm.factory_reset_config", default="Are you sure you want to Factory Reset Config?"),
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(node, full=False),
"Factory Reset Config Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
menu_state.start_index.pop()
continue
@@ -713,14 +504,12 @@ def settings_menu(
menu_win.refresh()
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
open_log_viewer = json_editor(stdscr, menu_state) # Open the App Settings menu
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)
@@ -734,25 +523,7 @@ def settings_menu(
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
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", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
@@ -760,7 +531,7 @@ def settings_menu(
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
elif selected_option in ["isLicensed", "isUnmessagable"]:
elif selected_option == "isLicensed":
new_value = get_list_input(
f"{human_readable_name} is currently: {current_value}",
str(current_value),
@@ -769,6 +540,9 @@ def settings_menu(
new_value = new_value == "True"
menu_state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in menu_state.current_menu.items():
modified_settings[option] = value
menu_state.start_index.pop()
elif selected_option in ["latitude", "longitude", "altitude"]:
@@ -789,11 +563,6 @@ def settings_menu(
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
menu_state.start_index.pop()
elif field is None and selected_option == "enabled" and menu_state.menu_path[1] == "Channels":
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
@@ -835,17 +604,8 @@ def settings_menu(
else: # Handle other field types
input_type = get_input_type_for_field(field)
allow_empty = (
menu_state.menu_path == ["Main Menu", "User Settings"]
and selected_option in ("short_name", "long_name")
) or (
selected_option == "name"
and len(menu_state.menu_path) == 3
and menu_state.menu_path[1] == "Channels"
)
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type,
allow_empty=allow_empty,
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
new_value = current_value if new_value is None else new_value
menu_state.start_index.pop()
@@ -870,35 +630,8 @@ def settings_menu(
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
menu_state.current_menu[selected_option] = (field, new_value)
if menu_state.menu_path == ["Main Menu", "User Settings"]:
update_ham_fields(menu_state.current_menu, node)
# Save the full HAM command, including fields that were not edited.
modified_settings.clear()
modified_settings.update({name: setting[1] for name, setting in menu_state.current_menu.items()})
menu_state.selected_index = list(menu_state.current_menu).index(selected_option)
else:
requested_remote_section = False
if remote:
try:
requested_remote_section = _request_remote_section(
node, menu_state.menu_path, selected_option, status_callback, 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.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
@@ -922,10 +655,8 @@ def settings_menu(
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
reconnect_required = save_changes(interface, modified_settings, menu_state)
save_changes(interface, modified_settings, menu_state)
logging.info("Changes Saved")
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
modified_settings.clear()
menu = rebuild_menu_at_current_path(interface, menu_state)
+5 -27
View File
@@ -56,13 +56,9 @@ def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client")
return fallback_dir
# Allow overriding config root via environment variable
config_root = os.getenv("CONTACT_CONFIG_ROOT")
if config_root:
if not _is_writable_dir(config_root):
raise RuntimeError(f"CONTACT_CONFIG_ROOT={config_root} is not writable")
else:
config_root = _get_config_root(parent_dir)
# Pick the root now.
config_root = _get_config_root(parent_dir)
# Paths (derived from the chosen root)
json_file_path = os.path.join(config_root, "config.json")
@@ -235,18 +231,13 @@ def initialize_config() -> Dict[str, object]:
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"notification_sound": "alert.mp3",
"notification_sound": "True",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
"ack_unknown_str": "[…]",
"node_sort": "lastHeard",
"theme": "dark",
"ping_bot": {
"enabled": "False",
"catch_words": "ping; test",
"response_word": "Pong!",
},
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
@@ -264,15 +255,6 @@ 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)
@@ -290,7 +272,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_enabled, ping_bot_catch_words, ping_bot_response_word
global node_sort, notification_sound
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
@@ -309,10 +291,6 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
ack_unknown_str = loaded_config["ack_unknown_str"]
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":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
+8 -7
View File
@@ -2,8 +2,8 @@ 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.shared_state import menu_state, ui_state
from contact.ui.nav_utils import draw_main_arrows
from contact.utilities.singleton import menu_state, ui_state
def dialog(title: str, message: str) -> None:
@@ -19,10 +19,10 @@ def dialog(title: str, message: str) -> None:
# Parse message into lines and calculate dimensions
message_lines = message.splitlines() or [""]
max_line_length = max(text_width(l) for l in message_lines)
max_line_length = max(len(l) for l in message_lines)
# Desired size
dialog_width = max(text_width(title) + 4, max_line_length + 4)
dialog_width = max(len(title) + 4, max_line_length + 4)
desired_height = len(message_lines) + 4
# Clamp dialog size to the screen (leave a 1-cell margin if possible)
@@ -61,7 +61,7 @@ def dialog(title: str, message: str) -> None:
# Title
try:
win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default"))
win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
except curses.error:
pass
@@ -80,8 +80,9 @@ def dialog(title: str, message: str) -> None:
if idx >= len(message_lines):
break
line = message_lines[idx]
trimmed = slice_to_width(line, max(0, dialog_width - 4))
msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2)
# Hard-trim lines that don't fit
trimmed = line[: max(0, dialog_width - 6)]
msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2)
try:
msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default"))
except curses.error:
+13 -68
View File
@@ -5,7 +5,7 @@ from collections import OrderedDict
from typing import Any, Union, Dict
from google.protobuf.message import Message
from meshtastic.protobuf import admin_pb2, channel_pb2, config_pb2, module_config_pb2
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
def encode_if_bytes(value: Any) -> str:
"""Encode byte values to base64 string."""
@@ -57,49 +57,14 @@ def extract_fields(
return menu
def update_ham_fields(user_menu, node):
"""Reflect the selected license flag using the installed HAM descriptors."""
params = admin_pb2.HamParameters()
fields = params.DESCRIPTOR.fields_by_name
licensed = user_menu.get("isLicensed", (None, False))[1] in (True, "True")
if not licensed:
call_sign = user_menu.get("call_sign", (None, ""))[1]
suffix = user_menu.get("long_name", (None, ""))[1]
user_menu.setdefault("longName", (None, " ".join(filter(None, (call_sign, suffix)))))
user_menu.setdefault("shortName", (None, user_menu.get("short_name", (None, ""))[1]))
for name in fields:
user_menu.pop(name, None)
return
owner_name = user_menu.get("longName", (None, ""))[1]
call_sign, _, suffix = owner_name.partition(" ")
defaults = {
"call_sign": call_sign,
"short_name": user_menu.get("shortName", (None, ""))[1],
"long_name": suffix,
"tx_power": node.localConfig.lora.tx_power,
"frequency": node.localConfig.lora.override_frequency,
}
for field in params.DESCRIPTOR.fields:
if field.name in defaults:
setattr(params, field.name, defaults[field.name])
for name, setting in extract_fields(params, params).items():
user_menu.setdefault(name, setting)
user_menu.pop("longName", None)
user_menu.pop("shortName", None)
def generate_menu_from_protobuf(interface: object, node: Any = None, include_app_settings: bool = True) -> Dict[str, Any]:
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
"""
Builds the full settings menu structure from the protobuf definitions.
"""
menu_structure = {"Main Menu": {}}
# Add User Settings
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))
current_node_info = interface.getMyNodeInfo() if interface else None
if current_node_info:
current_user_config = current_node_info.get("user", None)
@@ -108,40 +73,34 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
"longName": (None, current_user_config.get("longName", "Not Set")),
"shortName": (None, current_user_config.get("shortName", "Not Set")),
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
}
update_ham_fields(menu_structure["Main Menu"]["User Settings"], node)
else:
logging.info("User settings not found in Node Info")
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
else:
logging.info("Node Info not available")
menu_structure["Main Menu"]["User Settings"] = {}
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
# Add Channels
channel = channel_pb2.ChannelSettings()
menu_structure["Main Menu"]["Channels"] = {}
if node:
if interface:
for i in range(8):
current_channel = node.getChannelByChannelIndex(i)
current_channel = interface.localNode.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 = node.localConfig if node else None
current_radio_config = interface.localNode.localConfig if interface 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 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)),
"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)),
}
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
@@ -158,24 +117,11 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
# Add Module Settings
module = module_config_pb2.ModuleConfig()
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
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
# Add App Settings
if include_app_settings:
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Additional settings options
menu_structure["Main Menu"].update(
@@ -187,7 +133,6 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app
"Reset Node DB": None,
"Shutdown": None,
"Factory Reset": None,
"factory_reset_config": None,
"Exit": None,
}
)
+3 -47
View File
@@ -6,7 +6,7 @@ from contact.ui.colors import get_color
from contact.utilities.i18n import t
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.shared_state import interface_state, ui_state
from contact.utilities.singleton import interface_state, ui_state
def get_node_color(node_index: int, reverse: bool = False):
@@ -331,51 +331,6 @@ def text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def slice_to_width(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
width = 0
chars = []
for char in text:
char_width = text_width(char)
if width + char_width > max_width:
break
chars.append(char)
width += char_width
return "".join(chars)
def pad_to_width(text: str, width: int) -> str:
clipped = slice_to_width(text, width)
return clipped + (" " * max(0, width - text_width(clipped)))
def truncate_with_ellipsis(text: str, width: int) -> str:
if width <= 0:
return ""
if text_width(text) <= width:
return pad_to_width(text, width)
if width == 1:
return "…"
return pad_to_width(slice_to_width(text, width - 1) + "…", width)
def split_text_to_width_chunks(text: str, width: int) -> List[str]:
if width <= 0:
return [""]
chunks = []
remaining = text
while remaining:
chunk = slice_to_width(remaining, width)
if not chunk:
break
chunks.append(chunk)
remaining = remaining[len(chunk) :]
return chunks or [""]
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
@@ -398,7 +353,8 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
wrapped_lines.extend(split_text_to_width_chunks(word, wrap_width))
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
continue
if line_length + word_length > wrap_width and word.strip():
+5 -17
View File
@@ -1,10 +1,8 @@
import curses
import textwrap
from contact.ui.colors import get_color
from contact.version import __version__
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
def draw_splash(stdscr: object) -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
@@ -15,26 +13,16 @@ def draw_splash(stdscr: object, status: str = "connecting...") -> 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))
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.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.move(start_y + 5, start_x2)
stdscr.attrset(get_color("window_frame"))
stdscr.box()
+1 -26
View File
@@ -25,38 +25,14 @@ class ChatUIState:
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
input_focused: bool = False
input_cursor: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
redraw_channels: bool = False
redraw_messages: bool = False
redraw_nodes: bool = False
redraw_packetlog: bool = False
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)
highlighted_message_range: tuple = field(default_factory=tuple)
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
reply_id: Any = None
reply_context: str = ""
reply_id_unavailable: bool = False
@dataclass
@@ -68,4 +44,3 @@ class InterfaceState:
@dataclass
class AppState:
lock: Any = None
connection_args: Any = None
+28 -74
View File
@@ -1,4 +1,5 @@
import os
import json
import curses
from typing import Any, List, Dict, Optional
@@ -8,14 +9,12 @@ 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.shared_state import menu_state, ui_state
from contact.utilities.singleton import menu_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
@@ -87,32 +86,6 @@ 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.
@@ -142,23 +115,6 @@ 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: "
@@ -214,6 +170,14 @@ 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)
@@ -300,8 +264,6 @@ 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:
@@ -340,20 +302,14 @@ 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):
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)
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]
if isinstance(value, dict) or (isinstance(value, list) and len(value) != 2):
display_value = ">"
else:
display_value = f"{value}"[: w // 2 - 8]
display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
@@ -423,20 +379,27 @@ def update_app_settings_help(menu_win: curses.window, options: List[str]) -> Non
)
def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
file_path = config.json_file_path
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")
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# 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()
# 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)
data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu
@@ -495,14 +458,6 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
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)
@@ -606,18 +561,17 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
break
return False
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
config.reload_config()
setup_colors(reinit=True)
reload_translations(data.get("language"))
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
+3 -11
View File
@@ -1,19 +1,16 @@
from argparse import ArgumentParser
from contact.version import __version__
def setup_parser() -> ArgumentParser:
parser = ArgumentParser(
description="A terminal client for Meshtastic messaging and node settings.",
add_help=True,
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__)
connection_group = parser.add_argument_group(
connOuter = parser.add_argument_group(
"Connection", "Optional arguments to specify a device to connect to and how."
)
conn = connection_group.add_mutually_exclusive_group()
conn = connOuter.add_mutually_exclusive_group()
conn.add_argument(
"--port",
"--serial",
@@ -38,10 +35,5 @@ def setup_parser() -> ArgumentParser:
parser.add_argument(
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
)
parser.add_argument(
"--demo-screenshot",
help="Launch with a fake interface and seeded demo data for screenshots/testing.",
action="store_true",
)
return parser
+23 -28
View File
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename, node=None):
"""Apply a configuration file to the selected local or remote node."""
node = node or interface.localNode
def config_import(interface, filename):
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
node.beginSettingsTransaction()
interface.getNode("^local", False).beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
node.setOwner(configuration["owner"])
interface.getNode("^local", False).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
node.setOwner(long_name=None, short_name=configuration["owner_short"])
interface.getNode("^local", False).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
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
interface.getNode("^local", False).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']}")
node.setURL(configuration["channel_url"])
interface.getNode("^local").setURL(configuration["channel_url"])
time.sleep(0.5)
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
node.setURL(configuration["channelUrl"])
interface.getNode("^local").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,44 +185,39 @@ def config_import(interface, filename, node=None):
lon = float(configuration["location"]["lon"] or 0)
logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position")
node.setFixedPosition(lat, lon, alt)
interface.localNode.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
if "config" in configuration:
localConfig = node.localConfig
localConfig = interface.getNode("^local").localConfig
for section in configuration["config"]:
traverseConfig(section, configuration["config"][section], localConfig)
node.writeConfig(camel_to_snake(section))
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
if "module_config" in configuration:
moduleConfig = node.moduleConfig
moduleConfig = interface.getNode("^local").moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
node.writeConfig(camel_to_snake(section))
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
node.commitSettingsTransaction()
interface.getNode("^local", False).commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface, node=None) -> str:
"""Export the selected node's configuration as Meshtastic configure YAML."""
def config_export(interface) -> str:
"""used in --export-config"""
configObj = {}
node = node or interface.localNode
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()
owner = interface.getLongName()
owner_short = interface.getShortName()
channel_url = interface.localNode.getURL()
myinfo = interface.getMyNodeInfo()
pos = myinfo.get("position")
lat = None
lon = None
@@ -247,7 +242,7 @@ def config_export(interface, node=None) -> str:
if alt:
configObj["location"]["alt"] = alt
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
if config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -270,7 +265,7 @@ def config_export(interface, node=None) -> str:
else:
configObj["config"] = config
module_config = MessageToDict(node.moduleConfig)
module_config = MessageToDict(interface.localNode.moduleConfig)
if module_config:
# Convert inner keys to correct snake/camelCase
prefs = {}
+71 -167
View File
@@ -4,14 +4,11 @@ import logging
from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import build_reply_prefix, decimal_to_hex
from contact.utilities.utils import decimal_to_hex
import contact.ui.default_config as config
from contact.utilities.shared_state import ui_state, interface_state
MESSAGE_PAGE_SIZE = 100
from contact.utilities.singleton import ui_state, interface_state
def get_table_name(channel: str) -> str:
@@ -21,22 +18,7 @@ def get_table_name(channel: str) -> str:
return quoted_table_name
def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None:
"""Add fields introduced after the original message-history schema."""
table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")}
for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")):
if column not in table_columns:
db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}")
def save_message_to_db(
channel: str,
user_id: str,
message_text: str,
packet_id: Optional[int] = None,
reply_id: Optional[int] = None,
timestamp: Optional[int] = None,
) -> Optional[int]:
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
@@ -45,26 +27,20 @@ def save_message_to_db(
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT,
packet_id INTEGER,
reply_id INTEGER
ack_type TEXT
"""
ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
_ensure_message_columns(db_cursor, quoted_table_name)
if timestamp is None:
timestamp = int(time.time())
timestamp = int(time.time())
# Insert the message
insert_query = f"""
INSERT INTO {quoted_table_name}
(user_id, message_text, timestamp, ack_type, packet_id, reply_id)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id))
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_connection.commit()
return timestamp
@@ -77,8 +53,7 @@ def save_message_to_db(
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
update_query = f"""
UPDATE {get_table_name(channel)}
@@ -98,100 +73,35 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
logging.error(f"Unexpected error in update_ack_nak: {e}")
def _format_db_messages(db_messages, node_names):
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
hourly_messages = {}
known_messages = {}
for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages:
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
sanitized_message = message.replace("\x00", "")
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message)
else:
try:
fallback_name = decimal_to_hex(int(user_id))
except (TypeError, ValueError):
fallback_name = str(user_id)
formatted_message = (
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
sanitized_message,
)
if reply_id is not None and reply_id in known_messages:
referenced_prefix, referenced_message = known_messages[reply_id]
formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message)
hourly_messages.setdefault(hour, []).append((formatted_message, packet_id))
if packet_id is not None:
known_messages[packet_id] = formatted_message
formatted = []
packet_ids = []
for hour, messages in sorted(hourly_messages.items()):
formatted.append((f"-- {hour} --", ""))
packet_ids.append(None)
for formatted_message, packet_id in messages:
formatted.append(formatted_message)
packet_ids.append(packet_id)
return formatted, packet_ids
def _load_node_names(db_cursor):
table_name = f'"{interface_state.myNodeNum}_nodedb"'
try:
return {str(row[0]): row[1] for row in db_cursor.execute(f"SELECT user_id, short_name FROM {table_name}")}
except sqlite3.Error:
return {}
def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
def load_messages_from_db() -> None:
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
node_names = _load_node_names(db_cursor)
# Iterate through each table and fetch its messages
for table_name in tables:
quoted_table_name = (
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
)
_ensure_message_columns(db_cursor, quoted_table_name)
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query)
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {quoted_table_name}
ORDER BY rowid DESC LIMIT ?
"""
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
try:
# Fetch all messages from the table
db_cursor.execute(query, (page_size + 1,))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
db_cursor.execute(query)
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
# Extract the channel name from the table name
prefix = f"{interface_state.myNodeNum}_"
channel = table_name[len(prefix) : -len("_messages")]
channel = table_name.split("_")[1]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel
@@ -200,13 +110,53 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Replace the channel's messages with the freshly loaded page to avoid duplicates
formatted_messages, packet_ids = _format_db_messages(db_messages, node_names)
ui_state.all_messages[channel] = formatted_messages
ui_state.message_packet_ids[channel] = packet_ids
if db_messages:
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
# Ensure the channel exists in ui_state.all_messages
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {}
for row in db_messages:
user_id, message, timestamp, ack_type = row
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
if user_id == str(interface_state.myNodeNum):
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
sanitized_message,
)
else:
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -215,47 +165,6 @@ def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
logging.error(f"SQLite error in load_messages_from_db: {e}")
def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
"""Prepend one older page for a channel and return the number of messages loaded."""
before_rowid = ui_state.oldest_message_rowid.get(channel)
if before_rowid is None or not ui_state.has_older_messages.get(channel, False):
return 0
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {get_table_name(channel)}
WHERE rowid < ? ORDER BY rowid DESC LIMIT ?
"""
db_cursor.execute(query, (before_rowid, page_size + 1))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
if not db_messages:
ui_state.has_older_messages[channel] = False
return 0
older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor))
current = ui_state.all_messages.setdefault(channel, [])
current_packet_ids = ui_state.message_packet_ids.setdefault(channel, [])
while len(current_packet_ids) < len(current):
current_packet_ids.append(None)
# Keep the existing page's leading separator even when the older page
# falls in the same hour. Removing it makes a timestamp that the user
# is looking at jump out of view as soon as another page is loaded.
ui_state.all_messages[channel] = older + current
ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
return len(db_messages)
except sqlite3.Error as e:
logging.error(f"SQLite error loading older messages for channel '{channel}': {e}")
return 0
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
@@ -319,8 +228,7 @@ def update_node_info_in_db(
try:
ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
@@ -328,7 +236,6 @@ def update_node_info_in_db(
if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query)
db_connection.commit()
# Fetch existing values to preserve unchanged fields
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,))
@@ -404,8 +311,7 @@ def ensure_node_table_exists() -> None:
def ensure_table_exists(table_name: str, schema: str) -> None:
"""Ensure the given table exists in the database."""
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
db_cursor.execute(create_table_query)
@@ -425,8 +331,7 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
:return: The retrieved name or the hex of the user id
"""
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
# Construct table name
@@ -454,8 +359,7 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
def is_chat_archived(user_id: int) -> int:
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"'
-226
View File
@@ -1,226 +0,0 @@
import os
import sqlite3
import tempfile
from dataclasses import dataclass
from typing import Dict, List, Tuple, Union
import contact.ui.default_config as config
from contact.utilities.db_handler import get_table_name
DEMO_DB_FILENAME = "contact_demo_client.db"
DEMO_LOCAL_NODE_NUM = 0xC0DEC0DE
DEMO_BASE_TIMESTAMP = 1738717200 # 2025-02-04 17:00:00 UTC
DEMO_CHANNELS = ["MediumFast", "Another Channel"]
@dataclass
class DemoChannelSettings:
name: str
@dataclass
class DemoChannel:
role: bool
settings: DemoChannelSettings
@dataclass
class DemoLoRaConfig:
region: int = 1
modem_preset: int = 0
@dataclass
class DemoLocalConfig:
lora: DemoLoRaConfig
class DemoLocalNode:
def __init__(self, interface: "DemoInterface", channels: List[DemoChannel]) -> None:
self._interface = interface
self.channels = channels
self.localConfig = DemoLocalConfig(lora=DemoLoRaConfig())
def setFavorite(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isFavorite"] = True
def removeFavorite(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isFavorite"] = False
def setIgnored(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isIgnored"] = True
def removeIgnored(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isIgnored"] = False
def removeNode(self, node_num: int) -> None:
self._interface.nodesByNum.pop(node_num, None)
class DemoInterface:
def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List[DemoChannel]) -> None:
self.nodesByNum = nodes
self.nodes = self.nodesByNum
self.localNode = DemoLocalNode(self, channels)
def getMyNodeInfo(self) -> Dict[str, int]:
return {"num": DEMO_LOCAL_NODE_NUM}
def getNode(self, selector: str) -> DemoLocalNode:
if selector != "^local":
raise KeyError(selector)
return self.localNode
def close(self) -> None:
return
def build_demo_interface() -> DemoInterface:
channels = [DemoChannel(role=True, settings=DemoChannelSettings(name=name)) for name in DEMO_CHANNELS]
nodes = {
DEMO_LOCAL_NODE_NUM: _build_node(
DEMO_LOCAL_NODE_NUM,
"Meshtastic fb3c",
"fb3c",
hops=0,
snr=13.7,
last_heard_offset=5,
battery=88,
voltage=4.1,
favorite=True,
),
0xA1000001: _build_node(0xA1000001, "KG7NDX-N2", "N2", hops=1, last_heard_offset=18, battery=79, voltage=4.0),
0xA1000002: _build_node(0xA1000002, "Satellite II Repeater", "SAT2", hops=2, last_heard_offset=31),
0xA1000003: _build_node(0xA1000003, "Search for Discord/Meshtastic", "DISC", hops=1, last_heard_offset=46),
0xA1000004: _build_node(0xA1000004, "K7EOK Mobile", "MOBL", hops=1, last_heard_offset=63, battery=52),
0xA1000005: _build_node(0xA1000005, "Turtle", "TRTL", hops=3, last_heard_offset=87),
0xA1000006: _build_node(0xA1000006, "CARS Trewvilliger Plaza", "CARS", hops=2, last_heard_offset=121),
0xA1000007: _build_node(0xA1000007, "No Hands!", "NHDS", hops=1, last_heard_offset=155),
0xA1000008: _build_node(0xA1000008, "McCutie", "MCCU", hops=2, last_heard_offset=211, ignored=True),
0xA1000009: _build_node(0xA1000009, "K1PDX", "K1PX", hops=2, last_heard_offset=267),
0xA100000A: _build_node(0xA100000A, "Arnold Creek", "ARND", hops=1, last_heard_offset=301),
0xA100000B: _build_node(0xA100000B, "Nansen", "NANS", hops=1, last_heard_offset=355),
0xA100000C: _build_node(0xA100000C, "Kodin 1", "KOD1", hops=2, last_heard_offset=402),
0xA100000D: _build_node(0xA100000D, "PH1", "PH1", hops=3, last_heard_offset=470),
0xA100000E: _build_node(0xA100000E, "Luna", "LUNA", hops=1, last_heard_offset=501),
0xA100000F: _build_node(0xA100000F, "sputnik1", "SPUT", hops=1, last_heard_offset=550),
0xA1000010: _build_node(0xA1000010, "K7EOK Maplewood West", "MAPL", hops=2, last_heard_offset=602),
0xA1000011: _build_node(0xA1000011, "KE7YVU 2", "YVU2", hops=2, last_heard_offset=655),
0xA1000012: _build_node(0xA1000012, "DNET", "DNET", hops=1, last_heard_offset=702),
0xA1000013: _build_node(0xA1000013, "Green Bluff", "GBLF", hops=1, last_heard_offset=780),
0xA1000014: _build_node(0xA1000014, "Council Crest Solar", "CCST", hops=2, last_heard_offset=830),
0xA1000015: _build_node(0xA1000015, "Meshtastic 61c7", "61c7", hops=1, last_heard_offset=901),
0xA1000016: _build_node(0xA1000016, "Bella", "BELA", hops=2, last_heard_offset=950),
0xA1000017: _build_node(0xA1000017, "Mojo Solar Base 4f12", "MOJO", hops=1, last_heard_offset=1010, favorite=True),
}
return DemoInterface(nodes=nodes, channels=channels)
def configure_demo_database(base_dir: str = "") -> str:
if not base_dir:
base_dir = tempfile.mkdtemp(prefix="contact_demo_")
os.makedirs(base_dir, exist_ok=True)
db_path = os.path.join(base_dir, DEMO_DB_FILENAME)
if os.path.exists(db_path):
os.remove(db_path)
config.db_file_path = db_path
return db_path
def seed_demo_messages() -> None:
schema = """
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
"""
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
cursor = db_connection.cursor()
for channel_name, rows in _demo_messages().items():
table_name = get_table_name(channel_name)
cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})")
cursor.executemany(
f"""
INSERT INTO {table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
""",
rows,
)
db_connection.commit()
def _build_node(
node_num: int,
long_name: str,
short_name: str,
*,
hops: int,
last_heard_offset: int,
snr: float = 0.0,
battery: int = 0,
voltage: float = 0.0,
favorite: bool = False,
ignored: bool = False,
) -> Dict[str, object]:
node = {
"num": node_num,
"user": {
"longName": long_name,
"shortName": short_name,
"hwModel": "TBEAM",
"role": "CLIENT",
"publicKey": f"pk-{node_num:08x}",
"isLicensed": True,
},
"lastHeard": DEMO_BASE_TIMESTAMP + 3600 - last_heard_offset,
"hopsAway": hops,
"isFavorite": favorite,
"isIgnored": ignored,
}
if snr:
node["snr"] = snr
if battery:
node["deviceMetrics"] = {
"batteryLevel": battery,
"voltage": voltage or 4.0,
"uptimeSeconds": 86400 + node_num % 10000,
"channelUtilization": 12.5 + (node_num % 7),
"airUtilTx": 4.5 + (node_num % 5),
}
if node_num % 3 == 0:
node["position"] = {
"latitude": 45.5231 + ((node_num % 50) * 0.0001),
"longitude": -122.6765 - ((node_num % 50) * 0.0001),
"altitude": 85 + (node_num % 20),
}
return node
def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, Union[str, None]]]]:
return {
"MediumFast": [
(str(DEMO_LOCAL_NODE_NUM), "Help, I'm stuck in a ditch!", DEMO_BASE_TIMESTAMP + 45, "Ack"),
("2701131778", "Do you require a alpinist?", DEMO_BASE_TIMESTAMP + 80, None),
(str(DEMO_LOCAL_NODE_NUM), "I don't know what that is.", DEMO_BASE_TIMESTAMP + 104, "Implicit"),
],
"Another Channel": [
("2701131788", "Weather is holding for the summit push.", DEMO_BASE_TIMESTAMP + 220, None),
(str(DEMO_LOCAL_NODE_NUM), "Copy that. Keep me posted.", DEMO_BASE_TIMESTAMP + 260, "Ack"),
],
2701131788: [
("2701131788", "Ping me when you are back at the trailhead.", DEMO_BASE_TIMESTAMP + 330, None),
(str(DEMO_LOCAL_NODE_NUM), "Will do.", DEMO_BASE_TIMESTAMP + 350, "Ack"),
],
}
+5 -8
View File
@@ -2,14 +2,14 @@ import base64
import binascii
import curses
import ipaddress
from typing import Optional, List
from typing import Any, 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.shared_state import menu_state
from contact.utilities.singleton 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, *, allow_empty: bool = False) -> Optional[str]:
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
"""Handles user input with wrapped text for long prompts."""
def redraw_input_win():
@@ -127,9 +127,6 @@ def get_text_input(prompt: str, selected_config: str, input_type: str, *, allow_
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."),
@@ -370,7 +367,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
pass # Ignore invalid character inputs
from contact.utilities.shared_state import menu_state # Required if not already imported
from contact.utilities.singleton import menu_state # Required if not already imported
def get_repeated_input(current_value: List[str]) -> Optional[str]:
@@ -460,7 +457,7 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
pass
from contact.utilities.shared_state import menu_state # Ensure this is imported
from contact.utilities.singleton import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
+5 -92
View File
@@ -1,61 +1,12 @@
import logging
import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
from meshtastic.protobuf import mesh_pb2
class _StatusMessageConfigMixin:
"""Read status-message config omitted by Meshtastic 2.7.11's receiver."""
def _handleFromRadio(self, fromRadioBytes):
packet = mesh_pb2.FromRadio()
packet.ParseFromString(fromRadioBytes)
if packet.HasField("moduleConfig") and packet.moduleConfig.HasField("statusmessage"):
self.localNode.moduleConfig.statusmessage.CopyFrom(packet.moduleConfig.statusmessage)
return super()._handleFromRadio(fromRadioBytes)
class SerialInterface(_StatusMessageConfigMixin, meshtastic.serial_interface.SerialInterface):
pass
class TCPInterface(_StatusMessageConfigMixin, meshtastic.tcp_interface.TCPInterface):
pass
class BLEInterface(_StatusMessageConfigMixin, meshtastic.ble_interface.BLEInterface):
pass
def interface_is_connected(interface) -> bool:
"""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):
def initialize_interface(args):
try:
if args.ble:
return BLEInterface(args.ble if args.ble != "any" else None)
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
try:
@@ -64,15 +15,12 @@ def initialize_interface(args, status_callback=None):
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
return TCPInterface(tcp_hostname, portNumber=tcp_port)
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
except Exception as ex:
logging.error(f"Error connecting to {args.host}. {ex}")
else:
try:
# 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)
client = meshtastic.serial_interface.SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
@@ -85,9 +33,7 @@ def initialize_interface(args, status_callback=None):
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:
if status_callback:
status_callback("No serial device found.\nConnecting via TCP to localhost…")
client = TCPInterface("localhost")
client = meshtastic.tcp_interface.TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
@@ -95,36 +41,3 @@ def initialize_interface(args, status_callback=None):
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")
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_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("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
-26
View File
@@ -1,26 +0,0 @@
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
+39 -139
View File
@@ -1,83 +1,11 @@
from meshtastic.protobuf import admin_pb2, channel_pb2
from meshtastic.protobuf import channel_pb2
from google.protobuf.message import Message
import logging
import base64
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
POWER_REBOOT_KEYS = {
"device_battery_ina_address",
"is_power_saving",
"ls_secs",
"min_wake_secs",
"on_battery_shutdown_after_secs",
"sds_secs",
"wait_bluetooth_secs",
}
DISPLAY_REBOOT_KEYS = {"screen_on_secs", "flip_screen", "oled", "displaymode"}
LORA_REBOOT_KEYS = {
"use_preset",
"region",
"modem_preset",
"bandwidth",
"spread_factor",
"coding_rate",
"tx_power",
"frequency_offset",
"override_frequency",
"channel_num",
"sx126x_rx_boosted_gain",
}
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
import time
def _collect_changed_keys(modified_settings):
changed = set()
for key, value in modified_settings.items():
if isinstance(value, dict):
changed.update(_collect_changed_keys(value))
else:
changed.add(key)
return changed
def _requires_reconnect(menu_state, modified_settings) -> bool:
if not modified_settings or len(menu_state.menu_path) < 2:
return False
section = menu_state.menu_path[1]
changed_keys = _collect_changed_keys(modified_settings)
if section == "Module Settings":
return True
if section == "User Settings":
return bool(changed_keys & USER_RECONNECT_KEYS)
if section == "Channels":
return False
if section != "Radio Settings" or len(menu_state.menu_path) < 3:
return False
config_category = menu_state.menu_path[2].lower()
if config_category in {"network", "bluetooth"}:
return True
if config_category == "security":
return not changed_keys.issubset(SECURITY_NON_REBOOT_KEYS)
if config_category == "device":
return bool(changed_keys & DEVICE_REBOOT_KEYS)
if config_category == "power":
return bool(changed_keys & POWER_REBOOT_KEYS)
if config_category == "display":
return bool(changed_keys & DISPLAY_REBOOT_KEYS)
if config_category == "lora":
return bool(changed_keys & LORA_REBOOT_KEYS)
# Firmware defaults most config writes to reboot-required unless a handler
# explicitly clears that flag.
return True
def save_changes(interface, modified_settings, menu_state, node=None):
def save_changes(interface, modified_settings, menu_state):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -87,9 +15,9 @@ def save_changes(interface, modified_settings, menu_state, node=None):
try:
if not modified_settings:
logging.info("No changes to save. modified_settings is empty.")
return False
return
node = node or interface.getNode("^local")
node = interface.getNode("^local")
admin_key_backup = None
if "admin_key" in modified_settings:
# Get reference to security config
@@ -99,14 +27,22 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
# 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))
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!")
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
@@ -115,7 +51,7 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Return early if there are no other settings left to process
if not modified_settings:
return _requires_reconnect(menu_state, {"admin_key": admin_key_backup})
return
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs
@@ -125,78 +61,53 @@ def save_changes(interface, modified_settings, menu_state, node=None):
lon = float(modified_settings.get("longitude", 0.0))
alt = int(modified_settings.get("altitude", 0))
node.setFixedPosition(lat, lon, alt)
interface.localNode.setFixedPosition(lat, lon, alt)
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return False
return
elif menu_state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings"
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
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)
node.setOwner(long_name, short_name, is_licensed)
logging.info(
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
)
return _requires_reconnect(menu_state, modified_settings)
return
elif menu_state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
try:
channels_index = menu_state.menu_path.index("Channels")
channel = menu_state.menu_path[channels_index + 1]
channel = menu_state.menu_path[-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 == "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
if key == "psk": # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key in {"position_precision", "is_muted"}:
# These fields belong to ChannelSettings.module_settings,
# not ChannelSettings itself.
setattr(channel.settings.module_settings, key, value)
elif key == "position_precision": # Special case: module_settings
channel.settings.module_settings.position_precision = 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}")
logging.info(node.channels)
return False
return
else:
config_category = None
@@ -209,7 +120,7 @@ def save_changes(interface, modified_settings, menu_state, node=None):
config_container = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
return False
return
if len(menu_state.menu_path) >= 4:
nested_key = menu_state.menu_path[3]
@@ -248,24 +159,13 @@ def save_changes(interface, modified_settings, menu_state, node=None):
# Write the configuration changes to the node
try:
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)
node.writeConfig(config_category)
logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup
return _requires_reconnect(menu_state, modified_settings)
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
return False
except Exception as e:
logging.error(f"Error saving changes: {e}")
return False
File renamed without changes.
+8 -34
View File
@@ -13,29 +13,9 @@ 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':''}
}
@@ -75,20 +55,18 @@ def get_chunks(data):
parsed=""
for item in reading:
key, value = item.split(":", 1)
key = key.strip()
value = value.strip()
key, value = item.split(":")
# If value is float, round it to the 1 digit after point
# else make it int
try:
value = int(value)
except ValueError:
if "." in value:
value = round(float(value.strip()),1)
else:
try:
value = round(float(value), 1)
except ValueError:
# Leave nonnumeric values intact, including strings with periods.
pass
value = int(value.strip())
except Exception:
# Leave it string as last resort
value = value
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
@@ -101,10 +79,6 @@ 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")
+27 -121
View File
@@ -1,89 +1,44 @@
import datetime
import logging
import re
import time
from functools import lru_cache
from typing import Union
from typing import Optional, Union
from google.protobuf.message import DecodeError
from meshtastic import protocols
from meshtastic.protobuf import config_pb2, portnums_pb2
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
import contact.ui.default_config as config
from contact.utilities.shared_state import ui_state, interface_state
from contact.utilities.singleton 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
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)
def get_channels():
"""Retrieve channels from the node and rebuild named channel state."""
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
node = interface_state.interface.getNode("^local")
device_channels = node.channels
previous_channel_list = list(ui_state.channel_list)
previous_messages = dict(ui_state.all_messages)
named_channels = []
# Clear and rebuild channel list
# ui_state.channel_list = []
for device_channel in device_channels:
if device_channel.role:
named_channels.append(_get_channel_name(device_channel, node))
# Use the channel name if available, otherwise use the modem preset
if device_channel.settings.name:
channel_name = device_channel.settings.name
else:
# If channel name is blank, use the modem preset
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
channel_name = convert_to_camel_case(modem_preset_string)
previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)]
preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)]
rebuilt_messages = {}
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
for index, channel_name in enumerate(named_channels):
previous_name = previous_named_channels[index] if index < len(previous_named_channels) else channel_name
if previous_name in previous_messages:
rebuilt_messages[channel_name] = previous_messages[previous_name]
elif channel_name in previous_messages:
rebuilt_messages[channel_name] = previous_messages[channel_name]
else:
rebuilt_messages[channel_name] = []
for channel in preserved_direct_channels:
if channel in previous_messages:
rebuilt_messages[channel] = previous_messages[channel]
ui_state.channel_list = named_channels + preserved_direct_channels
ui_state.all_messages = rebuilt_messages
if ui_state.channel_list:
ui_state.selected_channel = max(0, min(ui_state.selected_channel, len(ui_state.channel_list) - 1))
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
return ui_state.channel_list
@@ -186,57 +141,13 @@ def get_time_ago(timestamp):
return "now"
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()
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}> "
def get_reply_context(reply_id):
"""Find the locally displayed message referred to by a Meshtastic reply ID."""
for channel, packet_ids in ui_state.message_packet_ids.items():
for index, packet_id in enumerate(packet_ids):
if packet_id == reply_id and index < len(ui_state.all_messages.get(channel, [])):
prefix, message = ui_state.all_messages[channel][index]
return build_reply_prefix(prefix, message)
return ""
def add_new_message(channel_id, prefix, message, packet_id=None, timestamp=None):
def add_new_message(channel_id, prefix, message):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
packet_ids = ui_state.message_packet_ids.setdefault(channel_id, [])
while len(packet_ids) < len(ui_state.all_messages[channel_id]):
packet_ids.append(None)
# Timestamp handling
if timestamp is None:
timestamp = time.time()
message_time = datetime.datetime.fromtimestamp(timestamp)
current_hour = message_time.strftime("%Y-%m-%d %H:00")
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
# Retrieve the last timestamp if available
channel_messages = ui_state.all_messages[channel_id]
@@ -254,12 +165,10 @@ def add_new_message(channel_id, prefix, message, packet_id=None, timestamp=None)
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
packet_ids.append(None)
# Add the message
ts_str = message_time.strftime("[%H:%M:%S] ")
ts_str = time.strftime("[%H:%M:%S] ")
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
packet_ids.append(packet_id)
def parse_protobuf(packet: dict) -> Union[str, dict]:
@@ -298,9 +207,6 @@ def parse_protobuf(packet: dict) -> Union[str, dict]:
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
return tb.get_chunks(str(pb.environment_metrics))
if hasattr(pb, "local_stats") and pb.HasField("local_stats"):
return tb.get_chunks(str(pb.local_stats))
# For other data, without implemented beautification, fallback to just printing the object
return str(pb).replace("\n", " ").replace("\r", " ").strip()
-8
View File
@@ -1,8 +0,0 @@
"""Application version shared by the CLI and UI."""
from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("contact")
except PackageNotFoundError:
__version__ = "dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.7.4"
version = "1.4.23"
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
View File
@@ -1 +0,0 @@
-26
View File
@@ -1,26 +0,0 @@
import unittest
from contact.utilities.arg_parser import __version__, setup_parser
class ArgParserTests(unittest.TestCase):
def test_demo_screenshot_flag_is_supported(self) -> None:
args = setup_parser().parse_args(["--demo-screenshot"])
self.assertTrue(args.demo_screenshot)
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__)
-64
View File
@@ -1,64 +0,0 @@
import unittest
import importlib
import sys
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):
@classmethod
def setUpClass(cls) -> None:
sys.modules.setdefault(
"contact.message_handlers.tx_handler",
types.SimpleNamespace(send_message=mock.Mock()),
)
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"))
self.assertTrue(self.bot_handler.is_bot_message("test"))
self.assertFalse(self.bot_handler.is_bot_message("hello"))
def test_is_bot_message_ignores_empty_config_values(self) -> None:
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()
-63
View File
@@ -1,63 +0,0 @@
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")
-66
View File
@@ -1,66 +0,0 @@
import unittest
from types import SimpleNamespace
from tempfile import NamedTemporaryFile
from unittest.mock import Mock
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):
def test_split_compound_name_preserves_multi_part_values(self) -> None:
self.assertEqual(splitCompoundName("config.device.role"), ["config", "device", "role"])
def test_split_compound_name_duplicates_single_part_values(self) -> None:
self.assertEqual(splitCompoundName("owner"), ["owner", "owner"])
def test_is_repeated_field_prefers_new_style_attribute(self) -> None:
field = type("Field", (), {"is_repeated": True})()
self.assertTrue(_is_repeated_field(field))
def test_is_repeated_field_falls_back_to_label_comparison(self) -> None:
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()
-750
View File
@@ -1,750 +0,0 @@
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.shared_state import ui_state
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class ContactUiTests(unittest.TestCase):
def test_refresh_pad_clips_to_pad_and_resized_screen(self):
for pad_size, screen_size, expected in (
((2, 5), (24, 80), (1, 0, 1, 1, 1, 5)),
((100, 100), (6, 8), (1, 0, 1, 1, 5, 7)),
):
with self.subTest(pad_size=pad_size, screen_size=screen_size), ExitStack() as stack:
box = mock.Mock()
box.getmaxyx.return_value = (40, 100)
box.getbegyx.return_value = (0, 0)
pad = mock.Mock()
pad.getmaxyx.return_value = pad_size
ui_state.selected_channel = 38
stack.enter_context(mock.patch.object(contact_ui, "channel_win", box, create=True))
stack.enter_context(mock.patch.object(contact_ui, "channel_pad", pad, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", screen_size[0], create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", screen_size[1], create=True))
contact_ui.refresh_pad(0, commit=False)
pad.noutrefresh.assert_called_once_with(*expected)
pad.noutrefresh.side_effect = contact_ui.curses.error("resize during refresh")
contact_ui.refresh_pad(0, commit=False)
self.assertTrue(ui_state.redraw_full_ui)
def test_f5_rebuilds_background_before_staging_after_terminal_resize(self):
for size in ((24, 80), (50, 140)):
with self.subTest(size=size), ExitStack() as stack:
ui_state.node_list = [101]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={101: {"user": {"shortName": "NODE"}}})
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", 40, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", 100, create=True))
win = mock.Mock()
def read_key():
if (contact_ui.curses.LINES, contact_ui.curses.COLS) == (40, 100):
contact_ui.curses.LINES, contact_ui.curses.COLS = size
return contact_ui.curses.KEY_RESIZE
return 27
win.getch.side_effect = read_key
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
for name in ("get_color", "refresh_node_selection"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
events = []
def resize(*args):
events.append(("resize", ui_state.current_window))
def stage(*args):
events.append(("stage", ui_state.current_window))
stack.enter_context(mock.patch.object(contact_ui, "handle_resize", side_effect=resize))
stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background", side_effect=stage))
contact_ui.handle_f5_key(mock.Mock())
self.assertEqual(events, [("resize", 2), ("stage", 4), ("resize", 2)])
self.assertEqual(ui_state.current_window, 2)
def test_main_ui_accepts_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key, "\x1b"]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
stack.enter_context(mock.patch.object(contact_ui, name))
enter = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
contact_ui.main_ui(mock.Mock())
enter.assert_called_once_with("")
def test_search_closes_on_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=0))
stack.enter_context(mock.patch.object(contact_ui, "draw_centered_text_field"))
contact_ui.search(2)
entry.get_wch.assert_called_once_with()
self.assertEqual(entry.timeout.call_args_list, [mock.call(-1), mock.call(200)])
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
ui_state.node_list = [101, 102]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
102: {"user": {"shortName": "SMALL", "longName": "B"}},
})
win = mock.Mock()
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
events = mock.Mock()
events.attach_mock(win.noutrefresh, "dialog")
stdscr = mock.Mock()
with ExitStack() as stack:
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
for name, value in (("LINES", 40), ("COLS", 100)):
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("get_color", "refresh_node_selection", "handle_resize"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
events.attach_mock(restore, "restore")
contact_ui.handle_f5_key(stdscr)
self.assertEqual(events.mock_calls, [
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
mock.call.restore(stdscr, 2), mock.call.dialog(),
])
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
self.assertEqual(win.refresh.call_count, 1) # Only on close.
def test_background_restore_stages_panes_without_flushing(self):
ui_state.current_window = 4
ui_state.single_pane_mode = True
stdscr = mock.Mock()
with ExitStack() as stack:
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
contact_ui.stage_node_details_background(stdscr, 2)
refresh.assert_called_once_with(2, commit=False)
update.assert_not_called()
stdscr.noutrefresh.assert_called_once()
self.assertEqual(ui_state.current_window, 4)
def setUp(self) -> None:
reset_shared_state()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_shared_state()
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 1
config.single_pane_mode = "False"
with mock.patch.object(contact_ui.curses, "curs_set") as curs_set:
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch.object(contact_ui, "get_channels") as get_channels:
with mock.patch.object(contact_ui, "refresh_node_list") as refresh_node_list:
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_backtick(stdscr)
settings_menu.assert_called_once()
get_channels.assert_called_once_with()
refresh_node_list.assert_called_once_with()
handle_resize.assert_called_once_with(stdscr, False)
self.assertEqual(curs_set.call_args_list[0].args, (0,))
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
ui_state.redraw_messages = True
ui_state.redraw_nodes = True
ui_state.redraw_packetlog = True
ui_state.scroll_messages_to_bottom = True
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win:
contact_ui.process_pending_ui_updates(stdscr)
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_packetlog_win.assert_called_once_with()
def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_full_ui = True
ui_state.redraw_channels = True
ui_state.redraw_messages = True
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.process_pending_ui_updates(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_messages = True
ui_state.scroll_messages_to_bottom = True
ui_state.preserve_message_selection = True
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
contact_ui.process_pending_ui_updates(stdscr)
draw_messages_window.assert_called_once_with(True, preserve_selection=True)
self.assertFalse(ui_state.preserve_message_selection)
def test_draw_messages_resizes_pad_once(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]}
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (2, 40)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 40)
contact_ui.messages_win.getbegyx.return_value = (0, 0)
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_packetlog_win"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_messages_window()
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: ",
"This is a message long enough to be shortened for the reply marker.",
)
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"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.message_packet_ids = {"Primary": [1234]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
self.assertEqual(ui_state.reply_id, 1234)
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
ui_state.current_window = 1
ui_state.reply_id = 1234
ui_state.reply_context = "<Re: B1G1: Good > "
ui_state.reply_id_unavailable = False
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
self.assertIsNone(ui_state.reply_id)
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"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
self.assertTrue(ui_state.reply_id_unavailable)
def test_refresh_message_highlight_marks_selected_message(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
ui_state.selected_message = 2
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
ui_state.current_window = 0
ui_state.highlighted_message_range = (2, 3, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
self.assertEqual(ui_state.highlighted_message_range, ())
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
ui_state.current_window = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
with mock.patch.object(contact_ui, "refresh_message_highlight"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.handle_end()
self.assertEqual(ui_state.selected_message, 99)
self.assertGreater(ui_state.start_index[1], 0)
def test_message_selection_can_reach_last_rendered_line(self) -> None:
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.set_message_selection(99)
self.assertEqual(ui_state.selected_message, 99)
self.assertEqual(ui_state.start_index[1], 90)
def test_switching_to_messages_redraws_at_bottom(self) -> None:
ui_state.current_window = 0
ui_state.single_pane_mode = False
with mock.patch.object(contact_ui, "refresh_main_window"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "refresh_message_highlight"):
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
draw_messages_window.assert_called_once_with(True)
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
ui_state.selected_message = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 3)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 5)
contact_ui.move_message_selection(-1)
self.assertEqual(ui_state.selected_message, 3)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
ui_state.start_index = [0, 0, 0]
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_pad.getmaxyx.return_value = (4, 20)
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
interface = mock.Mock()
interface.nodesByNum = {101: {}, 202: {}}
with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]):
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
contact_ui.refresh_node_selection(old_index=0, highlight=True)
self.assertEqual(
contact_ui.nodes_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
refresh_pad.assert_called_once_with(2)
draw_window_arrows.assert_called_once_with(2)
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
ui_state.channel_list = ["VeryLongChannelName"]
ui_state.notifications = []
ui_state.selected_channel = 0
ui_state.current_window = 0
contact_ui.channel_pad = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_color", return_value=1):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "remove_notification"):
contact_ui.draw_channel_list()
text = contact_ui.channel_pad.addstr.call_args.args[2]
self.assertEqual(len(text), 16)
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101]
ui_state.current_window = 2
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
contact_ui.entry_win = mock.Mock()
interface = mock.Mock()
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_node_list()
text = contact_ui.nodes_pad.addstr.call_args.args[2]
self.assertEqual(text_width(text), 16)
self.assertIn("…", text)
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 80)
ui_state.single_pane_mode = True
ui_state.current_window = 1
contact_ui.entry_win = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.packetlog_win = mock.Mock()
contact_ui.messages_pad = mock.Mock()
contact_ui.nodes_pad = mock.Mock()
contact_ui.channel_pad = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
contact_ui.handle_resize(stdscr, False)
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
contact_ui.channel_win.box.assert_not_called()
contact_ui.nodes_win.box.assert_not_called()
contact_ui.messages_win.box.assert_called_once_with()
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_window_arrows.assert_called_once_with(1)
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
ui_state.single_pane_mode = True
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
self.assertEqual(contact_ui.get_window_title(0), "")
self.assertEqual(contact_ui.get_window_title(1), "Primary")
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
self.addCleanup(mock.patch.stopall)
mock.patch.object(contact_ui.curses, "LINES", 24, create=True).start()
mock.patch.object(contact_ui.curses, "COLS", 80, create=True).start()
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
ui_state.single_pane_mode = True
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.start_index = [0, 0, 0]
ui_state.display_log = False
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (5, 20)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getbegyx.return_value = (0, 0)
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
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]
ui_state.selected_node = 0
contact_ui.entry_win = mock.Mock()
contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input")
with mock.patch.object(contact_ui, "draw_centered_text_field"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
contact_ui.search(2)
contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)])
contact_ui.entry_win.erase.assert_called()
def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27]
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
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:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.return_value = 27
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"snr": None,
"hopsAway": None,
"deviceMetrics": {
"batteryLevel": None,
"channelUtilization": None,
"airUtilTx": None,
"uptimeSeconds": None,
},
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
-163
View File
@@ -1,163 +0,0 @@
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.shared_state import app_state, interface_state
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_shared_state()
def tearDown(self) -> None:
reset_shared_state()
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
old_interface = mock.Mock()
new_interface = mock.Mock()
stdscr = mock.Mock()
app_state.connection_args = Namespace(host="mesh.local", port=None, ble=None)
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()
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)
def test_reconnect_after_admin_action_runs_action_then_reconnects(self) -> None:
stdscr = mock.Mock()
interface = mock.Mock()
new_interface = mock.Mock()
action = mock.Mock()
with mock.patch.object(control_ui, "reconnect_interface_with_splash", return_value=new_interface) as reconnect:
result = control_ui.reconnect_after_admin_action(
stdscr, interface, action, "Factory Reset Requested by menu"
)
action.assert_called_once_with()
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()
with mock.patch("contact.utilities.utils.get_channels") as get_channels:
with mock.patch("contact.utilities.utils.refresh_node_list") as refresh_node_list:
with mock.patch("contact.ui.contact_ui.handle_resize") as handle_resize:
control_ui.redraw_main_ui_after_reconnect(stdscr)
get_channels.assert_called_once_with()
refresh_node_list.assert_called_once_with()
handle_resize.assert_called_once_with(stdscr, False)
def test_request_factory_reset_uses_library_helper_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node)
node.factoryReset.assert_called_once_with(full=False)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node, full=True)
node.factoryReset.assert_called_once_with(full=True)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_config, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node, full=True)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_device, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
-15
View File
@@ -1,15 +0,0 @@
import unittest
from contact.utilities.control_utils import transform_menu_path
class ControlUtilsTests(unittest.TestCase):
def test_transform_menu_path_applies_replacements_and_normalization(self) -> None:
transformed = transform_menu_path(["Main Menu", "Radio Settings", "Channel 2", "Detail"])
self.assertEqual(transformed, ["config", "channel", "Detail"])
def test_transform_menu_path_preserves_unmatched_entries(self) -> None:
transformed = transform_menu_path(["Main Menu", "Module Settings", "WiFi"])
self.assertEqual(transformed, ["module", "WiFi"])
-219
View File
@@ -1,219 +0,0 @@
from datetime import datetime
import os
import sqlite3
import tempfile
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.shared_state import interface_state, ui_state
from contact.utilities.utils import decimal_to_hex
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class DbHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_shared_state()
self.saved_config = snapshot_config(
"db_file_path",
"message_prefix",
"sent_message_prefix",
"ack_str",
"ack_implicit_str",
"ack_unknown_str",
"nak_str",
)
self.tempdir = tempfile.TemporaryDirectory()
config.db_file_path = os.path.join(self.tempdir.name, "client.db")
interface_state.myNodeNum = 123
def tearDown(self) -> None:
self.tempdir.cleanup()
restore_config(self.saved_config)
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")
self.assertIsInstance(timestamp, int)
db_handler.update_ack_nak("Primary", timestamp, "hello", "Ack")
with sqlite3.connect(config.db_file_path) as conn:
row = conn.execute('SELECT user_id, message_text, ack_type FROM "123_Primary_messages"').fetchone()
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"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
("456", "original message", 1700000000, None),
)
db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901)
with sqlite3.connect(config.db_file_path) as conn:
columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')}
stored_ids = conn.execute(
'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",)
).fetchone()
conn.execute(
'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message")
)
conn.commit()
db_handler.load_messages_from_db()
self.assertTrue({"packet_id", "reply_id"}.issubset(columns))
self.assertEqual(stored_ids, (902, 901))
self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902])
self.assertEqual(
ui_state.all_messages["Primary"][-1][1],
f"<Re: {decimal_to_hex(456)}: origi> reply",
)
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
db_handler.update_node_info_in_db(999, short_name="ABCD")
original_long_name = db_handler.get_name_from_database(999, "long")
self.assertTrue(original_long_name.startswith("Meshtastic "))
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
self.assertEqual(db_handler.is_chat_archived(999), 0)
db_handler.update_node_info_in_db(999, chat_archived=1)
self.assertEqual(db_handler.get_name_from_database(999, "long"), original_long_name)
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
self.assertEqual(db_handler.is_chat_archived(999), 1)
def test_get_name_from_database_returns_hex_when_user_is_missing(self) -> None:
user_id = 0x1234ABCD
db_handler.ensure_node_table_exists()
self.assertEqual(db_handler.get_name_from_database(user_id, "short"), decimal_to_hex(user_id))
self.assertEqual(db_handler.is_chat_archived(user_id), 0)
def test_load_messages_from_db_populates_channels_and_messages(self) -> None:
db_handler.update_node_info_in_db(123, long_name="Local Node", short_name="ME")
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.update_node_info_in_db(789, long_name="Archived", short_name="AR", chat_archived=1)
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"""
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
""",
)
db_handler.ensure_table_exists(
'"123_789_messages"',
"""
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
""",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("123", "sent", 1700000000, "Ack"))
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("456", "reply", 1700000001, None))
conn.execute('INSERT INTO "123_789_messages" VALUES (?, ?, ?, ?)', ("789", "hidden", 1700000002, None))
conn.commit()
ui_state.channel_list = []
ui_state.all_messages = {}
db_handler.load_messages_from_db()
self.assertIn("Primary", ui_state.channel_list)
self.assertNotIn(789, ui_state.channel_list)
self.assertIn("Primary", ui_state.all_messages)
self.assertIn(789, ui_state.all_messages)
messages = ui_state.all_messages["Primary"]
self.assertTrue(messages[0][0].startswith("-- "))
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in messages))
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
def test_message_history_is_loaded_in_bounded_pages(self) -> None:
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.executemany(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
[("456", f"message-{i}", 1700000000 + i, None) for i in range(7)],
)
db_handler.load_messages_from_db(page_size=3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, ["message-4", "message-5", "message-6"])
self.assertTrue(ui_state.has_older_messages["Primary"])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(
loaded_text,
["message-1", "message-2", "message-3", "message-4", "message-5", "message-6"],
)
self.assertTrue(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 2)
self.assertEqual(separators[0], separators[1])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 1)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, [f"message-{i}" for i in range(7)])
self.assertFalse(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 3)
def test_message_table_channel_name_may_contain_underscores(self) -> None:
db_handler.ensure_node_table_exists()
db_handler.ensure_table_exists(
'"123_My_Channel_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_My_Channel_messages" VALUES (?, ?, ?, ?)',
("123", "hello", 1700000000, None),
)
db_handler.load_messages_from_db()
self.assertIn("My_Channel", ui_state.channel_list)
self.assertEqual(ui_state.all_messages["My_Channel"][-1][1], "hello")
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
interface_state.interface = build_demo_interface()
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
db_handler.init_nodedb()
self.assertEqual(db_handler.get_name_from_database(2701131778, "short"), "SAT2")
-38
View File
@@ -1,38 +0,0 @@
import tempfile
import unittest
from contact.ui import default_config
class DefaultConfigTests(unittest.TestCase):
def test_get_localisation_options_filters_hidden_and_non_ini_files(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
for filename in ("en.ini", "ru.ini", ".hidden.ini", "notes.txt"):
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
handle.write("")
self.assertEqual(default_config.get_localisation_options(tmpdir), ["en", "ru"])
def test_get_localisation_file_normalizes_extensions_and_falls_back_to_english(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
for filename in ("en.ini", "ru.ini"):
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
handle.write("")
self.assertTrue(default_config.get_localisation_file("RU.ini", tmpdir).endswith("/ru.ini"))
self.assertTrue(default_config.get_localisation_file("missing", tmpdir).endswith("/en.ini"))
def test_update_dict_only_adds_missing_values(self) -> None:
default = {"theme": "dark", "nested": {"language": "en", "sound": True}}
actual = {"nested": {"language": "ru"}}
updated = default_config.update_dict(default, actual)
self.assertTrue(updated)
self.assertEqual(actual, {"theme": "dark", "nested": {"language": "ru", "sound": True}})
def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> None:
rendered = default_config.format_json_single_line_arrays({"items": [1, 2], "nested": {"flags": ["a", "b"]}})
self.assertIn('"items": [1, 2]', rendered)
self.assertIn('"flags": ["a", "b"]', rendered)
-51
View File
@@ -1,51 +0,0 @@
import tempfile
import unittest
from unittest import mock
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.shared_state import interface_state, ui_state
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class DemoDataTests(unittest.TestCase):
def setUp(self) -> None:
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_shared_state()
def test_build_demo_interface_exposes_expected_shape(self) -> None:
interface = build_demo_interface()
self.assertEqual(interface.getMyNodeInfo()["num"], DEMO_LOCAL_NODE_NUM)
self.assertEqual([channel.settings.name for channel in interface.getNode("^local").channels], DEMO_CHANNELS)
self.assertIn(DEMO_LOCAL_NODE_NUM, interface.nodesByNum)
def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) -> None:
interface_state.interface = build_demo_interface()
with tempfile.TemporaryDirectory() as tmpdir:
demo_db_path = configure_demo_database(tmpdir)
with mock.patch.object(entrypoint.pub, "subscribe"):
entrypoint.initialize_globals(seed_demo=True)
self.assertEqual(config.db_file_path, demo_db_path)
self.assertIn("MediumFast", ui_state.channel_list)
self.assertIn("Another Channel", ui_state.channel_list)
self.assertIn(2701131788, ui_state.channel_list)
self.assertEqual(ui_state.node_list[0], DEMO_LOCAL_NODE_NUM)
self.assertEqual(get_name_from_database(2701131778, "short"), "SAT2")
medium_fast = ui_state.all_messages["MediumFast"]
self.assertTrue(medium_fast[0][0].startswith("-- "))
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in medium_fast))
self.assertTrue(any("SAT2:" in prefix for prefix, _ in medium_fast))
direct_messages = ui_state.all_messages[2701131788]
self.assertEqual(len(direct_messages), 3)
-88
View File
@@ -1,88 +0,0 @@
import unittest
from unittest import mock
from contact.ui import dialog as dialog_module
from contact.utilities.shared_state import menu_state, ui_state
class _FakeWindow:
def __init__(self, height: int, width: int) -> None:
self.height = height
self.width = width
self.children = []
self.added_strings = []
self._getch_values = [10]
def erase(self) -> None:
return None
def bkgd(self, *_args) -> None:
return None
def attrset(self, *_args) -> None:
return None
def border(self, *_args) -> None:
return None
def addstr(self, y: int, x: int, text: str, *_args) -> None:
self.added_strings.append((y, x, text))
def derwin(self, height: int, width: int, _y: int, _x: int):
child = _FakeWindow(height, width)
self.children.append(child)
return child
def noutrefresh(self) -> None:
return None
def keypad(self, *_args) -> None:
return None
def timeout(self, *_args) -> None:
return None
def getch(self) -> int:
return self._getch_values.pop(0) if self._getch_values else -1
def refresh(self) -> None:
return None
def getmaxyx(self):
return (self.height, self.width)
class DialogTests(unittest.TestCase):
def setUp(self) -> None:
self.previous_window = ui_state.current_window
self.previous_start_index = list(ui_state.start_index)
self.previous_need_redraw = menu_state.need_redraw
def tearDown(self) -> None:
ui_state.current_window = self.previous_window
ui_state.start_index = self.previous_start_index
menu_state.need_redraw = self.previous_need_redraw
def test_dialog_renders_full_message_when_width_is_sufficient(self) -> None:
root = _FakeWindow(5, 33)
ui_state.current_window = 0
ui_state.start_index = [0, 0, 0]
menu_state.need_redraw = False
with mock.patch.object(dialog_module, "t_text", side_effect=lambda text: text):
with mock.patch.object(dialog_module, "get_color", return_value=0):
with mock.patch.object(dialog_module, "draw_main_arrows"):
with mock.patch.object(dialog_module.curses, "update_lines_cols"):
with mock.patch.object(dialog_module.curses, "doupdate"):
with mock.patch.object(dialog_module.curses, "LINES", 24, create=True):
with mock.patch.object(dialog_module.curses, "COLS", 80, create=True):
with mock.patch.object(dialog_module.curses, "newwin", return_value=root):
dialog_module.dialog("Bot Responder", "Bot responder is now Enabled.")
self.assertTrue(root.children)
message_text = [text for _y, _x, text in root.children[0].added_strings]
self.assertIn("Bot responder is now Enabled.", message_text)
if __name__ == "__main__":
unittest.main()
-11
View File
@@ -1,11 +0,0 @@
import unittest
from contact.utilities.emoji_utils import normalize_message_text
class EmojiUtilsTests(unittest.TestCase):
def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None:
self.assertEqual(normalize_message_text("👍🏽 7️⃣"), "👍 7")
def test_rewrites_flag_emoji_to_country_codes(self) -> None:
self.assertEqual(normalize_message_text("🇺🇸 hello 🇩🇪"), "US hello DE")
-146
View File
@@ -1,146 +0,0 @@
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)
-70
View File
@@ -1,70 +0,0 @@
import os
import tempfile
import unittest
from unittest import mock
import contact.ui.default_config as config
from contact.utilities import i18n
from contact.utilities.ini_utils import parse_ini_file
from tests.test_support import restore_config, snapshot_config
class I18nTests(unittest.TestCase):
def setUp(self) -> None:
self.saved_config = snapshot_config("language")
i18n._translations = {}
i18n._language = None
def tearDown(self) -> None:
restore_config(self.saved_config)
i18n._translations = {}
i18n._language = None
def test_t_loads_translation_file_and_formats_placeholders(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
translation_file = os.path.join(tmpdir, "xx.ini")
with open(translation_file, "w", encoding="utf-8") as handle:
handle.write('[ui]\n')
handle.write('greeting,"Hello {name}"\n')
config.language = "xx"
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
self.assertEqual(i18n.t("ui.greeting", name="Ben"), "Hello Ben")
def test_t_falls_back_to_default_and_returns_unformatted_text_on_error(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
translation_file = os.path.join(tmpdir, "xx.ini")
with open(translation_file, "w", encoding="utf-8") as handle:
handle.write('[ui]\n')
handle.write('greeting,"Hello {name}"\n')
config.language = "xx"
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
self.assertEqual(i18n.t("ui.greeting"), "Hello {name}")
self.assertEqual(i18n.t("ui.missing", default="Fallback"), "Fallback")
self.assertEqual(i18n.t_text("Literal {value}", value=7), "Literal 7")
def test_loader_cache_is_reused_until_language_changes(self) -> None:
config.language = "en"
with mock.patch.object(i18n, "parse_ini_file", return_value=({"key": "value"}, {})) as parse_ini_file:
self.assertEqual(i18n.t("key"), "value")
self.assertEqual(i18n.t("key"), "value")
self.assertEqual(parse_ini_file.call_count, 1)
config.language = "ru"
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
self.assertEqual(parse_ini_file.call_count, 2)
def test_ping_bot_settings_translation_keys_exist_in_all_locales(self) -> None:
required_keys = {
"app_settings.ping_bot",
"app_settings.ping_bot.enabled",
"app_settings.ping_bot.catch_words",
"app_settings.ping_bot.response_word",
}
for language in config.get_localisation_options():
field_mapping, _ = parse_ini_file(config.get_localisation_file(language))
self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}")
-40
View File
@@ -1,40 +0,0 @@
import os
import tempfile
import unittest
from unittest import mock
from contact.utilities.ini_utils import parse_ini_file
class IniUtilsTests(unittest.TestCase):
def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ini_path = os.path.join(tmpdir, "settings.ini")
with open(ini_path, "w", encoding="utf-8") as handle:
handle.write('; comment\n')
handle.write('[config.device]\n')
handle.write('title,"Device","Device help"\n')
handle.write('name,"Node Name","Node help"\n')
handle.write('empty_help,"Fallback",""\n')
with mock.patch("contact.utilities.ini_utils.i18n.t", return_value="No help available."):
mapping, help_text = parse_ini_file(ini_path)
self.assertEqual(mapping["config.device"], "Device")
self.assertEqual(help_text["config.device"], "Device help")
self.assertEqual(mapping["config.device.name"], "Node Name")
self.assertEqual(help_text["config.device.name"], "Node help")
self.assertEqual(help_text["config.device.empty_help"], "No help available.")
def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ini_path = os.path.join(tmpdir, "settings.ini")
with open(ini_path, "w", encoding="utf-8") as handle:
handle.write('[section]\n')
handle.write('name,"Name"\n')
with mock.patch("contact.utilities.ini_utils.i18n.t", side_effect=RuntimeError("boom")):
mapping, help_text = parse_ini_file(ini_path)
self.assertEqual(mapping["section.name"], "Name")
self.assertEqual(help_text["section.name"], "No help available.")
-63
View File
@@ -1,63 +0,0 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
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()
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:
with mock.patch("contact.utilities.interfaces.logging.info") as info:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
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()
with mock.patch("contact.utilities.interfaces.initialize_interface", return_value=None):
with mock.patch("contact.utilities.interfaces.time.sleep"):
with self.assertRaises(RuntimeError):
reconnect_interface(args, attempts=2, delay_seconds=0)
-241
View File
@@ -1,241 +0,0 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.runtime as entrypoint
import contact.ui.default_config as config
from contact.utilities.shared_state import interface_state, ui_state
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class MainRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
reset_shared_state()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_shared_state()
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
args = Namespace(demo_screenshot=True)
with mock.patch.object(entrypoint, "configure_demo_database") as configure_demo_database:
with mock.patch.object(entrypoint, "build_demo_interface", return_value="demo-interface") as build_demo:
with mock.patch.object(entrypoint, "initialize_interface") as initialize_interface:
result = entrypoint.initialize_runtime_interface(args)
self.assertEqual(result, "demo-interface")
configure_demo_database.assert_called_once_with()
build_demo.assert_called_once_with()
initialize_interface.assert_not_called()
def test_initialize_runtime_interface_uses_live_branch_without_demo_flag(self) -> None:
args = Namespace(demo_screenshot=False)
with mock.patch.object(entrypoint, "initialize_interface", return_value="live-interface") as initialize_interface:
result = entrypoint.initialize_runtime_interface(args)
self.assertEqual(result, "live-interface")
initialize_interface.assert_called_once_with(args)
def test_interface_is_ready_detects_missing_local_node(self) -> None:
self.assertFalse(entrypoint.interface_is_ready(object()))
self.assertTrue(entrypoint.interface_is_ready(SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))))
def test_initialize_runtime_interface_with_retry_retries_until_node_is_ready(self) -> None:
args = Namespace(demo_screenshot=False)
stdscr = mock.Mock()
bad_interface = mock.Mock(spec=["close"])
good_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))
with mock.patch.object(entrypoint, "initialize_runtime_interface", side_effect=[bad_interface, good_interface]):
with mock.patch.object(entrypoint, "get_list_input", return_value="Retry") as get_list_input:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
self.assertIs(result, good_interface)
get_list_input.assert_called_once()
bad_interface.close.assert_called_once_with()
draw_splash.assert_called_once_with(stdscr)
def test_initialize_runtime_interface_with_retry_returns_none_when_user_closes(self) -> None:
args = Namespace(demo_screenshot=False)
stdscr = mock.Mock()
bad_interface = mock.Mock(spec=["close"])
with mock.patch.object(entrypoint, "initialize_runtime_interface", return_value=bad_interface):
with mock.patch.object(entrypoint, "get_list_input", return_value="Close") as get_list_input:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
self.assertIsNone(result)
get_list_input.assert_called_once()
bad_interface.close.assert_called_once_with()
draw_splash.assert_not_called()
def test_prompt_region_if_unset_reinitializes_interface_after_confirmation(self) -> None:
args = Namespace()
old_interface = mock.Mock()
new_interface = mock.Mock()
stdscr = mock.Mock()
interface_state.interface = old_interface
with mock.patch.object(entrypoint, "get_list_input", return_value="Yes"):
with mock.patch.object(entrypoint, "set_region") as set_region:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
with mock.patch.object(entrypoint, "reconnect_interface", return_value=new_interface) as reconnect:
entrypoint.prompt_region_if_unset(args, stdscr)
set_region.assert_called_once_with(old_interface)
old_interface.close.assert_called_once_with()
draw_splash.assert_called_once_with(stdscr)
reconnect.assert_called_once_with(args)
self.assertIs(interface_state.interface, new_interface)
def test_prompt_region_if_unset_leaves_interface_unchanged_when_declined(self) -> None:
args = Namespace()
interface = mock.Mock()
interface_state.interface = interface
with mock.patch.object(entrypoint, "get_list_input", return_value="No"):
with mock.patch.object(entrypoint, "set_region") as set_region:
with mock.patch.object(entrypoint, "reconnect_interface") as reconnect:
entrypoint.prompt_region_if_unset(args)
set_region.assert_not_called()
reconnect.assert_not_called()
interface.close.assert_not_called()
self.assertIs(interface_state.interface, interface)
def test_initialize_globals_resets_and_populates_runtime_state(self) -> None:
ui_state.channel_list = ["stale"]
ui_state.all_messages = {"stale": [("old", "message")]}
ui_state.notifications = [1]
ui_state.packet_buffer = ["packet"]
ui_state.node_list = [99]
ui_state.selected_channel = 3
ui_state.selected_message = 4
ui_state.selected_node = 5
ui_state.start_index = [9, 9, 9]
config.single_pane_mode = "True"
with mock.patch.object(entrypoint, "get_nodeNum", return_value=123):
with mock.patch.object(entrypoint, "get_channels", return_value=["Primary"]) as get_channels:
with mock.patch.object(entrypoint, "get_node_list", return_value=[123, 456]) as get_node_list:
with mock.patch.object(entrypoint.pub, "subscribe") as subscribe:
with mock.patch.object(entrypoint, "init_nodedb") as init_nodedb:
with mock.patch.object(entrypoint, "seed_demo_messages") as seed_demo_messages:
with mock.patch.object(entrypoint, "load_messages_from_db") as load_messages:
entrypoint.initialize_globals(seed_demo=True)
self.assertEqual(ui_state.channel_list, ["Primary"])
self.assertEqual(ui_state.all_messages, {})
self.assertEqual(ui_state.notifications, [])
self.assertEqual(ui_state.packet_buffer, [])
self.assertEqual(ui_state.node_list, [123, 456])
self.assertEqual(ui_state.selected_channel, 0)
self.assertEqual(ui_state.selected_message, 0)
self.assertEqual(ui_state.selected_node, 0)
self.assertEqual(ui_state.start_index, [0, 0, 0])
self.assertTrue(ui_state.single_pane_mode)
self.assertEqual(interface_state.myNodeNum, 123)
get_channels.assert_called_once_with()
get_node_list.assert_called_once_with()
subscribe.assert_called_once_with(entrypoint.on_receive, "meshtastic.receive")
init_nodedb.assert_called_once_with()
seed_demo_messages.assert_called_once_with()
load_messages.assert_called_once_with()
def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.side_effect = [(10, 80), (11, 80)]
with mock.patch.object(entrypoint, "dialog") as dialog:
with mock.patch.object(entrypoint.curses, "update_lines_cols") as update_lines_cols:
entrypoint.ensure_min_rows(stdscr, min_rows=11)
dialog.assert_called_once()
update_lines_cols.assert_called_once_with()
stdscr.clear.assert_called_once_with()
stdscr.refresh.assert_called_once_with()
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(Namespace())
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:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
entrypoint.start(Namespace())
wrapper.assert_called_once_with(entrypoint.main, Namespace())
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
stdscr = mock.Mock()
args = Namespace(settings=False, demo_screenshot=False)
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, "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()
def test_start_handles_keyboard_interrupt(self) -> None:
interface = mock.Mock()
interface_state.interface = interface
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
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(Namespace())
self.assertEqual(raised.exception.code, 0)
interface.close.assert_called_once_with()
exit_mock.assert_called_once_with(0)
def test_start_handles_keyboard_interrupt_with_no_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
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(Namespace())
self.assertEqual(raised.exception.code, 0)
exit_mock.assert_called_once_with(0)
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
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_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(Namespace())
self.assertEqual(raised.exception.code, 1)
endwin.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)
-63
View File
@@ -1,63 +0,0 @@
from types import SimpleNamespace
import unittest
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.ui.menus import generate_menu_from_protobuf
class MenusTests(unittest.TestCase):
def test_main_menu_includes_factory_reset_config_after_factory_reset(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", "isLicensed": False},
"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0},
},
)
menu = generate_menu_from_protobuf(interface)
keys = list(menu["Main Menu"].keys())
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))
-95
View File
@@ -1,95 +0,0 @@
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)
-36
View File
@@ -1,36 +0,0 @@
import unittest
from unittest import mock
from contact.ui import nav_utils
from contact.ui.nav_utils import truncate_with_ellipsis, wrap_text
from contact.utilities.shared_state import ui_state
class NavUtilsTests(unittest.TestCase):
def setUp(self) -> None:
ui_state.current_window = 0
ui_state.node_list = []
ui_state.start_index = [0, 0, 0]
def test_wrap_text_splits_wide_characters_by_display_width(self) -> None:
self.assertEqual(wrap_text("🔐🔐🔐", 4), ["🔐", "🔐", "🔐"])
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None:
ui_state.current_window = 2
ui_state.start_index = [0, 0, 0]
menu_win = mock.Mock()
menu_win.getbegyx.return_value = (0, 0)
menu_win.getmaxyx.return_value = (8, 20)
menu_pad = mock.Mock()
menu_pad.getmaxyx.return_value = (4, 20)
with mock.patch.object(nav_utils, "get_node_color", side_effect=[11, 22]):
nav_utils.highlight_line(menu_win, menu_pad, 0, 1, 5)
self.assertEqual(
menu_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
-55
View File
@@ -1,55 +0,0 @@
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)
-224
View File
@@ -1,224 +0,0 @@
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.shared_state import interface_state, menu_state, ui_state
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class RxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
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_shared_state()
def test_message_sound_depends_on_selected_view(self):
config.notification_sound = "alert.mp3"
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary", "Other"]
for window, packet_log, log_viewer, channel, expected in (
(1, False, False, 0, False),
(1, False, False, 1, False),
(0, False, False, 0, True),
(2, False, False, 0, True),
(4, False, False, 0, True),
(1, True, False, 0, True),
(1, False, True, 0, True),
):
with self.subTest(window=window, packet_log=packet_log, log_viewer=log_viewer, channel=channel):
ui_state.current_window = window
ui_state.display_log = packet_log
ui_state.log_viewer_open = log_viewer
ui_state.all_messages = {"Primary": [], "Other": []}
packet = {
"from": 222, "to": 999, "channel": channel,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
mock.patch.object(rx_handler, "request_ui_redraw"), \
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
mock.patch.object(rx_handler, "save_message_to_db"), \
mock.patch.object(rx_handler, "schedule_notification_sound") as sound:
rx_handler.on_receive(packet, interface=None)
self.assertEqual(sound.call_count, int(expected))
def test_pending_sound_is_suppressed_if_chat_selected_before_playback(self):
with mock.patch.object(rx_handler, "_sound_timer", None), \
mock.patch.object(rx_handler, "_last_sound_request", 0.0), \
mock.patch.object(rx_handler.threading, "Timer") as timer, \
mock.patch.object(rx_handler, "play_sound") as play:
rx_handler.schedule_notification_sound()
callback = timer.call_args.args[1]
callback_args = timer.call_args.kwargs["args"]
ui_state.current_window = 1
callback(*callback_args)
play.assert_not_called()
ui_state.current_window = 0
callback(*callback_args)
play.assert_called_once_with()
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 3,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=True):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "add_notification") as add_notification:
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(
request_ui_redraw.call_args_list,
[
mock.call(nodes=True),
mock.call(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=False,
),
],
)
add_notification.assert_not_called()
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])
def test_on_receive_preserves_message_selection_while_showing_new_message(self) -> None:
interface_state.myNodeNum = 111
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "save_message_to_db"):
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
request_ui_redraw.assert_called_once_with(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=True,
)
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 111,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"dm"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "add_notification") as add_notification:
with mock.patch.object(rx_handler, "update_node_info_in_db") as update_node_info_in_db:
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
self.assertIn(222, ui_state.channel_list)
self.assertIn(222, ui_state.all_messages)
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, timestamp=mock.ANY)
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:00:00] >> SAT2: ", "hello world")]}
ui_state.message_packet_ids = {"Primary": [900]}
ui_state.selected_channel = 0
packet = {
"id": 901,
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi", "replyId": 900},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw"):
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"):
rx_handler.on_receive(packet, interface=None)
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, 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))
ui_state.display_log = True
ui_state.current_window = 4
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
rx_handler.on_receive({"id": "new"}, interface=None)
request_ui_redraw.assert_called_once_with(packetlog=True)
self.assertEqual(len(ui_state.packet_buffer), 20)
self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"})
self.assertTrue(menu_state.need_redraw)
-195
View File
@@ -1,195 +0,0 @@
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(
lora=SimpleNamespace(region=0, serial_enabled=False),
device=SimpleNamespace(role="CLIENT", name="node"),
security=SimpleNamespace(debug_log_api_enabled=False, serial_enabled=False, admin_key=[]),
display=SimpleNamespace(flip_screen=False, units=0),
power=SimpleNamespace(is_power_saving=False, adc_enabled=False),
network=SimpleNamespace(wifi_enabled=False),
bluetooth=SimpleNamespace(enabled=False),
)
node.moduleConfig = SimpleNamespace(mqtt=SimpleNamespace(enabled=False))
interface = mock.Mock()
interface.getNode.return_value = node
return interface, node
def test_save_changes_returns_true_for_lora_writes_that_require_reconnect(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
reconnect_required = save_changes(interface, {"region": 7}, menu_state)
self.assertTrue(reconnect_required)
self.assertEqual(node.localConfig.lora.region, 7)
node.writeConfig.assert_called_once_with("lora")
def test_save_changes_returns_false_when_nothing_changed(self) -> None:
interface = mock.Mock()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
self.assertFalse(save_changes(interface, {}, menu_state))
def test_save_changes_returns_false_for_non_rebooting_security_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"serial_enabled": True}, menu_state)
self.assertFalse(reconnect_required)
self.assertTrue(node.localConfig.security.serial_enabled)
def test_save_changes_returns_true_for_rebooting_security_fields(self) -> None:
interface, _node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"admin_key": [b"12345678"]}, menu_state)
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"])
self.assertFalse(save_changes(interface, {"name": "renamed"}, menu_state))
self.assertEqual(node.localConfig.device.name, "renamed")
node.writeConfig.reset_mock()
self.assertTrue(save_changes(interface, {"role": "ROUTER"}, menu_state))
self.assertEqual(node.localConfig.device.role, "ROUTER")
def test_save_changes_returns_true_for_network_settings(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Network"])
reconnect_required = save_changes(interface, {"wifi_enabled": True}, menu_state)
self.assertTrue(reconnect_required)
self.assertTrue(node.localConfig.network.wifi_enabled)
def test_save_changes_returns_true_only_for_rebooting_power_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Power"])
self.assertFalse(save_changes(interface, {"adc_enabled": True}, menu_state))
self.assertTrue(node.localConfig.power.adc_enabled)
node.writeConfig.reset_mock()
self.assertTrue(save_changes(interface, {"is_power_saving": True}, menu_state))
self.assertTrue(node.localConfig.power.is_power_saving)
def test_save_changes_returns_true_for_module_settings(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Module Settings", "Mqtt"])
reconnect_required = save_changes(interface, {"enabled": True}, menu_state)
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"])
reconnect_required = save_changes(interface, {"longName": "Node"}, menu_state)
self.assertTrue(reconnect_required)
node.setOwner.assert_called_once()
def test_save_changes_returns_true_for_user_license_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
reconnect_required = save_changes(interface, {"isLicensed": True}, menu_state)
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)
-69
View File
@@ -1,69 +0,0 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.settings_runtime as settings
class SettingsRuntimeTests(unittest.TestCase):
def test_main_closes_interface_after_normal_settings_exit(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
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, "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()
def test_main_closes_reconnected_interface_after_region_reset(self) -> None:
stdscr = mock.Mock()
args = Namespace()
old_interface = mock.Mock()
old_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=0)))
new_interface = mock.Mock()
new_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
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, "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)
settings_menu.assert_called_once_with(stdscr, new_interface)
old_interface.close.assert_called_once_with()
new_interface.close.assert_called_once_with()
def test_main_closes_interface_when_settings_menu_raises(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
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, "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()
-27
View File
@@ -1,27 +0,0 @@
import threading
import contact.ui.default_config as config
from contact.ui.ui_state import AppState, ChatUIState, InterfaceState, MenuState
from contact.utilities.shared_state import app_state, interface_state, menu_state, ui_state
def reset_shared_state() -> None:
_reset_instance(ui_state, ChatUIState())
_reset_instance(interface_state, InterfaceState())
_reset_instance(menu_state, MenuState())
_reset_instance(app_state, AppState())
app_state.lock = threading.Lock()
def restore_config(saved: dict) -> None:
for key, value in saved.items():
setattr(config, key, value)
def snapshot_config(*keys: str) -> dict:
return {key: getattr(config, key) for key in keys}
def _reset_instance(target: object, replacement: object) -> None:
target.__dict__.clear()
target.__dict__.update(replacement.__dict__)
-33
View File
@@ -1,33 +0,0 @@
import unittest
from unittest import mock
from contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_direction
class TelemetryBeautifierTests(unittest.TestCase):
def test_humanize_wind_direction_handles_boundaries(self) -> None:
self.assertEqual(humanize_wind_direction(0), "N")
self.assertEqual(humanize_wind_direction(90), "E")
self.assertEqual(humanize_wind_direction(225), "SW")
self.assertIsNone(humanize_wind_direction(-1))
def test_get_chunks_formats_known_and_unknown_values(self) -> None:
rendered = get_chunks("uptime_seconds:7200\nwind_direction:90\nlatitude_i:123456789\nunknown:abc\n")
self.assertIn("🆙 2.0h", rendered)
self.assertIn("⮆ E", rendered)
self.assertIn("🌍 12.345679", rendered)
self.assertIn("unknown:abc", rendered)
def test_get_chunks_formats_time_values(self) -> None:
with mock.patch("contact.utilities.telemetry_beautifier.datetime.datetime") as mocked_datetime:
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "01.01.1970 00:00"
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)
-122
View File
@@ -1,122 +0,0 @@
from types import SimpleNamespace
import unittest
from unittest import mock
from meshtastic import BROADCAST_NUM
import contact.ui.default_config as config
from contact.message_handlers import tx_handler
from contact.utilities.shared_state import interface_state, ui_state
from tests.test_support import reset_shared_state, restore_config, snapshot_config
class TxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
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_shared_state()
def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-1")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=999) as save_message_to_db:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
tx_handler.send_message("hello", channel=0)
interface.sendText.assert_called_once_with(
text="hello",
destinationId=BROADCAST_NUM,
wantAck=True,
wantResponse=False,
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
save_message_to_db.assert_called_once_with("Primary", 111, "hello", packet_id="req-1", reply_id=None)
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
def test_send_message_to_direct_node_uses_node_as_destination(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-2")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = [222]
ui_state.all_messages = {222: []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
tx_handler.send_message("dm", channel=0)
interface.sendText.assert_called_once_with(
text="dm",
destinationId=222,
wantAck=True,
wantResponse=False,
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
def test_send_message_uses_native_reply_id_but_only_displays_reply_context_locally(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-3")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
tx_handler.send_message("hello", channel=0, reply_id=77, reply_context="<Re: NODE: hello> ")
self.assertEqual(interface.sendText.call_args.kwargs["text"], "hello")
self.assertEqual(interface.sendText.call_args.kwargs["replyId"], 77)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: NODE: hello> hello")
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
with mock.patch("contact.ui.contact_ui.request_ui_redraw") as request_ui_redraw:
tx_handler.onAckNak(packet)
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack")
request_ui_redraw.assert_called_once_with(messages=True)
self.assertIn(config.sent_message_prefix, ui_state.all_messages["Primary"][0][0])
self.assertIn(config.ack_str, ui_state.all_messages["Primary"][0][0])
def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
with mock.patch("contact.ui.contact_ui.request_ui_redraw"):
tx_handler.onAckNak(packet)
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Implicit")
self.assertIn(config.ack_implicit_str, ui_state.all_messages["Primary"][0][0])
-63
View File
@@ -1,63 +0,0 @@
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()
-180
View File
@@ -1,180 +0,0 @@
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.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_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_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_shared_state()
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
config.node_sort = "lastHeard"
interface = build_demo_interface()
interface_state.interface = interface
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
node_list = get_node_list()
self.assertEqual(node_list[0], DEMO_LOCAL_NODE_NUM)
self.assertEqual(node_list[-1], 0xA1000008)
def test_add_new_message_groups_messages_by_hour(self) -> None:
ui_state.all_messages = {"MediumFast": []}
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 --", ""),
("[17:16:40] >> Test: ", "First"),
("[17:16:40] >> Test: ", "Second"),
],
)
def test_get_channels_populates_message_buckets_for_device_channels(self) -> None:
interface_state.interface = build_demo_interface()
ui_state.channel_list = []
ui_state.all_messages = {}
channels = get_channels()
self.assertIn("MediumFast", channels)
self.assertIn("Another Channel", channels)
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"
interface_state.interface = interface
ui_state.channel_list = ["MediumFast", "Another Channel", 2701131788]
ui_state.all_messages = {
"MediumFast": [("prefix", "first")],
"Another Channel": [("prefix", "second")],
2701131788: [("prefix", "dm")],
}
ui_state.selected_channel = 2
channels = get_channels()
self.assertEqual(channels[0], "Renamed Channel")
self.assertEqual(channels[1], "Another Channel")
self.assertEqual(channels[2], 2701131788)
self.assertEqual(ui_state.all_messages["Renamed Channel"], [("prefix", "first")])
self.assertEqual(ui_state.all_messages["Another Channel"], [("prefix", "second")])
self.assertEqual(ui_state.all_messages[2701131788], [("prefix", "dm")])
self.assertNotIn("MediumFast", ui_state.all_messages)
def test_parse_protobuf_returns_string_payload_unchanged(self) -> None:
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": "hello"}}
self.assertEqual(parse_protobuf(packet), "hello")
def test_parse_protobuf_returns_placeholder_for_text_messages(self) -> None:
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}}
self.assertEqual(parse_protobuf(packet), "✉️")
-14
View File
@@ -1,14 +0,0 @@
import unittest
from contact.utilities.validation_rules import get_validation_for
class ValidationRulesTests(unittest.TestCase):
def test_get_validation_for_matches_exact_keys(self) -> None:
self.assertEqual(get_validation_for("shortName"), {"max_length": 4})
def test_get_validation_for_matches_substrings(self) -> None:
self.assertEqual(get_validation_for("config.position.latitude"), {"min_value": -90, "max_value": 90})
def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -> None:
self.assertEqual(get_validation_for("totally_unknown"), {})