Compare commits

..

25 Commits
1.5.2 ... 1.5.7

Author SHA1 Message Date
pdxlocations
80ede60153 bump version to 1.5.7 in pyproject.toml 2026-03-28 22:42:34 -07:00
pdxlocations
046ede23a2 Merge pull request #265 from stason200711/main
Allow overriding DATA_DIR via environment variable and fix file descriptor leaks
2026-03-28 22:41:47 -07:00
pdxlocations
0ad39fd6a0 Add Ping Bot Config to Settings 2026-03-28 22:35:34 -07:00
pdxlocations
6721874937 add bot strings to i18n and fix dilogue width 2026-03-28 22:22:41 -07:00
pdxlocations
8ee21b1973 Merge pull request #266 from SpudGunMan:bot
auto respond bot
2026-03-28 22:10:26 -07:00
Kelly
0760d6d0ca auto respond bot
Added an optional auto-responder bot that listens for configurable trigger words (from localisation) and sends a response with available link metadata (like SNR/RSSI/hops/transport) The feature is toggleable from the UI shortcut ctl+B
2026-03-28 19:57:22 -07:00
stason200711
cd0b114f13 Update db_handler.py 2026-03-28 09:52:37 +03:00
stason200711
2d4b407caa Update default_config.py
Allow overriding DATA_DIR via environment variable
2026-03-26 09:55:00 +03:00
stason200711
80b9311959 Update demo_data.py
Fix file descriptor leaks
2026-03-26 09:52:08 +03:00
stason200711
da5f5bff39 Update db_handler.py
Fix file descriptor leaks
2026-03-25 23:20:36 +03:00
pdxlocations
bc69c709ff Bump version to 1.5.6 in pyproject.toml 2026-03-22 21:37:12 -07:00
pdxlocations
6453865cba Merge pull request #262 from SpudGunMan:main
refactor exit
2026-03-22 21:36:27 -07:00
pdxlocations
090e89d92c Merge pull request #263 from pdxlocations:add-french-language
Add French localization file for user interface and settings
2026-03-22 21:16:15 -07:00
pdxlocations
07716c1719 Add French localization file for user interface and settings 2026-03-22 21:15:58 -07:00
Kelly
da5b102047 Update __main__.py 2026-03-22 14:44:22 -07:00
pdxlocations
1668b68c4f Bump version to 1.5.5 in pyproject.toml 2026-03-21 22:08:57 -07:00
pdxlocations
fd4b9e2174 Add tests for handling interface absence and keyboard interrupts in start function 2026-03-21 22:08:41 -07:00
pdxlocations
8f32e2c99c Merge pull request #260 from pdxlocations:reset
Add factory reset config option and tryfix factory reset
2026-03-21 21:28:29 -07:00
pdxlocations
b53dab1840 Add factory reset config option and tryfix factory reset 2026-03-21 21:28:15 -07:00
pdxlocations
f2904eb550 Merge pull request #259 from pdxlocations:fix-shutdown
Fix interface shutdown handling
2026-03-21 21:14:38 -07:00
pdxlocations
480c32ba56 try fix shutdown 2026-03-21 21:13:53 -07:00
pdxlocations
b4b084b627 bump version to 1.5.4 in pyproject.toml 2026-03-19 15:47:51 -07:00
pdxlocations
5940c9b02b fix content margins 2026-03-19 15:19:24 -07:00
pdxlocations
c492c96685 bump version to 1.5.3 in pyproject.toml 2026-03-19 14:37:29 -07:00
pdxlocations
90376d35f3 Single pane mode fix 2026-03-19 14:37:14 -07:00
28 changed files with 998 additions and 61 deletions

View File

@@ -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.

View File

@@ -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()

View File

@@ -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", ""

View 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", ""

View File

@@ -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, "Фон", ""

View 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

View File

@@ -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]:

View File

@@ -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:

View File

@@ -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,

View File

@@ -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()

View File

@@ -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":

View File

@@ -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:

View File

@@ -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,
}
)

View File

@@ -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()

View File

@@ -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])

View File

@@ -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)

View File

@@ -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}"'

View File

@@ -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():

View File

@@ -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
View 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()

View File

@@ -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)

View File

@@ -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
View 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()

View File

@@ -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}")

View File

@@ -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
View 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")

View File

@@ -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
View 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()