mirror of
https://github.com/pdxlocations/contact.git
synced 2026-05-01 02:52:16 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ede60153 | ||
|
|
046ede23a2 | ||
|
|
0ad39fd6a0 | ||
|
|
6721874937 | ||
|
|
8ee21b1973 | ||
|
|
0760d6d0ca | ||
|
|
cd0b114f13 | ||
|
|
2d4b407caa | ||
|
|
80b9311959 | ||
|
|
da5f5bff39 | ||
|
|
bc69c709ff | ||
|
|
6453865cba | ||
|
|
090e89d92c | ||
|
|
07716c1719 | ||
|
|
da5b102047 | ||
|
|
1668b68c4f | ||
|
|
fd4b9e2174 | ||
|
|
8f32e2c99c | ||
|
|
b53dab1840 | ||
|
|
f2904eb550 | ||
|
|
480c32ba56 | ||
|
|
b4b084b627 | ||
|
|
5940c9b02b | ||
|
|
c492c96685 | ||
|
|
90376d35f3 |
@@ -60,6 +60,7 @@ For smaller displays you may wish to enable `single_pane_mode`:
|
||||
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
|
||||
- `F5` = Display a node's info
|
||||
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
|
||||
- `CTRL` + `b` = Enable/Disable Autoresponder Bot (ping / pong)
|
||||
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
|
||||
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
||||
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
|
||||
|
||||
@@ -57,6 +57,8 @@ logging.basicConfig(
|
||||
|
||||
app_state.lock = threading.Lock()
|
||||
|
||||
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Main Program Logic
|
||||
@@ -72,11 +74,36 @@ def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None)
|
||||
interface_state.interface = reconnect_interface(args)
|
||||
|
||||
|
||||
def close_interface(interface: object) -> None:
|
||||
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
|
||||
if interface is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
interface.close()
|
||||
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:
|
||||
@@ -205,23 +232,32 @@ def start() -> None:
|
||||
setup_parser().print_help()
|
||||
sys.exit(0)
|
||||
|
||||
interrupted = False
|
||||
fatal_error = None
|
||||
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
close_interface(interface_state.interface)
|
||||
except KeyboardInterrupt:
|
||||
interrupted = True
|
||||
logging.info("User exited with Ctrl+C")
|
||||
close_interface(interface_state.interface)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
fatal_error = e
|
||||
logging.critical("Fatal error", exc_info=True)
|
||||
try:
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
print("Fatal error:", e)
|
||||
finally:
|
||||
close_interface(interface_state.interface)
|
||||
|
||||
if fatal_error is not None:
|
||||
print("Fatal error:", fatal_error)
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if interrupted:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start()
|
||||
|
||||
@@ -12,6 +12,7 @@ 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", ""
|
||||
@@ -55,6 +56,7 @@ 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", ""
|
||||
@@ -76,6 +78,7 @@ help.traceroute, "Ctrl+T or F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Full node info", ""
|
||||
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
||||
help.favorite, "Ctrl+F = Favorite", ""
|
||||
help.bot_responder, "Ctrl+B = Toggle Bot Responder", ""
|
||||
help.ignore, "Ctrl+G = Ignore", ""
|
||||
help.search, "Ctrl+/ or / = Search", ""
|
||||
help.help, "Ctrl+K = Help", ""
|
||||
@@ -88,6 +91,11 @@ confirm.remove_ignored, "Remove {name} from Ignored?", ""
|
||||
confirm.region_unset, "Your region is UNSET. Set it now?", ""
|
||||
dialog.resize_title, "Resize Terminal", ""
|
||||
dialog.resize_body, "Please resize the terminal to at least {rows} rows.", ""
|
||||
bot.status.enabled, "Enabled", ""
|
||||
bot.status.disabled, "Disabled", ""
|
||||
bot.dialog.title, "Bot Responder", ""
|
||||
bot.dialog.body, "Bot responder is now {status}.", ""
|
||||
bot.status.message, "Bot responder is now {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "User"
|
||||
@@ -114,10 +122,16 @@ 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", ""
|
||||
catch_words, "Catch words", "Semicolon-separated bot trigger words."
|
||||
response_word, "Response word", "Bot response word."
|
||||
|
||||
[app_settings.color_config]
|
||||
default, "Default", ""
|
||||
background, "Background", ""
|
||||
|
||||
209
contact/localisations/fr.ini
Normal file
209
contact/localisations/fr.ini
Normal file
@@ -0,0 +1,209 @@
|
||||
##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.bot_responder, "Ctrl+B = Activer/désactiver le bot répondeur", ""
|
||||
help.ignore, "Ctrl+G = Ignorer", ""
|
||||
help.search, "Ctrl+/ ou / = Rechercher", ""
|
||||
help.help, "Ctrl+K = Aide", ""
|
||||
help.no_help, "Aucune aide disponible.", ""
|
||||
bot.status.enabled, "Activé", ""
|
||||
bot.status.disabled, "Désactivé", ""
|
||||
bot.dialog.title, "Bot répondeur", ""
|
||||
bot.dialog.body, "Le bot répondeur est maintenant {status}.", ""
|
||||
bot.status.message, "Le bot répondeur est maintenant {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Utilisateur", ""
|
||||
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", ""
|
||||
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", ""
|
||||
@@ -12,6 +12,7 @@ Reboot, "Перезагрузить", ""
|
||||
Reset Node DB, "Сбросить БД узлов", ""
|
||||
Shutdown, "Выключить", ""
|
||||
Factory Reset, "Сброс до заводских", ""
|
||||
factory_reset_config, "Сбросить только конфигурацию", ""
|
||||
Exit, "Выход", ""
|
||||
Yes, "Да", ""
|
||||
No, "Нет", ""
|
||||
@@ -55,6 +56,7 @@ 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, "Подождите", ""
|
||||
@@ -76,6 +78,7 @@ help.traceroute, "Ctrl+T или F4 = Traceroute", ""
|
||||
help.node_info, "F5 = Полная информация об узле", ""
|
||||
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
||||
help.favorite, "Ctrl+F = Избранное", ""
|
||||
help.bot_responder, "Ctrl+B = Вкл/выкл автоответчик", ""
|
||||
help.ignore, "Ctrl+G = Игнорировать", ""
|
||||
help.search, "Ctrl+/ или / = Поиск", ""
|
||||
help.help, "Ctrl+K = Справка", ""
|
||||
@@ -88,6 +91,11 @@ confirm.remove_ignored, "Убрать {name} из игнорируемых?", ""
|
||||
confirm.region_unset, "Ваш регион НЕ ЗАДАН. Установить сейчас?", ""
|
||||
dialog.resize_title, "Увеличьте окно", ""
|
||||
dialog.resize_body, "Пожалуйста, увеличьте окно до {rows} строк.", ""
|
||||
bot.status.enabled, "Включен", ""
|
||||
bot.status.disabled, "Выключен", ""
|
||||
bot.dialog.title, "Автоответчик", ""
|
||||
bot.dialog.body, "Автоответчик теперь {status}.", ""
|
||||
bot.status.message, "Автоответчик теперь {status}.", ""
|
||||
|
||||
[User Settings]
|
||||
user, "Пользователь"
|
||||
@@ -114,10 +122,16 @@ 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, "Пинг-бот", ""
|
||||
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
|
||||
response_word, "Ответное слово", "Ответное слово бота."
|
||||
|
||||
[app_settings.color_config]
|
||||
default, "По умолчанию", ""
|
||||
background, "Фон", ""
|
||||
|
||||
87
contact/message_handlers/bot_handler.py
Normal file
87
contact/message_handlers/bot_handler.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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.singleton import app_state, interface_state, ui_state
|
||||
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 bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
|
||||
"""Send a basic response when bot mode is enabled."""
|
||||
if not ui_state.bot_mode_enabled:
|
||||
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)
|
||||
replyIDset = packet.get('replyId', False)
|
||||
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 replyIDset:
|
||||
details.append(f"Relay: {replyIDset}")
|
||||
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 ui_state.bot_mode_enabled:
|
||||
return
|
||||
|
||||
send_message(response_data_string,channel=send_channel)
|
||||
|
||||
# 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
|
||||
@@ -60,6 +60,7 @@ from contact.utilities.db_handler import (
|
||||
import contact.ui.default_config as config
|
||||
|
||||
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
|
||||
from contact.message_handlers.bot_handler import bot_respond
|
||||
|
||||
|
||||
def play_sound():
|
||||
@@ -168,6 +169,8 @@ 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]:
|
||||
|
||||
@@ -6,19 +6,26 @@ 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.ui.dialog import dialog
|
||||
from contact.ui.splash import draw_splash
|
||||
from contact.utilities.arg_parser import setup_parser
|
||||
from contact.utilities.i18n import t
|
||||
from contact.utilities.input_handlers import get_list_input
|
||||
from contact.utilities.interfaces import initialize_interface, reconnect_interface
|
||||
|
||||
|
||||
def close_interface(interface: object) -> None:
|
||||
if interface is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
interface.close()
|
||||
|
||||
|
||||
def main(stdscr: curses.window) -> None:
|
||||
output_capture = io.StringIO()
|
||||
interface = None
|
||||
try:
|
||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||
setup_colors()
|
||||
@@ -39,7 +46,7 @@ def main(stdscr: curses.window) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
set_region(interface)
|
||||
interface.close()
|
||||
close_interface(interface)
|
||||
draw_splash(stdscr)
|
||||
interface = reconnect_interface(args)
|
||||
stdscr.clear()
|
||||
@@ -52,6 +59,8 @@ def main(stdscr: curses.window) -> None:
|
||||
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:
|
||||
|
||||
@@ -4,7 +4,7 @@ import time
|
||||
import traceback
|
||||
from typing import Union
|
||||
|
||||
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
|
||||
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list, add_new_message
|
||||
from contact.settings import settings_menu
|
||||
from contact.message_handlers.tx_handler import send_message, send_traceroute
|
||||
from contact.utilities.utils import parse_protobuf
|
||||
@@ -143,7 +143,7 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||
|
||||
if 0 <= old_index < len(ui_state.node_list):
|
||||
try:
|
||||
@@ -175,7 +175,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
if ui_state.node_list and nodes_pad is not None:
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 2)
|
||||
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
||||
refresh_pad(2)
|
||||
|
||||
@@ -185,6 +185,43 @@ def get_node_display_name(node_num: int, node: dict) -> str:
|
||||
return user.get("longName") or get_name_from_database(node_num, "long")
|
||||
|
||||
|
||||
def get_selected_channel_title() -> str:
|
||||
if not ui_state.channel_list:
|
||||
return ""
|
||||
|
||||
channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)]
|
||||
if isinstance(channel, int):
|
||||
return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel)
|
||||
return str(channel)
|
||||
|
||||
|
||||
def get_window_title(window: int) -> str:
|
||||
if window == 2:
|
||||
return f"Nodes: {len(ui_state.node_list)}"
|
||||
if ui_state.single_pane_mode and window == 1:
|
||||
return get_selected_channel_title()
|
||||
return ""
|
||||
|
||||
|
||||
def draw_frame_title(box: curses.window, title: str) -> None:
|
||||
if not title:
|
||||
return
|
||||
|
||||
_, box_w = box.getmaxyx()
|
||||
max_title_width = max(0, box_w - 6)
|
||||
if max_title_width <= 0:
|
||||
return
|
||||
|
||||
clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip()
|
||||
if not clipped_title:
|
||||
return
|
||||
|
||||
try:
|
||||
box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||
@@ -193,11 +230,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
height, width = stdscr.getmaxyx()
|
||||
|
||||
if ui_state.single_pane_mode:
|
||||
channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window)
|
||||
channel_width = width
|
||||
messages_width = width
|
||||
nodes_width = width
|
||||
channel_x = 0
|
||||
messages_x = 0
|
||||
nodes_x = 0
|
||||
else:
|
||||
channel_width = int(config.channel_list_16ths) * (width // 16)
|
||||
nodes_width = int(config.node_list_16ths) * (width // 16)
|
||||
messages_width = width - channel_width - nodes_width
|
||||
channel_x = 0
|
||||
messages_x = channel_width
|
||||
nodes_x = channel_width + messages_width
|
||||
|
||||
channel_width = max(MIN_COL, channel_width)
|
||||
messages_width = max(MIN_COL, messages_width)
|
||||
@@ -205,7 +250,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
|
||||
# Ensure the three widths sum exactly to the terminal width by adjusting the focused pane
|
||||
total = channel_width + messages_width + nodes_width
|
||||
if total != width:
|
||||
if not ui_state.single_pane_mode and total != width:
|
||||
delta = total - width
|
||||
if ui_state.current_window == 0:
|
||||
channel_width = max(MIN_COL, channel_width - delta)
|
||||
@@ -222,11 +267,11 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
if firstrun:
|
||||
entry_win = curses.newwin(entry_height, width, height - entry_height, 0)
|
||||
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, 0)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, channel_width)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, channel_width + messages_width)
|
||||
channel_win = curses.newwin(content_h, channel_width, 0, channel_x)
|
||||
messages_win = curses.newwin(content_h, messages_width, 0, messages_x)
|
||||
nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x)
|
||||
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Will be resized to what we need when drawn
|
||||
messages_pad = curses.newpad(1, 1)
|
||||
@@ -253,19 +298,25 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||
entry_win.mvwin(height - entry_height, 0)
|
||||
|
||||
channel_win.resize(content_h, channel_width)
|
||||
channel_win.mvwin(0, 0)
|
||||
channel_win.mvwin(0, channel_x)
|
||||
|
||||
messages_win.resize(content_h, messages_width)
|
||||
messages_win.mvwin(0, channel_width)
|
||||
messages_win.mvwin(0, messages_x)
|
||||
|
||||
nodes_win.resize(content_h, nodes_width)
|
||||
nodes_win.mvwin(0, channel_width + messages_width)
|
||||
nodes_win.mvwin(0, nodes_x)
|
||||
|
||||
packetlog_win.resize(pkt_h, messages_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, channel_width)
|
||||
packetlog_win.mvwin(height - pkt_h - entry_height, messages_x)
|
||||
|
||||
# Draw window borders
|
||||
for win in [channel_win, entry_win, nodes_win, messages_win]:
|
||||
windows_to_draw = [entry_win]
|
||||
if ui_state.single_pane_mode:
|
||||
windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window])
|
||||
else:
|
||||
windows_to_draw.extend([channel_win, nodes_win, messages_win])
|
||||
|
||||
for win in windows_to_draw:
|
||||
win.box()
|
||||
win.refresh()
|
||||
|
||||
@@ -394,6 +445,9 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == chr(6): # Ctrl + F to toggle favorite
|
||||
handle_ctrl_f(stdscr)
|
||||
|
||||
elif char == chr(2): # Ctrl + B to toggle bot responder
|
||||
handle_ctrl_b(stdscr)
|
||||
|
||||
elif char == chr(7): # Ctrl + G to toggle ignored
|
||||
handle_ctlr_g(stdscr)
|
||||
|
||||
@@ -868,6 +922,7 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
t("ui.help.node_info", default="F5 = Full node info"),
|
||||
t("ui.help.archive_chat", default="Ctrl+D = Archive chat / remove node"),
|
||||
t("ui.help.favorite", default="Ctrl+F = Favorite"),
|
||||
t("ui.help.bot_responder", default="Ctrl+B = Toggle Bot Responder"),
|
||||
t("ui.help.ignore", default="Ctrl+G = Ignore"),
|
||||
t("ui.help.search", default="Ctrl+/ = Search"),
|
||||
t("ui.help.help", default="Ctrl+K = Help"),
|
||||
@@ -879,6 +934,32 @@ def handle_ctrl_k(stdscr: curses.window) -> None:
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_b(stdscr: curses.window) -> None:
|
||||
"""Handle Ctrl + B key events to toggle automatic bot responses."""
|
||||
ui_state.bot_mode_enabled = not ui_state.bot_mode_enabled
|
||||
status = t("ui.bot.status.enabled", default="Enabled") if ui_state.bot_mode_enabled else t(
|
||||
"ui.bot.status.disabled", default="Disabled"
|
||||
)
|
||||
|
||||
curses.curs_set(0)
|
||||
contact.ui.dialog.dialog(
|
||||
t("ui.bot.dialog.title", default="Bot Responder"),
|
||||
t("ui.bot.dialog.body", default="Bot responder is now {status}.", status=status),
|
||||
)
|
||||
|
||||
if ui_state.channel_list:
|
||||
channel_id = ui_state.channel_list[ui_state.selected_channel]
|
||||
add_new_message(
|
||||
channel_id,
|
||||
f"{config.message_prefix} Info: ",
|
||||
t("ui.bot.status.message", default="Bot responder is now {status}.", status=status.lower()),
|
||||
)
|
||||
draw_messages_window(True)
|
||||
|
||||
curses.curs_set(1)
|
||||
handle_resize(stdscr, False)
|
||||
|
||||
|
||||
def handle_ctrl_d() -> None:
|
||||
if ui_state.current_window == 0:
|
||||
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
|
||||
@@ -1038,7 +1119,7 @@ def draw_channel_list() -> None:
|
||||
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
||||
|
||||
# Truncate the channel name if it's too long to fit in the window
|
||||
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 3)
|
||||
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 4)
|
||||
|
||||
color = get_color("channel_list")
|
||||
if idx == ui_state.selected_channel:
|
||||
@@ -1133,7 +1214,7 @@ def draw_node_list() -> None:
|
||||
node_name = get_node_display_name(node_num, node)
|
||||
|
||||
# Future node name custom formatting possible
|
||||
node_str = pad_to_width(f"{status_icon} {node_name}", box_width - 2)
|
||||
node_str = truncate_with_ellipsis(f"{status_icon} {node_name}", box_width - 4)
|
||||
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
||||
|
||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||
@@ -1365,7 +1446,6 @@ def refresh_pad(window: int) -> None:
|
||||
pad = nodes_pad
|
||||
box = nodes_win
|
||||
lines = box.getmaxyx()[0] - 2
|
||||
box.addstr(0, 2, (f"Nodes: {len(ui_state.node_list)}"), curses.A_BOLD)
|
||||
selected_item = ui_state.selected_node
|
||||
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
|
||||
|
||||
@@ -1402,6 +1482,9 @@ def refresh_pad(window: int) -> None:
|
||||
if bottom < top or right < left:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
|
||||
pad.refresh(
|
||||
start_index,
|
||||
0,
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
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
|
||||
@@ -34,7 +35,7 @@ MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
|
||||
save_option = "Save Changes"
|
||||
max_help_lines = 0
|
||||
help_win = None
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
|
||||
# Compute the effective menu width for the current terminal
|
||||
@@ -43,7 +44,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"]
|
||||
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
|
||||
|
||||
# Get the parent directory of the script
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -246,6 +247,26 @@ def reconnect_after_admin_action(stdscr: object, interface: object, action, log_
|
||||
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
|
||||
@@ -538,7 +559,27 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
||||
)
|
||||
if confirmation == "Yes":
|
||||
interface = reconnect_after_admin_action(
|
||||
stdscr, interface, interface.localNode.factoryReset, "Factory Reset Requested by menu"
|
||||
stdscr,
|
||||
interface,
|
||||
lambda: request_factory_reset(interface.localNode, 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(interface.localNode, full=False),
|
||||
"Factory Reset Config Requested by menu",
|
||||
)
|
||||
menu = rebuild_menu_at_current_path(interface, menu_state)
|
||||
menu_state.start_index.pop()
|
||||
|
||||
@@ -56,9 +56,13 @@ def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client")
|
||||
|
||||
return fallback_dir
|
||||
|
||||
|
||||
# Pick the root now.
|
||||
config_root = _get_config_root(parent_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)
|
||||
|
||||
# Paths (derived from the chosen root)
|
||||
json_file_path = os.path.join(config_root, "config.json")
|
||||
@@ -238,6 +242,10 @@ def initialize_config() -> Dict[str, object]:
|
||||
"ack_unknown_str": "[…]",
|
||||
"node_sort": "lastHeard",
|
||||
"theme": "dark",
|
||||
"ping_bot": {
|
||||
"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,
|
||||
@@ -272,7 +280,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
|
||||
global node_sort, notification_sound, ping_bot_catch_words, ping_bot_response_word
|
||||
|
||||
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||
node_list_16ths = loaded_config["node_list_16ths"]
|
||||
@@ -291,6 +299,9 @@ 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_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":
|
||||
|
||||
@@ -2,7 +2,7 @@ import curses
|
||||
|
||||
from contact.utilities.i18n import t_text
|
||||
from contact.ui.colors import get_color
|
||||
from contact.ui.nav_utils import draw_main_arrows
|
||||
from contact.ui.nav_utils import draw_main_arrows, slice_to_width, text_width
|
||||
from contact.utilities.singleton import menu_state, ui_state
|
||||
|
||||
|
||||
@@ -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(len(l) for l in message_lines)
|
||||
max_line_length = max(text_width(l) for l in message_lines)
|
||||
|
||||
# Desired size
|
||||
dialog_width = max(len(title) + 4, max_line_length + 4)
|
||||
dialog_width = max(text_width(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, title[: max(0, dialog_width - 4)], get_color("settings_default"))
|
||||
win.addstr(0, 2, slice_to_width(title, max(0, dialog_width - 4)), get_color("settings_default"))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
@@ -80,9 +80,8 @@ def dialog(title: str, message: str) -> None:
|
||||
if idx >= len(message_lines):
|
||||
break
|
||||
line = message_lines[idx]
|
||||
# Hard-trim lines that don't fit
|
||||
trimmed = line[: max(0, dialog_width - 6)]
|
||||
msg_x = max(0, ((dialog_width - 2) - len(trimmed)) // 2)
|
||||
trimmed = slice_to_width(line, max(0, dialog_width - 4))
|
||||
msg_x = max(0, ((dialog_width - 2) - text_width(trimmed)) // 2)
|
||||
try:
|
||||
msg_win.addstr(1 + i, msg_x, trimmed, get_color("settings_default"))
|
||||
except curses.error:
|
||||
|
||||
@@ -133,6 +133,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
|
||||
"Reset Node DB": None,
|
||||
"Shutdown": None,
|
||||
"Factory Reset": None,
|
||||
"factory_reset_config": None,
|
||||
"Exit": None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -457,8 +457,8 @@ def highlight_line(
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
|
||||
|
||||
elif ui_state.current_window == 2:
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(new_idx, reverse=True))
|
||||
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
|
||||
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
|
||||
|
||||
menu_win.refresh()
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class ChatUIState:
|
||||
current_window: int = 0
|
||||
last_sent_time: float = 0.0
|
||||
last_traceroute_time: float = 0.0
|
||||
bot_mode_enabled: bool = False
|
||||
|
||||
selected_index: int = 0
|
||||
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||
|
||||
@@ -309,7 +309,10 @@ def display_menu() -> tuple[Any, Any, List[str]]:
|
||||
else:
|
||||
display_key = key
|
||||
display_key = f"{display_key}"[: w // 2 - 2]
|
||||
display_value = f"{value}"[: w // 2 - 8]
|
||||
if isinstance(value, dict) or (isinstance(value, list) and len(value) != 2):
|
||||
display_value = ">"
|
||||
else:
|
||||
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)
|
||||
|
||||
@@ -31,7 +31,8 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
|
||||
"""
|
||||
ensure_table_exists(quoted_table_name, schema)
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
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()
|
||||
timestamp = int(time.time())
|
||||
|
||||
@@ -53,7 +54,8 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
|
||||
|
||||
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None:
|
||||
try:
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
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()
|
||||
update_query = f"""
|
||||
UPDATE {get_table_name(channel)}
|
||||
@@ -76,7 +78,8 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> 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) as db_connection:
|
||||
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 = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
|
||||
@@ -92,6 +95,7 @@ def load_messages_from_db() -> None:
|
||||
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)
|
||||
db_connection.commit()
|
||||
|
||||
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
|
||||
|
||||
@@ -228,7 +232,8 @@ 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) as db_connection:
|
||||
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()
|
||||
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names
|
||||
|
||||
@@ -236,6 +241,7 @@ 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,))
|
||||
@@ -311,7 +317,8 @@ 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) as db_connection:
|
||||
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()
|
||||
create_table_query = f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})"
|
||||
db_cursor.execute(create_table_query)
|
||||
@@ -331,7 +338,8 @@ 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) as db_connection:
|
||||
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()
|
||||
|
||||
# Construct table name
|
||||
@@ -359,7 +367,8 @@ 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) as db_connection:
|
||||
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()
|
||||
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
|
||||
nodeinfo_table = f'"{table_name}"'
|
||||
|
||||
@@ -141,7 +141,8 @@ def seed_demo_messages() -> None:
|
||||
ack_type TEXT
|
||||
"""
|
||||
|
||||
with sqlite3.connect(config.db_file_path) as db_connection:
|
||||
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():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.5.2"
|
||||
version = "1.5.7"
|
||||
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"}
|
||||
|
||||
31
tests/test_bot_handler.py
Normal file
31
tests/test_bot_handler.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import unittest
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
|
||||
|
||||
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 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"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,6 +3,7 @@ from unittest import mock
|
||||
|
||||
import contact.ui.default_config as config
|
||||
from contact.ui import contact_ui
|
||||
from contact.ui.nav_utils import text_width
|
||||
from contact.utilities.singleton import ui_state
|
||||
|
||||
from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
@@ -69,7 +70,7 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertFalse(ui_state.redraw_channels)
|
||||
self.assertFalse(ui_state.redraw_messages)
|
||||
|
||||
def test_refresh_node_selection_highlights_full_row_width(self) -> None:
|
||||
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]
|
||||
@@ -89,7 +90,113 @@ class ContactUiTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
contact_ui.nodes_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
[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:
|
||||
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):
|
||||
contact_ui.refresh_pad(1)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
@@ -63,3 +64,49 @@ class ControlUiTests(unittest.TestCase):
|
||||
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"])
|
||||
|
||||
88
tests/test_dialog.py
Normal file
88
tests/test_dialog.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import dialog as dialog_module
|
||||
from contact.utilities.singleton 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()
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
|
||||
@@ -55,3 +56,20 @@ class I18nTests(unittest.TestCase):
|
||||
config.language = "ru"
|
||||
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
|
||||
self.assertEqual(parse_ini_file.call_count, 2)
|
||||
|
||||
def test_bot_ui_translation_keys_exist_in_all_locales(self) -> None:
|
||||
required_keys = {
|
||||
"ui.help.bot_responder",
|
||||
"ui.bot.status.enabled",
|
||||
"ui.bot.status.disabled",
|
||||
"ui.bot.dialog.title",
|
||||
"ui.bot.dialog.body",
|
||||
"ui.bot.status.message",
|
||||
"app_settings.ping_bot",
|
||||
"app_settings.ping_bot.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}")
|
||||
|
||||
@@ -186,6 +186,15 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
wrapper.assert_called_once_with(entrypoint.main)
|
||||
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()
|
||||
|
||||
wrapper.assert_called_once_with(entrypoint.main)
|
||||
|
||||
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
|
||||
stdscr = mock.Mock()
|
||||
args = Namespace(settings=False, demo_screenshot=False)
|
||||
@@ -215,6 +224,18 @@ class MainRuntimeTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
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")):
|
||||
|
||||
28
tests/test_menus.py
Normal file
28
tests/test_menus.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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")
|
||||
@@ -18,7 +18,7 @@ class NavUtilsTests(unittest.TestCase):
|
||||
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
|
||||
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
|
||||
|
||||
def test_highlight_line_uses_full_node_row_width(self) -> None:
|
||||
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()
|
||||
@@ -32,5 +32,5 @@ class NavUtilsTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
menu_pad.chgat.call_args_list,
|
||||
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
|
||||
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
|
||||
)
|
||||
|
||||
75
tests/test_settings.py
Normal file
75
tests/test_settings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from argparse import Namespace
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import contact.settings 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, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=interface):
|
||||
with mock.patch.object(settings, "settings_menu") as settings_menu:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
settings.main(stdscr)
|
||||
|
||||
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, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=old_interface):
|
||||
with mock.patch.object(settings, "get_list_input", return_value="Yes"):
|
||||
with mock.patch.object(settings, "set_region") as set_region:
|
||||
with mock.patch.object(
|
||||
settings, "reconnect_interface", return_value=new_interface
|
||||
) as reconnect_interface:
|
||||
with mock.patch.object(settings, "settings_menu") as settings_menu:
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
settings.main(stdscr)
|
||||
|
||||
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, "setup_parser") as setup_parser:
|
||||
with mock.patch.object(settings, "initialize_interface", return_value=interface):
|
||||
with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")):
|
||||
setup_parser.return_value.parse_args.return_value = args
|
||||
with self.assertRaises(RuntimeError):
|
||||
settings.main(stdscr)
|
||||
|
||||
interface.close.assert_called_once_with()
|
||||
Reference in New Issue
Block a user