Compare commits

...
98 Commits
Author SHA1 Message Date
pdxlocations ac23c2bbbb Merge remote-tracking branch 'origin/main' into remote-admin
# Conflicts:
#	contact/ui/contact_ui.py
#	contact/ui/control_ui.py
2026-07-25 21:55:13 -07:00
pdxlocations e378aba526 Refactor remote admin handling to improve status updates and timeout management 2026-07-25 21:53:36 -07:00
pdxlocations ec35cbd3e7 Merge pull request #294 from pdxlocations:reconnect-01
Reconnect after disconnect
2026-07-25 21:47:05 -07:00
pdxlocations 7f6fa49385 Enhance reconnect logic and error handling in UI and interface modules
- Implement automatic reconnection attempts in the main UI when disconnected.
- Add a non-blocking connection status dialog to inform users during reconnection.
- Improve error handling when sending messages and during interface reconnections.
- Update the reconnect_interface function to increase the number of attempts and refine connection checks.
2026-07-25 21:46:27 -07:00
pdxlocations 4ed3cbf370 bump version to 1.6.2 in pyproject.toml 2026-07-25 14:31:22 -07:00
pdxlocations 7afc025b9d Refactor settings menu to improve channel request handling and status updates 2026-07-25 14:30:58 -07:00
pdxlocations ece37f2a36 bump version to 1.6.1 in pyproject.toml 2026-07-24 23:40:07 -07:00
pdxlocations 0cee9777bc Improve sound playback handling by adding support for multiple audio players and enhancing error logging 2026-07-24 23:39:46 -07:00
pdxlocations 72956321da bump version to 1.6.0 in pyproject.toml 2026-07-24 23:16:02 -07:00
pdxlocations 6a61f7dcd5 Merge pull request #290 from pdxlocations:notifications-2
Add notification sound feature and update configuration
2026-07-24 23:12:45 -07:00
pdxlocations b1b9f22203 Add notification sound feature and update configuration 2026-07-24 23:12:21 -07:00
pdxlocations f63c27e9ad typo 2026-07-24 22:57:30 -07:00
pdxlocations 8ea5646f6c Merge pull request #284 from pdxlocations:update-en.ini-with-2.8-fields
Add new configuration options for device UI and modules
2026-07-24 22:56:27 -07:00
pdxlocations 41d703ad9b Merge pull request #289 from pdxlocations:remote-admin
Remote-admin
2026-07-24 22:52:43 -07:00
pdxlocations f4bd0c8614 add remote admin 2026-07-24 22:50:39 -07:00
pdxlocations 68b5d81c1f Enhance settings menu and remote admin handling
- Refactor settings_menu to support remote node configurations.
- Add show_remote_admin_wait function for displaying progress during remote admin requests.
- Update save_changes to accept a node parameter for better flexibility.
- Improve error handling for remote admin rejections in handle_backtick.
- Add unit tests for new remote admin functionalities and error scenarios.
2026-07-24 15:47:06 -07:00
pdxlocations 61bbc24d29 Merge pull request #287 from pdxlocations:display-bug
Clear stale reply context in draw_text_field function and add corresponding test
2026-07-24 15:25:38 -07:00
pdxlocations 0143d9bacc Clear stale reply context in draw_text_field function and add corresponding test 2026-07-24 15:25:07 -07:00
pdxlocations 424e10af1e Bump version to 1.5.13 in pyproject.toml 2026-07-24 10:17:55 -07:00
pdxlocations 986cf817f3 Merge pull request #286 from pdxlocations:canned-messages-and-ringtone
Add Canned Messages and Ringone
2026-07-24 10:17:18 -07:00
pdxlocations c699845c81 Improve error handling in API responses
Enhance the error handling mechanism in API responses to provide clearer and more informative messages to users. This change aims to improve user experience and debugging capabilities.
2026-07-24 10:14:49 -07:00
pdxlocations 7273e80639 Add new configuration options for device UI and modules 2026-07-24 09:55:53 -07:00
pdxlocations 954bd6ee82 Bump version to 1.5.12 in pyproject.toml 2026-07-24 09:35:48 -07:00
pdxlocations 2cebd6b659 Merge pull request #283 from pdxlocations:bot-enable-config
Move bot responder enable to app config
2026-07-24 09:31:51 -07:00
pdxlocations 72d959a3c1 Move bot responder enable to app config 2026-07-24 09:31:34 -07:00
pdxlocations 73b31a5124 Merge pull request #282 from pdxlocations:ping-reply
Enhance bot response to include triggering packet ID for native replies
2026-07-24 09:20:43 -07:00
pdxlocations d403d2cea5 Enhance bot response to include triggering packet ID for native replies 2026-07-24 09:20:11 -07:00
pdxlocations 59a7b5b488 Fix reply sender extraction to handle missing sender gracefully 2026-07-24 09:13:24 -07:00
pdxlocations 5baef73c3c Bump version to 1.5.11 in pyproject.toml 2026-07-18 22:58:03 -07:00
pdxlocations 46a73fbc65 Fix reply prefix logic to prevent nesting existing reply markers and update tests accordingly 2026-07-18 22:57:32 -07:00
pdxlocations 0145d84f3b Bump version to 1.5.10 in pyproject.toml 2026-07-18 22:50:29 -07:00
pdxlocations ff656f20ad Implement local short name retrieval and update reply prefix logic for outgoing messages 2026-07-18 22:49:57 -07:00
pdxlocations eef321cac7 Bump version to 1.5.9 in pyproject.toml 2026-07-18 22:25:00 -07:00
pdxlocations cc33716222 Merge pull request #281 from pdxlocations/replies
Support replies
2026-07-18 22:23:33 -07:00
pdxlocations 7efc1a640d Enhance messaging features with reply support and message selection preservation
- Updated `send_message` to include reply ID and context for native replies.
- Modified `onAckNak` to correctly update the database with the message context.
- Implemented reply prefix formatting in `build_reply_prefix` for better message clarity.
- Added functionality to preserve message selection when new messages are received.
- Enhanced database handling to store packet IDs and reply IDs for messages.
- Updated UI state to manage reply context and message highlighting effectively.
- Improved tests to cover new reply features and message selection behavior.
2026-07-18 22:21:55 -07:00
pdxlocations b54e467ef0 Merge pull request #280 from pdxlocations:message-loading
Slowness with busy channels
2026-07-18 21:34:40 -07:00
pdxlocations 8c01f8fc17 Reduce MESSAGE_PAGE_SIZE to improve message loading efficiency and update tests to verify separator consistency. 2026-07-18 21:34:07 -07:00
pdxlocations c6164af582 Refactor message loading logic to replace channel messages and avoid duplicates 2026-07-18 21:28:57 -07:00
pdxlocations 52f42591d2 Slowness with busy channels
Fixes #279
2026-07-18 21:24:32 -07:00
pdxlocations 69cb568d2c bump version to 1.5.8 in pyproject.toml 2026-05-07 13:50:36 -07:00
pdxlocations 68b08787de Enhance error handling in contact UI and improve metrics display logic 2026-05-07 13:50:12 -07:00
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
pdxlocations 4b35a74e2c bump version to 1.5.2 in pyproject.toml 2026-03-19 14:07:16 -07:00
pdxlocations ecc5308dad Refactor UI redraw handling and improve message drawing logic 2026-03-19 14:06:01 -07:00
pdxlocations 8f376edabe bump version to 1.5.1 in pyproject.toml 2026-03-19 11:41:08 -07:00
pdxlocations e5ef87ec19 Merge pull request #258 from pdxlocations:reconnect
Reconnect after config changes
2026-03-19 11:40:41 -07:00
pdxlocations 1b6d269d50 Reconnect after config changes 2026-03-19 11:40:24 -07:00
pdxlocations 1d95dae536 Merge pull request #257 from pdxlocations:tests
Add tests
2026-03-19 11:05:03 -07:00
pdxlocations 705b25192c full test suite 2026-03-19 09:49:11 -07:00
pdxlocations 6c5ae3b168 Add tests and demo screenshot 2026-03-18 23:00:49 -07:00
pdxlocations 02b4866a38 Update README.md 2026-03-18 22:58:41 -07:00
pdxlocations 286b5a531b Bump version to 1.5.0 in pyproject.toml 2026-03-18 22:37:31 -07:00
pdxlocations 004868c7fc Merge pull request #256 from pdxlocations:fix-search
Fix ctrl / on mac terminal
2026-03-18 22:36:39 -07:00
pdxlocations b43e3f4868 Fix ctrl / on mac terminal 2026-03-18 22:36:23 -07:00
pdxlocations 2e8e21f5ba Merge pull request #255 from pdxlocations:info-window
scroll nodes in info window
2026-03-18 22:33:38 -07:00
pdxlocations 09f0d626df scroll nodes in info window 2026-03-18 22:33:07 -07:00
pdxlocations 8c37f2394b Merge pull request #254 from pdxlocations:single-pane-reload
single pane reload configuration on save
2026-03-18 22:21:53 -07:00
pdxlocations 568f29ee29 single pane reload configuration on save 2026-03-18 22:21:36 -07:00
pdxlocations 87127adef1 Merge pull request #253 from pdxlocations:UI
Enhance UI color handling for channel and node rows, and refactor refresh logic
2026-03-18 22:17:27 -07:00
pdxlocations dd0eb1473c Enhance UI color handling for channel and node rows, and refactor refresh logic 2026-03-18 22:17:08 -07:00
pdxlocations 7d9703a548 Merge pull request #252 from pdxlocations:normalize-emojis
Add emoji normalization utility and integrate into message rendering
2026-03-18 21:37:02 -07:00
pdxlocations 68b8d15181 Add emoji normalization utility and integrate into message rendering 2026-03-18 21:36:46 -07:00
pdxlocations 4ef87871df bump version 2026-03-12 19:37:21 -07:00
pdxlocations 18df7d326a Merge pull request #250 from pdxlocations:resize-faster
Add resize event handling
2026-03-12 16:21:15 -07:00
pdxlocations c7b54caf45 Add resize event handling and update version to 1.4.18 2026-03-12 15:30:03 -07:00
pdxlocations 773f43edd8 Merge pull request #248 from pdxlocations:fix-protubuf-depend
Fix-protubuf-depend
2026-03-02 16:31:55 -08:00
pdxlocations 6af1c46bd3 bump version to 1.4.17 2026-03-02 16:31:33 -08:00
pdxlocations 7e3e44df24 Refactor repeated field handling in protobuf utilities 2026-03-02 16:31:09 -08:00
pdxlocations 45626f5e83 bump version 2026-02-28 10:32:43 -08:00
pdxlocations e9181972b2 bump version 2026-02-28 10:32:16 -08:00
pdxlocations 795ab84ef5 Fix 3.9 compatibility 2026-02-28 10:31:48 -08:00
pdxlocations 5e108c5fe5 version bump 2026-02-12 07:45:41 -08:00
pdxlocations edef37b116 IP bug fix 2026-02-12 07:38:43 -08:00
pdxlocations e7e1bf7852 Merge pull request #246 from pdxlocations:display-ip
Display Human-Readable IP's and Actually Save Nested Configs
2026-02-11 21:57:54 -08:00
57 changed files with 4866 additions and 563 deletions

No files matched your search

+8
View File
@@ -17,6 +17,14 @@
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": ["--host","192.168.86.69"]
},
{
"name": "Python Debugger: tcp-remote",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": ["--host","100.89.187.29"]
}
]
}
+9 -2
View File
@@ -16,8 +16,7 @@ pip install contact
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<img width="991" height="516" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/76722145-e8a4-4f01-8898-f4ae794b5d7b" />
<br><br>
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
@@ -46,6 +45,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
### Notification sounds
Add audio files to Contact's `contact/sounds/` folder, then choose one in Settings → App Settings → Notification sound. `alert.mp3` is the default selection; choose `None` to disable notification audio. Supported file types are MP3, WAV, OGG, AIFF, and FLAC.
### Remote administration
With the Nodes pane selected, press `` ` `` or `F12` and choose **Remote admin** for the highlighted node. Contact requests the node's settings through Meshtastic Remote Admin and displays an error if the node does not authorize your public key. Remote App Settings are intentionally unavailable because they apply only to the local Contact client.
For smaller displays you may wish to enable `single_pane_mode`:
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
+106 -13
View File
@@ -19,6 +19,7 @@ import subprocess
import sys
import threading
import traceback
from typing import Optional
# Third-party
from pubsub import pub
@@ -32,10 +33,11 @@ from contact.ui.contact_ui import main_ui
from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from contact.utilities.demo_data import build_demo_interface, configure_demo_database, seed_demo_messages
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.interfaces import initialize_interface
from contact.utilities.interfaces import initialize_interface, reconnect_interface
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list
from contact.utilities.singleton import ui_state, interface_state, app_state
@@ -55,22 +57,93 @@ logging.basicConfig(
app_state.lock = threading.Lock()
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
def prompt_region_if_unset(args: object) -> None:
def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None) -> None:
"""Prompt user to set region if it is unset."""
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface_state.interface)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
close_interface(interface_state.interface)
if stdscr is not None:
draw_splash(stdscr)
interface_state.interface = reconnect_interface(args)
def initialize_globals() -> None:
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
if interface is None:
return True
close_errors = []
def _close_target() -> None:
try:
interface.close()
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
close_errors.append(error)
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
close_thread.start()
close_thread.join(timeout_seconds)
if close_thread.is_alive():
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
return False
if not close_errors:
return True
error = close_errors[0]
if isinstance(error, KeyboardInterrupt):
logging.info("Interrupted while closing interface; continuing shutdown")
return True
logging.warning("Ignoring error while closing interface: %r", error)
return True
def interface_is_ready(interface: object) -> bool:
try:
return getattr(interface, "localNode", None) is not None and interface.localNode.localConfig is not None
except Exception:
return False
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
while True:
interface = initialize_runtime_interface(args)
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
return interface
choice = get_list_input(
t("ui.prompt.node_not_found", default="No node found. Retry connection?"),
"Retry",
["Retry", "Close"],
mandatory=True,
)
close_interface(interface)
if choice == "Close":
return None
draw_splash(stdscr)
def initialize_globals(seed_demo: bool = False) -> None:
"""Initializes interface and shared globals."""
ui_state.channel_list = []
ui_state.all_messages = {}
ui_state.notifications = []
ui_state.packet_buffer = []
ui_state.node_list = []
ui_state.selected_channel = 0
ui_state.selected_message = 0
ui_state.selected_node = 0
ui_state.start_index = [0, 0, 0]
interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
@@ -78,9 +151,18 @@ def initialize_globals() -> None:
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
if seed_demo:
seed_demo_messages()
load_messages_from_db()
def initialize_runtime_interface(args: object):
if getattr(args, "demo_screenshot", False):
configure_demo_database()
return build_demo_interface()
return initialize_interface(args)
def main(stdscr: curses.window) -> None:
"""Main entry point for the curses UI."""
@@ -98,12 +180,14 @@ def main(stdscr: curses.window) -> None:
logging.info("Initializing interface...")
with app_state.lock:
interface_state.interface = initialize_interface(args)
interface_state.interface = initialize_runtime_interface_with_retry(stdscr, args)
if interface_state.interface is None:
return
if interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args)
if not getattr(args, "demo_screenshot", False) and interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args, stdscr)
initialize_globals()
initialize_globals(seed_demo=getattr(args, "demo_screenshot", False))
logging.info("Starting main UI")
stdscr.clear()
@@ -148,23 +232,32 @@ def start() -> None:
setup_parser().print_help()
sys.exit(0)
interrupted = False
fatal_error = None
try:
curses.wrapper(main)
interface_state.interface.close()
except KeyboardInterrupt:
interrupted = True
logging.info("User exited with Ctrl+C")
interface_state.interface.close()
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()
+81 -3
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", ""
@@ -65,6 +67,8 @@ dialog.traceroute_not_sent_body, "Please wait {seconds} seconds before sending a
dialog.traceroute_sent_title, "Traceroute Sent To: {name}", ""
dialog.traceroute_sent_body, "Results will appear in messages window.", ""
dialog.help_title, "Help - Shortcut Keys", ""
dialog.reply_unavailable_title, "Reply unavailable", ""
dialog.reply_unavailable_body, "This message has no packet ID, so Contact cannot send a native Meshtastic reply.", ""
help.scroll, "Up/Down = Scroll", ""
help.switch_window, "Left/Right = Switch window", ""
help.jump_windows, "F1/F2/F3 = Jump to Channel/Messages/Nodes", ""
@@ -72,12 +76,13 @@ help.enter, "ENTER = Send / Select", ""
help.settings, "` or F12 = Settings", ""
help.quit, "ESC = Quit", ""
help.packet_log, "Ctrl+P = Toggle Packet Log", ""
help.reply, "Ctrl+R = Reply to message at cursor", ""
help.traceroute, "Ctrl+T or F4 = Traceroute", ""
help.node_info, "F5 = Full node info", ""
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
help.favorite, "Ctrl+F = Favorite", ""
help.ignore, "Ctrl+G = Ignore", ""
help.search, "Ctrl+/ = Search", ""
help.search, "Ctrl+/ or / = Search", ""
help.help, "Ctrl+K = Help", ""
help.no_help, "No help available.", ""
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
@@ -107,17 +112,24 @@ language, "Language", "UI language for labels and help text."
message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", ""
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (unknown)", ""
node_sort, "Node sort", ""
theme, "Theme", ""
ping_bot, "Ping Bot", ""
COLOR_CONFIG_DARK, "Theme colors (dark)", ""
COLOR_CONFIG_LIGHT, "Theme colors (light)", ""
COLOR_CONFIG_GREEN, "Theme colors (green)", ""
[app_settings.ping_bot]
title, "Ping Bot", ""
enabled, "Enabled", "Enable automatic Ping Bot responses."
catch_words, "Catch words", "Semicolon-separated bot trigger words."
response_word, "Response word", "Bot response word."
[app_settings.color_config]
default, "Default", ""
background, "Background", ""
@@ -240,6 +252,8 @@ heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' o
wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button."
compass_orientation, "Compass orientation", "Whether to rotate the compass."
use_12h_clock, "Use 12 hour clock"
use_long_node_name, "Use long node name", "Show each node's long name on the device display when space permits."
enable_message_bubbles, "Enable message bubbles", "Display messages in chat-style bubbles on supported device displays."
[config.device_ui]
title, "Device UI"
@@ -258,6 +272,10 @@ node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
compass_mode, "Compass mode", "Controls whether the compass is dynamic, fixed, or freezes its heading."
screen_rgb_color, "Screen RGB color", "Sets the display color on supported RGB screens."
is_clockface_analog, "Analog clock face", "Use an analog clock face on supported device displays."
gps_format, "GPS coordinate format", "Select the coordinate format used by the device UI."
[config.device_ui.node_filter]
title, "Node Filter"
@@ -283,6 +301,12 @@ home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.device_ui.map_data.home]
title, "Home location", ""
zoom, "Zoom", "Map zoom level for the home location."
latitude, "Latitude", "Latitude of the home location."
longitude, "Longitude", "Longitude of the home location."
[config.lora]
title, "LoRa"
use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset."
@@ -302,6 +326,8 @@ override_frequency, "Override frequency in MHz", "Overrides frequency slot. May
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
fem_lna_mode, "FEM LNA mode", "Configures the front-end module low-noise amplifier mode on supported hardware."
serial_hal_only, "Serial HAL only", "Use only the serial hardware abstraction layer on supported hardware."
[config.bluetooth]
title, "Bluetooth"
@@ -318,6 +344,7 @@ serial_enabled, "Enable serial console", ""
debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active."
admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes."
admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3."
packet_signature_policy, "Packet signature policy", "Controls how the node validates packet signatures."
[module.mqtt]
title, "MQTT"
@@ -349,6 +376,7 @@ override_console_serial_port, "Override console serial port", "If set to true, t
[module.external_notification]
title, "External Notification"
ringtone, "Ringtone", "RTTTL ringtone played by supported external-notification hardware."
enabled, "Enabled", "Enables the module."
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
@@ -379,6 +407,7 @@ title, "Range Test"
enabled, "Enabled", "Enables the module."
sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages."
save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]"
clear_on_reboot, "Clear CSV on reboot", "Delete the RangeTest.csv file when the device reboots. [warning]Only available on ESP32-based devices.[/warning]"
[module.telemetry]
title, "Telemetry"
@@ -396,9 +425,11 @@ health_measurement_enabled, "Health telemetry interval", "This option is used to
health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network."
health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display."
device_telemetry_enabled, "Device telemetry enabled", "Enable the Device Telemetry"
air_quality_screen_enabled, "Air quality screen enabled", "Show air-quality telemetry on the device display."
[module.canned_message]
title, "Canned Message"
messages, "Messages", "Pipe-separated canned messages, for example: Hi|On my way|Yes|No."
rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder."
inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A."
inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B."
@@ -456,5 +487,52 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
title, "Paxcounter"
enabled, "Enabled", "Enables the module."
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
wifi_threshold, "Wi-Fi threshold", "Wi-Fi RSSI threshold. Defaults to -80."
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
[module.statusmessage]
title, "Status Message"
node_status, "Node status", ""
[module.traffic_management]
title, "Traffic Management"
position_min_interval_secs, "Position minimum interval", "Minimum seconds between position updates from the same node. Set to 0 to disable position-update suppression."
nodeinfo_direct_response_max_hops, "NodeInfo direct-response maximum hops", "Maximum hop distance at which cached NodeInfo requests receive a direct response. Set to 0 to disable direct responses."
rate_limit_window_secs, "Rate-limit window", "Per-node rate-limit window in seconds. Set to 0 to disable rate limiting."
rate_limit_max_packets, "Rate-limit maximum packets", "Maximum packets allowed per node during the rate-limit window. Set to 0 to disable rate limiting."
unknown_packet_threshold, "Unknown-packet threshold", "Maximum unknown or undecryptable packets per rate-limit window before packets from that source are dropped. Set to 0 to disable filtering."
[module.tak]
title, "Tak"
team, "Team", "Your team color."
role, "Role", "Your role in the team."
[module.mesh_beacon]
title, "Mesh Beacon", ""
flags, "Beacon options", "Bitwise combination of the listen, broadcast, and legacy-split options."
broadcast_send_as_node, "Broadcast as node", "Optional node ID to use as the sender of beacon packets. Leave unset to send as this node."
broadcast_message, "Broadcast message", "Message included in each beacon broadcast. Maximum 100 bytes."
broadcast_offer_channel, "Offered channel", "Optional channel name and key to advertise in the beacon offer."
broadcast_offer_region, "Offered region", "Optional LoRa region to advertise in the beacon offer."
broadcast_offer_preset, "Offered modem preset", "Optional modem preset to advertise in the beacon offer."
broadcast_on_channel, "Broadcast channel", "Optional channel settings to use when transmitting beacons. Uses the primary channel when unset."
broadcast_on_region, "Broadcast region", "Optional LoRa region to use when transmitting beacons."
broadcast_on_preset, "Broadcast modem preset", "Optional modem preset to use when transmitting beacons."
broadcast_interval_secs, "Broadcast interval", "How often to broadcast beacons in seconds. The minimum and default is 3600 seconds."
broadcast_targets, "Broadcast targets", "Optional list of channel, region, and modem-preset targets for beacon broadcasts."
[module.mesh_beacon.broadcast_offer_channel]
title, "Offered channel", ""
psk, "PSK", "Encryption key for the offered channel."
name, "Name", "Name of the offered channel."
[module.mesh_beacon.broadcast_on_channel]
title, "Broadcast channel", ""
psk, "PSK", "Encryption key for the broadcast channel."
name, "Name", "Name of the broadcast channel."
[module.mesh_beacon.broadcast_targets]
title, "Broadcast targets", ""
preset, "Modem preset", "Optional modem preset for this beacon target."
region, "Region", "LoRa region for this beacon target."
channel_index, "Channel index", "Index of the configured channel to use for this beacon target."
+204
View File
@@ -0,0 +1,204 @@
##field_name, "Nom du champ lisible avec première lettre en majuscule", "Texte d'aide avec [warning]avertissements[/warning], [note]notes[/note], [underline]soulignements[/underline], \033[31mcodes couleur ANSI\033[0m et \nsauts de ligne."
Main Menu, "Menu principal", ""
User Settings, "Paramètres utilisateur", ""
Channels, "Canaux", ""
Radio Settings, "Paramètres radio", ""
Module Settings, "Paramètres des modules", ""
App Settings, "Paramètres de l'application", ""
Export Config File, "Exporter le fichier de configuration", ""
Load Config File, "Charger le fichier de configuration", ""
Config URL, "URL de configuration", ""
Reboot, "Redémarrer", ""
Reset Node DB, "Réinitialiser la base de données des nœuds", ""
Shutdown, "Éteindre", ""
Factory Reset, "Réinitialisation d'usine", ""
factory_reset_config, "Réinitialiser la configuration d'usine", ""
Exit, "Quitter", ""
Yes, "Oui", ""
No, "Non", ""
Cancel, "Annuler", ""
[ui]
save_changes, "Enregistrer les modifications", ""
dialog.invalid_input, "Entrée invalide", ""
prompt.enter_new_value, "Entrer une nouvelle valeur : ", ""
error.value_empty, "La valeur ne peut pas être vide.", ""
error.value_exact_length, "La valeur doit comporter exactement {length} caractères.", ""
error.value_min_length, "La valeur doit comporter au moins {length} caractères.", ""
error.value_max_length, "La valeur ne doit pas dépasser {length} caractères.", ""
error.digits_only, "Seuls les chiffres (0-9) sont autorisés.", ""
error.number_range, "Entrez un nombre entre {min_value} et {max_value}.", ""
error.float_invalid, "Doit être un nombre à virgule flottante valide.", ""
prompt.edit_admin_keys, "Modifier jusqu'à 3 clés administrateur :", ""
label.admin_key, "Clé administrateur", ""
error.admin_key_invalid, "Erreur : chaque clé doit être en Base64 valide et faire 32 octets !", ""
prompt.edit_values, "Modifier jusqu'à 3 valeurs :", ""
label.value, "Valeur", ""
prompt.enter_ip, "Entrez une adresse IP (xxx.xxx.xxx.xxx) :", ""
label.current, "Actuel", ""
label.new_value, "Nouvelle valeur", ""
label.editing, "Modification de {label}", ""
label.current_value, "Valeur actuelle :", ""
error.ip_invalid, "Adresse IP invalide. Réessayez.", ""
prompt.select_foreground_color, "Sélectionner la couleur de premier plan pour {label}", ""
prompt.select_background_color, "Sélectionner la couleur d'arrière-plan pour {label}", ""
prompt.select_value, "Sélectionner {label}", ""
confirm.save_before_exit, "Vous avez des modifications non enregistrées. Enregistrer avant de quitter ?", ""
prompt.config_filename, "Entrez un nom de fichier pour le fichier de configuration", ""
confirm.overwrite_file, "{filename} existe déjà. Écraser ?", ""
dialog.config_saved_title, "Fichier de configuration enregistré :", ""
dialog.no_config_files, " Aucun fichier de configuration trouvé. Exportez-en un d'abord.", ""
prompt.choose_config_file, "Choisissez un fichier de configuration", ""
confirm.load_config_file, "Êtes-vous sûr de vouloir charger {filename} ?", ""
prompt.config_url_current, "L'URL de configuration est actuellement : {value}", ""
confirm.load_config_url, "Êtes-vous sûr de vouloir charger cette configuration ?", ""
confirm.reboot, "Êtes-vous sûr de vouloir redémarrer ?", ""
confirm.reset_node_db, "Êtes-vous sûr de vouloir réinitialiser la base de données des nœuds ?", ""
confirm.shutdown, "Êtes-vous sûr de vouloir éteindre ?", ""
confirm.factory_reset, "Êtes-vous sûr de vouloir effectuer une réinitialisation d'usine ?", ""
confirm.factory_reset_config, "Êtes-vous sûr de vouloir réinitialiser la configuration d'usine ?", ""
confirm.save_before_exit_section, "Vous avez des modifications non enregistrées dans {section}. Enregistrer avant de quitter ?", ""
prompt.select_region, "Sélectionnez votre région :", ""
dialog.slow_down_title, "Ralentissez", ""
dialog.slow_down_body, "Veuillez attendre 2 secondes entre les messages.", ""
dialog.node_details_title, "📡 Détails du nœud : {name}", ""
dialog.traceroute_not_sent_title, "Traceroute non envoyé", ""
dialog.traceroute_not_sent_body, "Veuillez attendre {seconds} secondes avant d'envoyer un autre traceroute.", ""
dialog.traceroute_sent_title, "Traceroute envoyé à : {name}", ""
dialog.traceroute_sent_body, "Les résultats apparaîtront dans la fenêtre des messages.", ""
dialog.help_title, "Aide - Raccourcis clavier", ""
help.scroll, "Haut/Bas = Défilement", ""
help.switch_window, "Gauche/Droite = Changer de fenêtre", ""
help.jump_windows, "F1/F2/F3 = Aller à Canal/Messages/Nœuds", ""
help.enter, "ENTRÉE = Envoyer / Sélectionner", ""
help.settings, "` ou F12 = Paramètres", ""
help.quit, "ESC = Quitter", ""
help.packet_log, "Ctrl+P = Activer/désactiver le journal des paquets", ""
help.traceroute, "Ctrl+T ou F4 = Traceroute", ""
help.node_info, "F5 = Informations complètes du nœud", ""
help.archive_chat, "Ctrl+D = Archiver la discussion / supprimer le nœud", ""
help.favorite, "Ctrl+F = Favori", ""
help.ignore, "Ctrl+G = Ignorer", ""
help.search, "Ctrl+/ ou / = Rechercher", ""
help.help, "Ctrl+K = Aide", ""
help.no_help, "Aucune aide disponible.", ""
[User Settings]
user, "Utilisateur", ""
longName, "Nom long du nœud", "Si vous êtes un opérateur radioamateur agréé et avez activé le mode HAM, cela doit être votre indicatif."
shortName, "Nom court du nœud", "Doit contenir jusqu'à 4 octets."
isLicensed, "Activer le mode radioamateur (HAM)", "IMPORTANT : lire la documentation Meshtastic avant d'activer."
[app_settings]
title, "Paramètres de l'application", ""
channel_list_16ths, "Largeur de la liste des canaux", ""
node_list_16ths, "Largeur de la liste des nœuds", ""
single_pane_mode, "Mode panneau unique", ""
db_file_path, "Chemin du fichier de base de données", ""
log_file_path, "Chemin du fichier journal", ""
node_configs_file_path, "Chemin des configurations des nœuds", ""
language, "Langue", ""
message_prefix, "Préfixe des messages", ""
sent_message_prefix, "Préfixe des messages envoyés", ""
notification_symbol, "Symbole de notification", ""
notification_sound, "Son de notification", ""
ack_implicit_str, "ACK (implicite)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
ack_unknown_str, "ACK (inconnu)", ""
node_sort, "Tri des nœuds", ""
theme, "Thème", ""
ping_bot, "Bot Ping", ""
[app_settings.ping_bot]
title, "Bot Ping", ""
enabled, "Activé", "Activer les réponses automatiques du bot Ping."
catch_words, "Mots déclencheurs", "Mots déclencheurs du bot séparés par des points-virgules."
response_word, "Mot de réponse", "Mot de réponse du bot."
[config.device]
title, "Appareil", ""
role, "Rôle", ""
serial_enabled, "Activer la console série", ""
button_gpio, "GPIO bouton", ""
buzzer_gpio, "GPIO buzzer", ""
rebroadcast_mode, "Mode de rediffusion", ""
node_info_broadcast_secs, "Intervalle diffusion infos nœud", ""
double_tap_as_button_press, "Double tap = bouton", ""
is_managed, "Activer mode géré", ""
disable_triple_click, "Désactiver triple clic", ""
tzdef, "Fuseau horaire", ""
led_heartbeat_disabled, "Désactiver LED heartbeat", ""
buzzer_mode, "Mode buzzer", ""
[config.network]
title, "Réseau", ""
wifi_enabled, "Wi-Fi activé", ""
wifi_ssid, "SSID Wi-Fi", ""
wifi_psk, "Mot de passe Wi-Fi", ""
ntp_server, "Serveur NTP", ""
eth_enabled, "Ethernet activé", ""
address_mode, "Mode IPv4", ""
ip, "Adresse IP", ""
gateway, "Passerelle", ""
subnet, "Sous-réseau", ""
dns, "DNS", ""
[config.display]
title, "Affichage", ""
screen_on_secs, "Durée écran actif", ""
gps_format, "Format GPS", ""
auto_screen_carousel_secs, "Intervalle carrousel", ""
compass_north_top, "Toujours nord en haut", ""
flip_screen, "Retourner écran", ""
units, "Unités préférées", ""
[config.bluetooth]
title, "Bluetooth", ""
enabled, "Activé", ""
mode, "Mode d'appairage", ""
fixed_pin, "Code PIN fixe", ""
[module.mqtt]
title, "MQTT", ""
enabled, "Activé", ""
address, "Adresse serveur", ""
username, "Nom d'utilisateur", ""
password, "Mot de passe", ""
encryption_enabled, "Chiffrement activé", ""
json_enabled, "JSON activé", ""
tls_enabled, "TLS activé", ""
[module.serial]
title, "Série", ""
enabled, "Activé", ""
echo, "Écho", ""
rxd, "GPIO réception", ""
txd, "GPIO transmission", ""
baud, "Débit en bauds", ""
timeout, "Délai", ""
[module.telemetry]
title, "Télémétrie", ""
device_update_interval, "Intervalle métriques appareil", ""
environment_update_interval, "Intervalle métriques environnement", ""
environment_measurement_enabled, "Télémétrie environnement activée", ""
[module.audio]
title, "Audio", ""
codec2_enabled, "Activé", ""
ptt_pin, "GPIO PTT", ""
bitrate, "Débit audio", ""
[module.remote_hardware]
title, "Matériel distant", ""
enabled, "Activé", ""
available_pins, "Broches disponibles", ""
[module.ambient_lighting]
title, "Éclairage ambiant", ""
led_state, "État LED", ""
current, "Courant", ""
red, "Rouge", ""
green, "Vert", ""
blue, "Bleu", ""
+10 -1
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, "Подождите", ""
@@ -77,7 +79,7 @@ help.node_info, "F5 = Полная информация об узле", ""
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
help.favorite, "Ctrl+F = Избранное", ""
help.ignore, "Ctrl+G = Игнорировать", ""
help.search, "Ctrl+/ = Поиск", ""
help.search, "Ctrl+/ или / = Поиск", ""
help.help, "Ctrl+K = Справка", ""
help.no_help, "Нет справки.", ""
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
@@ -114,10 +116,17 @@ nak_str, "NAK", ""
ack_unknown_str, "ACK (неизвестный)", ""
node_sort, "Сортировка нод", ""
theme, "Тема", ""
ping_bot, "Пинг-бот", ""
COLOR_CONFIG_DARK, "Цвета темы (темная)", ""
COLOR_CONFIG_LIGHT, "Цвета темы (светлая)", ""
COLOR_CONFIG_GREEN, "Цвета темы (зеленая)", ""
[app_settings.ping_bot]
title, "Пинг-бот", ""
enabled, "Включен", "Включить автоматические ответы пинг-бота."
catch_words, "Слова-триггеры", "Слова для активации бота, разделенные точкой с запятой."
response_word, "Ответное слово", "Ответное слово бота."
[app_settings.color_config]
default, "По умолчанию", ""
background, "Фон", ""
+96
View File
@@ -0,0 +1,96 @@
# 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
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
def _get_bot_catch_words() -> set[str]:
"""Return normalized bot trigger words from app settings."""
raw_words = getattr(config, "ping_bot_catch_words", "ping; test")
words = {
word.strip().casefold()
for word in raw_words.replace(";", ",").split(",")
if word.strip()
}
return words or {"ping"}
def is_bot_message(message: str) -> bool:
"""Return True when the incoming message should trigger an automatic response."""
return message.strip().casefold() in _get_bot_catch_words()
def is_bot_enabled() -> bool:
"""Return whether automatic responses are enabled in app settings."""
value = getattr(config, "ping_bot_enabled", False)
return value if isinstance(value, bool) else str(value).strip().casefold() == "true"
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
"""Send a basic response when bot mode is enabled."""
if not is_bot_enabled():
return False
if not is_bot_message(message):
""" Only respond to specific messages. """
return False
from_node = packet.get("from")
if from_node is None:
return False
if from_node == interface_state.myNodeNum:
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
incoming_reply_id = (packet.get('decoded') or {}).get('replyId')
packet_id = packet.get("id")
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
transport_type = packet.get('transportMechanism', None)
hops = hop_start - hop_limit
details = []
if snr != -128:
details.append(f"SNR: {snr}")
if rssi != -128:
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
if incoming_reply_id:
details.append(f"Relay: {incoming_reply_id}")
transport_text = str(transport_type).upper() if transport_type is not None else ""
for transport_name in ("UDP", "MQTT"):
if transport_name in transport_text:
details.append(f"Via: {transport_name}")
response_data_string = getattr(config, "ping_bot_response_word", "Pong!")
if details:
response_data_string += f" {', '.join(details)}"
def send_response_delayed() -> None:
try:
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not is_bot_enabled():
return
# Use the triggering packet's ID so Meshtastic clients render this
# automatic response as a native reply when that ID is available.
send_message(response_data_string, channel=send_channel, reply_id=packet_id)
# 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
+52 -37
View File
@@ -5,9 +5,10 @@ import shutil
import time
import subprocess
import threading
from typing import Any, Dict, Optional
# Debounce notification sounds so a burst of queued messages only plays once.
_SOUND_DEBOUNCE_SECONDS = 0.8
_sound_timer: threading.Timer | None = None
_sound_timer: Optional[threading.Timer] = None
_sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
@@ -42,18 +43,14 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
_sound_timer.start()
from typing import Any, Dict
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
get_reply_context,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
request_ui_redraw,
)
from contact.utilities.db_handler import (
save_message_to_db,
@@ -64,41 +61,39 @@ 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():
try:
system = platform.system()
sound_path = None
executable = None
selected_sound = str(getattr(config, "notification_sound", "None"))
if selected_sound.casefold() in {"none", "false", ""}:
return
sound_path = os.path.join(config.parent_dir, "sounds", selected_sound)
if not os.path.isfile(sound_path):
logging.warning("Configured notification sound is unavailable: %s", sound_path)
return
if system == "Darwin": # macOS
sound_path = "/System/Library/Sounds/Ping.aiff"
executable = "afplay"
elif system == "Linux":
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
if shutil.which("paplay") and os.path.exists(ogg_path):
executable = "paplay"
sound_path = ogg_path
elif shutil.which("ffplay") and os.path.exists(ogg_path):
executable = "ffplay"
sound_path = ogg_path
elif shutil.which("aplay") and os.path.exists(wav_path):
executable = "aplay"
sound_path = wav_path
else:
logging.warning("No suitable sound player or sound file found on Linux")
executable = "afplay" if platform.system() == "Darwin" else next(
(shutil.which(player) for player in ("ffplay", "mpg123", "mpv", "cvlc", "paplay") if shutil.which(player)),
None,
)
if executable and sound_path:
player = os.path.basename(executable)
cmd = [executable, sound_path]
if executable == "ffplay":
if player == "ffplay":
cmd = [executable, "-nodisp", "-autoexit", sound_path]
elif player == "mpv":
cmd = [executable, "--no-video", sound_path]
elif player == "cvlc":
cmd = [executable, "--play-and-exit", sound_path]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
logging.warning(
"No suitable sound player found for notification sound; install ffmpeg (ffplay), mpg123, mpv, or VLC."
)
except subprocess.CalledProcessError as e:
logging.error(f"Sound playback failed: {e}")
@@ -122,7 +117,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
draw_packetlog_win()
request_ui_redraw(packetlog=True)
if ui_state.current_window == 4:
menu_state.need_redraw = True
@@ -133,7 +128,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if changed:
draw_node_list()
request_ui_redraw(nodes=True)
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
@@ -146,7 +141,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
hops = hop_start - hop_limit
if config.notification_sound == "True":
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
schedule_notification_sound()
message_bytes = packet["decoded"]["payload"]
@@ -172,6 +167,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]:
@@ -184,14 +181,32 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
# replyId is a field of Meshtastic's decrypted Data payload.
reply_id = packet["decoded"].get("replyId")
reply_context = get_reply_context(reply_id) if reply_id is not None else ""
add_new_message(
channel_id,
f"{config.message_prefix} [{hops}] {message_from_string} ",
f"{reply_context}{message_string}",
packet_id=packet.get("id"),
)
if refresh_channels:
draw_channel_list()
request_ui_redraw(channels=True)
if refresh_messages:
draw_messages_window(True)
request_ui_redraw(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=(ui_state.current_window == 1),
)
save_message_to_db(channel_id, message_from_id, message_string)
save_message_to_db(
channel_id,
message_from_id,
message_string,
packet_id=packet.get("id"),
reply_id=reply_id,
)
except KeyError as e:
logging.error(f"Error processing packet: {e}")
+121 -110
View File
@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
from contact.utilities.singleton import ui_state, interface_state, app_state
from contact.utilities.utils import add_new_message
@@ -28,147 +28,149 @@ def onAckNak(packet: Dict[str, Any]) -> None:
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import draw_messages_window
from contact.ui.contact_ui import request_ui_redraw
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
with app_state.lock:
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
confirm_string = " "
ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.ack_str
ack_type = "Ack"
else:
confirm_string = config.nak_str
ack_type = "Nak"
confirm_string = config.nak_str
ack_type = "Nak"
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type)
update_ack_nak(acknak["channel"], acknak["timestamp"], acknak["dbMessage"], ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
draw_messages_window()
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
request_ui_redraw(messages=True)
def on_response_traceroute(packet: Dict[str, Any]) -> None:
"""
Handle traceroute response packets and render the route visually in the UI.
"""
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
from contact.ui.contact_ui import add_notification, request_ui_redraw
refresh_channels = False
refresh_messages = False
with app_state.lock:
refresh_channels = False
refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR
UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"])
msg_dict = google.protobuf.json_format.MessageToDict(route_discovery)
msg_str = "Traceroute to:\n"
msg_str = "Traceroute to:\n"
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with origin of response
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = (
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
) # Start with origin of response
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0:
for idx, node_num in enumerate(msg_dict["route"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with destination of response (us)
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route back to us
msg_str += route_str + "\n"
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
if lenBack > 0:
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
msg_str += route_str + "\n"
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
save_message_to_db(channel_id, packet["from"], msg_str)
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
refresh_channels = True
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if refresh_channels:
request_ui_redraw(channels=True)
if refresh_messages:
request_ui_redraw(messages=True, scroll_messages_to_bottom=True)
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(
message: str,
destination: int = BROADCAST_NUM,
channel: int = 0,
reply_id: int | None = None,
reply_context: str = "",
) -> None:
"""
Sends a chat message using the selected channel.
"""
@@ -181,23 +183,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
wantResponse=False,
onResponse=onAckNak,
channelIndex=send_on_channel,
send_kwargs = {
"text": message,
"destinationId": destination,
"wantAck": True,
"wantResponse": False,
"onResponse": onAckNak,
"channelIndex": send_on_channel,
}
if reply_id is not None:
send_kwargs["replyId"] = reply_id
sent_message_data = interface_state.interface.sendText(**send_kwargs)
add_new_message(
channel_id,
config.sent_message_prefix + config.ack_unknown_str + ": ",
f"{reply_context}{message}",
packet_id=sent_message_data.id,
)
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
timestamp = save_message_to_db(channel_id, myid, message)
timestamp = save_message_to_db(channel_id, myid, message, packet_id=sent_message_data.id, reply_id=reply_id)
ack_naks[sent_message_data.id] = {
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
"dbMessage": message,
}
+18 -8
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.interfaces import initialize_interface
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,8 +46,9 @@ def main(stdscr: curses.window) -> None:
)
if confirmation == "Yes":
set_region(interface)
interface.close()
interface = initialize_interface(args)
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
stdscr.refresh()
settings_menu(stdscr, interface)
@@ -51,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:
Binary file not shown.
+845 -218
View File
File diff suppressed because it is too large. Load diff
+233 -22
View File
@@ -4,11 +4,15 @@ import ipaddress
import logging
import os
import sys
import threading
import time
from typing import List
from meshtastic.protobuf import admin_pb2
from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import
from contact.utilities.interfaces import reconnect_interface
from contact.utilities.control_utils import transform_menu_path
from contact.utilities.i18n import t
from contact.utilities.ini_utils import parse_ini_file
@@ -24,14 +28,17 @@ from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor
from contact.utilities.singleton import menu_state
from contact.utilities.arg_parser import setup_parser
from contact.utilities.singleton import interface_state, menu_state
# Setup Variables
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
save_option = "Save Changes"
max_help_lines = 0
REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS = 15
admin_target_label = ""
help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"]
# Compute the effective menu width for the current terminal
@@ -40,7 +47,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__))
@@ -56,6 +63,13 @@ config_folder = os.path.abspath(config.node_configs_file_path)
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def reload_translations() -> None:
global translation_file, field_mapping, help_text
@@ -75,7 +89,8 @@ def get_translated_header(menu_path: List[str]) -> str:
continue
full_key = ".".join(transformed_path[:idx])
translated_parts.append(field_mapping.get(full_key, part))
return " > ".join(translated_parts)
breadcrumb = " > ".join(translated_parts)
return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb
def display_menu() -> tuple[object, object]:
@@ -126,12 +141,16 @@ def display_menu() -> tuple[object, object]:
if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}:
if isinstance(current_value, int):
try:
current_value = str(ipaddress.IPv4Address(current_value))
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
elif isinstance(current_value, str) and current_value.isdigit():
try:
current_value = str(ipaddress.IPv4Address(int(current_value)))
current_value = str(
ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False))
)
except ipaddress.AddressValueError:
pass
@@ -211,10 +230,130 @@ def get_input_type_for_field(field) -> type:
return str
def settings_menu(stdscr: object, interface: object) -> None:
curses.update_lines_cols()
def reconnect_interface_with_splash(stdscr: object, interface: object) -> object:
try:
interface.close()
except Exception:
pass
menu = generate_menu_from_protobuf(interface)
new_interface = reconnect_interface(setup_parser().parse_args())
interface_state.interface = new_interface
redraw_main_ui_after_reconnect(stdscr)
return new_interface
def reconnect_after_admin_action(stdscr: object, interface: object, action, log_message: str) -> object:
action()
logging.info(log_message)
return reconnect_interface_with_splash(stdscr, interface)
def request_factory_reset(node: object, full: bool = False):
try:
return node.factoryReset(full=full)
except TypeError as ex:
field_name = "factory_reset_device" if full else "factory_reset_config"
field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name]
if field.cpp_type != field.CPPTYPE_INT32:
raise
node.ensureSessionKey()
message = admin_pb2.AdminMessage()
setattr(message, field_name, 1)
if node == node.iface.localNode:
on_response = None
else:
on_response = node.onAckNak
return node._sendAdmin(message, onResponse=on_response)
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
try:
from contact.ui import contact_ui
from contact.utilities.utils import get_channels, refresh_node_list
get_channels()
refresh_node_list()
contact_ui.handle_resize(stdscr, False)
except Exception:
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
def _request_remote_with_timeout(request, *args) -> None:
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
failure = []
def run_request() -> None:
try:
request(*args)
except BaseException as exc:
failure.append(exc)
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
worker.start()
worker.join(REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS)
if worker.is_alive():
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
if failure:
raise failure[0]
def _request_remote_channels_with_timeout(node: object) -> None:
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
node.requestChannels()
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
while getattr(node, "channels", None) is None:
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the remote node's channel list.")
time.sleep(0.1)
def _request_remote_section(node: object, menu_path: List[str], selected_option: str, status_callback=None) -> bool:
"""Fetch exactly the remote-admin section the user is opening.
Meshtastic returns radio and module settings one protobuf section at a
time. Asking for all of them on menu entry is both slow and unreliable on
long paths, so leave the top-level menu immediately available.
"""
if menu_path == ["Main Menu"] and selected_option == "Channels":
if status_callback:
status_callback("Requesting channel list…")
_request_remote_channels_with_timeout(node)
return True
if len(menu_path) != 2:
return False
category = menu_path[-1]
if category == "Radio Settings":
config = node.localConfig
elif category == "Module Settings":
config = node.moduleConfig
else:
return False
field = config.DESCRIPTOR.fields_by_name.get(selected_option)
if field is None or field.name in {"version", "sessionkey"}:
return False
if status_callback:
status_callback(f"Requesting {selected_option} config…")
_request_remote_with_timeout(node.requestConfig, field)
return True
def settings_menu(
stdscr: object, interface: object, node: object = None, remote: bool = False, status_callback=None
) -> None:
curses.update_lines_cols()
global admin_target_label
node = node or interface.localNode
node_num = getattr(node, "nodeNum", 0)
node_info = interface.getMyNodeInfo() if not remote else getattr(interface, "nodesByNum", {}).get(node_num, {})
node_name = node_info.get("user", {}).get("longName") if isinstance(node_info, dict) else None
admin_target_label = node_name or f"!{node_num:08x}"
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
@@ -319,9 +458,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state)
reconnect_required = save_changes(interface, modified_settings, menu_state, node=node)
modified_settings.clear()
logging.info("Changes Saved")
if remote:
break
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
if len(menu_state.menu_path) > 1:
menu_state.menu_path.pop()
@@ -421,7 +565,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
current_value = node.getURL()
new_value = get_text_input(
t(
"ui.prompt.config_url_current",
@@ -439,7 +583,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if overwrite == "Yes":
interface.localNode.setURL(new_value)
node.setURL(new_value)
logging.info(f"New Config URL sent to node")
menu_state.start_index.pop()
continue
@@ -449,8 +593,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
t("ui.confirm.reboot", default="Are you sure you want to Reboot?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
interface = reconnect_after_admin_action(
stdscr, interface, node.reboot, "Node Reboot Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
@@ -461,8 +607,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
interface = reconnect_after_admin_action(
stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
@@ -471,7 +619,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
t("ui.confirm.shutdown", default="Are you sure you want to Shutdown?"), None, ["Yes", "No"]
)
if confirmation == "Yes":
interface.localNode.shutdown()
node.shutdown()
logging.info(f"Node Shutdown Requested by menu")
menu_state.start_index.pop()
continue
@@ -483,8 +631,30 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(node, full=True),
"Factory Reset Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
elif selected_option == "factory_reset_config":
confirmation = get_list_input(
t("ui.confirm.factory_reset_config", default="Are you sure you want to Factory Reset Config?"),
None,
["Yes", "No"],
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr,
interface,
lambda: request_factory_reset(node, full=False),
"Factory Reset Config Requested by menu",
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
continue
@@ -512,6 +682,24 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
if field is None and selected_option in ("ringtone", "messages"):
is_ringtone = selected_option == "ringtone"
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
getter = getattr(node, getter_name, None)
setter = getattr(node, setter_name, None)
fetched_value = getter() if callable(getter) else None
current_value = fetched_value if fetched_value is not None else current_value
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
)
if new_value is not None and new_value != current_value and callable(setter):
setter(new_value)
menu_state.current_menu[selected_option] = (field, new_value)
menu_state.start_index.pop()
menu_state.need_redraw = True
continue
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
@@ -560,7 +748,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = new_value == "True" or new_value is True
menu_state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
elif _is_repeated_field(field): # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop()
@@ -620,7 +808,28 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.current_menu[selected_option] = (field, new_value)
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
requested_remote_section = False
if remote:
try:
requested_remote_section = _request_remote_section(
node, menu_state.menu_path, selected_option, status_callback
)
finally:
if status_callback:
status_callback(None)
if requested_remote_section:
# The response updates the node's protobufs. Rebuild
# the menu before entering the requested section so
# the newly received values are what the user sees.
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=False)
refreshed_menu = menu["Main Menu"]
for path_step in menu_state.menu_path[1:]:
refreshed_menu = refreshed_menu[path_step]
menu_state.current_menu = refreshed_menu[selected_option]
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
menu_state.menu_path.append(selected_option)
menu_state.menu_index.append(menu_state.selected_index)
menu_state.selected_index = 0
@@ -644,8 +853,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_changes(interface, modified_settings, menu_state)
reconnect_required = save_changes(interface, modified_settings, menu_state)
logging.info("Changes Saved")
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
modified_settings.clear()
menu = rebuild_menu_at_current_path(interface, menu_state)
+27 -5
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")
@@ -231,13 +235,18 @@ def initialize_config() -> Dict[str, object]:
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"notification_sound": "True",
"notification_sound": "alert.mp3",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
"ack_unknown_str": "[…]",
"node_sort": "lastHeard",
"theme": "dark",
"ping_bot": {
"enabled": "False",
"catch_words": "ping; test",
"response_word": "Pong!",
},
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN,
@@ -255,6 +264,15 @@ def initialize_config() -> Dict[str, object]:
# Check and add missing variables
updated = update_dict(default_config_variables, loaded_config)
# Migrate the legacy boolean notification preference to the file-based
# sound selection introduced in 1.5.12.
if loaded_config.get("notification_sound") == "True":
loaded_config["notification_sound"] = "alert.mp3"
updated = True
elif loaded_config.get("notification_sound") == "False":
loaded_config["notification_sound"] = "None"
updated = True
# Update the JSON file if any variables were missing
if updated:
formatted_json = format_json_single_line_arrays(loaded_config)
@@ -272,7 +290,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global node_list_16ths, channel_list_16ths, single_pane_mode
global theme, COLOR_CONFIG, language
global node_sort, notification_sound
global node_sort, notification_sound, ping_bot_enabled, ping_bot_catch_words, ping_bot_response_word
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
@@ -291,6 +309,10 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
ack_unknown_str = loaded_config["ack_unknown_str"]
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"]
ping_bot = loaded_config.get("ping_bot", {})
ping_bot_enabled = ping_bot.get("enabled", "False")
ping_bot_catch_words = ping_bot.get("catch_words", "ping; test")
ping_bot_response_word = ping_bot.get("response_word", "Pong!")
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
+6 -7
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:
+29 -12
View File
@@ -57,14 +57,17 @@ def extract_fields(
return menu
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
def generate_menu_from_protobuf(interface: object, node: Any = None, include_app_settings: bool = True) -> Dict[str, Any]:
"""
Builds the full settings menu structure from the protobuf definitions.
"""
menu_structure = {"Main Menu": {}}
# Add User Settings
current_node_info = interface.getMyNodeInfo() if interface else None
node = node or (interface.localNode if interface else None)
current_node_info = interface.getMyNodeInfo() if interface and node is interface.localNode else None
if current_node_info is None and interface and node is not None:
current_node_info = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None))
if current_node_info:
current_user_config = current_node_info.get("user", None)
@@ -79,28 +82,28 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
else:
logging.info("Node Info not available")
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
menu_structure["Main Menu"]["User Settings"] = {}
# Add Channels
channel = channel_pb2.ChannelSettings()
menu_structure["Main Menu"]["Channels"] = {}
if interface:
if node:
for i in range(8):
current_channel = interface.localNode.getChannelByChannelIndex(i)
current_channel = node.getChannelByChannelIndex(i)
if current_channel:
channel_config = extract_fields(channel, current_channel.settings)
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
# Add Radio Settings
radio = config_pb2.Config()
current_radio_config = interface.localNode.localConfig if interface else None
current_radio_config = node.localConfig if node else None
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
# Add Lat/Lon/Alt
position_data = {
"latitude": (None, current_node_info["position"].get("latitude", 0.0)),
"longitude": (None, current_node_info["position"].get("longitude", 0.0)),
"altitude": (None, current_node_info["position"].get("altitude", 0)),
"latitude": (None, (current_node_info or {}).get("position", {}).get("latitude", 0.0)),
"longitude": (None, (current_node_info or {}).get("position", {}).get("longitude", 0.0)),
"altitude": (None, (current_node_info or {}).get("position", {}).get("altitude", 0)),
}
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
@@ -117,11 +120,24 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
current_module_config = node.moduleConfig if node else None
module_settings = extract_fields(module, current_module_config)
if node:
# These values use dedicated admin requests rather than ModuleConfig
# fields, so expose them alongside their related module settings.
module_settings.setdefault("external_notification", {})["ringtone"] = (
None,
getattr(node, "ringtone", "") or "",
)
module_settings.setdefault("canned_message", {})["messages"] = (
None,
getattr(node, "cannedPluginMessage", "") or "",
)
menu_structure["Main Menu"]["Module Settings"] = module_settings
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
if include_app_settings:
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Additional settings options
menu_structure["Main Menu"].update(
@@ -133,6 +149,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,
}
)
+46 -2
View File
@@ -331,6 +331,51 @@ def text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def slice_to_width(text: str, max_width: int) -> str:
if max_width <= 0:
return ""
width = 0
chars = []
for char in text:
char_width = text_width(char)
if width + char_width > max_width:
break
chars.append(char)
width += char_width
return "".join(chars)
def pad_to_width(text: str, width: int) -> str:
clipped = slice_to_width(text, width)
return clipped + (" " * max(0, width - text_width(clipped)))
def truncate_with_ellipsis(text: str, width: int) -> str:
if width <= 0:
return ""
if text_width(text) <= width:
return pad_to_width(text, width)
if width == 1:
return "…"
return pad_to_width(slice_to_width(text, width - 1) + "…", width)
def split_text_to_width_chunks(text: str, width: int) -> List[str]:
if width <= 0:
return [""]
chunks = []
remaining = text
while remaining:
chunk = slice_to_width(remaining, width)
if not chunk:
break
chunks.append(chunk)
remaining = remaining[len(chunk) :]
return chunks or [""]
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
@@ -353,8 +398,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
wrapped_lines.extend(split_text_to_width_chunks(word, wrap_width))
continue
if line_length + word_length > wrap_width and word.strip():
+16 -1
View File
@@ -27,12 +27,27 @@ class ChatUIState:
current_window: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
redraw_channels: bool = False
redraw_messages: bool = False
redraw_nodes: bool = False
redraw_packetlog: bool = False
redraw_full_ui: bool = False
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
reconnect_attempted: bool = False
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
highlighted_message_range: tuple = field(default_factory=tuple)
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
reply_id: Any = None
reply_context: str = ""
reply_id_unavailable: bool = False
@dataclass
+22 -10
View File
@@ -115,6 +115,23 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L
def edit_value(key: str, display_label: str, current_value: str) -> str:
if key in ("single_pane_mode", "enabled"):
return get_list_input(display_label, current_value, ["True", "False"])
if key == "notification_sound":
sounds_dir = os.path.join(config.parent_dir, "sounds")
try:
sound_options = sorted(
filename for filename in os.listdir(sounds_dir)
if filename.lower().endswith((".mp3", ".wav", ".ogg", ".aiff", ".flac"))
)
except OSError:
sound_options = []
sound_options = ["None"] + sound_options
if current_value not in sound_options:
sound_options.append(current_value)
return get_list_input(display_label, current_value, sound_options)
w = get_effective_width()
height = 10
input_width = w - 16 # Allow space for "New Value: "
@@ -170,14 +187,6 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
sort_options = ["lastHeard", "name", "hops"]
return get_list_input(display_label, current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
curses.curs_set(1)
@@ -309,7 +318,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)
@@ -566,7 +578,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
config.reload_config()
reload_translations(data.get("language"))
+5
View File
@@ -35,5 +35,10 @@ def setup_parser() -> ArgumentParser:
parser.add_argument(
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true"
)
parser.add_argument(
"--demo-screenshot",
help="Launch with a fake interface and seeded demo data for screenshots/testing.",
action="store_true",
)
return parser
+12 -1
View File
@@ -9,6 +9,17 @@ from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
def _is_repeated_field(field_desc) -> bool:
"""Return True if the protobuf field is repeated.
Protobuf 6.31.0+ exposes `is_repeated`, while older versions require
checking `label == LABEL_REPEATED`.
"""
if hasattr(field_desc, "is_repeated"):
return bool(field_desc.is_repeated)
return field_desc.label == field_desc.LABEL_REPEATED
def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root)
@@ -89,7 +100,7 @@ def setPref(config, comp_name, raw_val) -> bool:
return False
# repeating fields need to be handled with append, not setattr
if pref.label != pref.LABEL_REPEATED:
if not _is_repeated_field(pref):
try:
if config_type.message_type is not None:
config_values = getattr(config_part, config_type.name)
+163 -69
View File
@@ -4,13 +4,16 @@ import logging
from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import decimal_to_hex
from contact.utilities.utils import build_reply_prefix, decimal_to_hex
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
MESSAGE_PAGE_SIZE = 100
def get_table_name(channel: str) -> str:
# Construct the table name
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
@@ -18,7 +21,21 @@ def get_table_name(channel: str) -> str:
return quoted_table_name
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
def _ensure_message_columns(db_cursor, quoted_table_name: str) -> None:
"""Add fields introduced after the original message-history schema."""
table_columns = {row[1] for row in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")}
for column, definition in (("ack_type", "TEXT"), ("packet_id", "INTEGER"), ("reply_id", "INTEGER")):
if column not in table_columns:
db_cursor.execute(f"ALTER TABLE {quoted_table_name} ADD COLUMN {column} {definition}")
def save_message_to_db(
channel: str,
user_id: str,
message_text: str,
packet_id: Optional[int] = None,
reply_id: Optional[int] = None,
) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
@@ -27,20 +44,25 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
ack_type TEXT,
packet_id INTEGER,
reply_id INTEGER
"""
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()
_ensure_message_columns(db_cursor, quoted_table_name)
timestamp = int(time.time())
# Insert the message
insert_query = f"""
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
INSERT INTO {quoted_table_name}
(user_id, message_text, timestamp, ack_type, packet_id, reply_id)
VALUES (?, ?, ?, ?, ?, ?)
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None, packet_id, reply_id))
db_connection.commit()
return timestamp
@@ -53,7 +75,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)}
@@ -73,35 +96,100 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
logging.error(f"Unexpected error in update_ack_nak: {e}")
def load_messages_from_db() -> None:
def _format_db_messages(db_messages, node_names):
"""Format database rows and packet IDs, adding one timestamp separator per hour."""
hourly_messages = {}
known_messages = {}
for _rowid, user_id, message, timestamp, ack_type, packet_id, reply_id in db_messages:
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {(user_id, message, timestamp, ack_type)}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
sanitized_message = message.replace("\x00", "")
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{ts_str} {config.sent_message_prefix}{ack_str}: ", sanitized_message)
else:
try:
fallback_name = decimal_to_hex(int(user_id))
except (TypeError, ValueError):
fallback_name = str(user_id)
formatted_message = (
f"{ts_str} {config.message_prefix} {node_names.get(str(user_id), fallback_name)}: ",
sanitized_message,
)
if reply_id is not None and reply_id in known_messages:
referenced_prefix, referenced_message = known_messages[reply_id]
formatted_message = (formatted_message[0], build_reply_prefix(referenced_prefix, referenced_message) + sanitized_message)
hourly_messages.setdefault(hour, []).append((formatted_message, packet_id))
if packet_id is not None:
known_messages[packet_id] = formatted_message
formatted = []
packet_ids = []
for hour, messages in sorted(hourly_messages.items()):
formatted.append((f"-- {hour} --", ""))
packet_ids.append(None)
for formatted_message, packet_id in messages:
formatted.append(formatted_message)
packet_ids.append(packet_id)
return formatted, packet_ids
def _load_node_names(db_cursor):
table_name = f'"{interface_state.myNodeNum}_nodedb"'
try:
return {str(row[0]): row[1] for row in db_cursor.execute(f"SELECT user_id, short_name FROM {table_name}")}
except sqlite3.Error:
return {}
def load_messages_from_db(page_size: int = MESSAGE_PAGE_SIZE) -> None:
"""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 ?"
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
node_names = _load_node_names(db_cursor)
# Iterate through each table and fetch its messages
for table_name in tables:
quoted_table_name = (
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
)
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query)
_ensure_message_columns(db_cursor, quoted_table_name)
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}"
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {quoted_table_name}
ORDER BY rowid DESC LIMIT ?
"""
try:
# Fetch all messages from the table
db_cursor.execute(query)
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
db_cursor.execute(query, (page_size + 1,))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
# Extract the channel name from the table name
channel = table_name.split("_")[1]
prefix = f"{interface_state.myNodeNum}_"
channel = table_name[len(prefix) : -len("_messages")]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel
@@ -110,53 +198,13 @@ def load_messages_from_db() -> None:
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Ensure the channel exists in ui_state.all_messages
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {}
for row in db_messages:
user_id, message, timestamp, ack_type = row
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
elif ack_type == "Ack":
ack_str = config.ack_str
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
if user_id == str(interface_state.myNodeNum):
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
sanitized_message,
)
else:
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
# Replace the channel's messages with the freshly loaded page to avoid duplicates
formatted_messages, packet_ids = _format_db_messages(db_messages, node_names)
ui_state.all_messages[channel] = formatted_messages
ui_state.message_packet_ids[channel] = packet_ids
if db_messages:
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -165,6 +213,47 @@ def load_messages_from_db() -> None:
logging.error(f"SQLite error in load_messages_from_db: {e}")
def load_older_messages(channel, page_size: int = MESSAGE_PAGE_SIZE) -> int:
"""Prepend one older page for a channel and return the number of messages loaded."""
before_rowid = ui_state.oldest_message_rowid.get(channel)
if before_rowid is None or not ui_state.has_older_messages.get(channel, False):
return 0
try:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
db_cursor = db_connection.cursor()
query = f"""
SELECT rowid, user_id, message_text, timestamp, ack_type, packet_id, reply_id
FROM {get_table_name(channel)}
WHERE rowid < ? ORDER BY rowid DESC LIMIT ?
"""
db_cursor.execute(query, (before_rowid, page_size + 1))
rows = db_cursor.fetchall()
has_older = len(rows) > page_size
db_messages = list(reversed(rows[:page_size]))
if not db_messages:
ui_state.has_older_messages[channel] = False
return 0
older, older_packet_ids = _format_db_messages(db_messages, _load_node_names(db_cursor))
current = ui_state.all_messages.setdefault(channel, [])
current_packet_ids = ui_state.message_packet_ids.setdefault(channel, [])
while len(current_packet_ids) < len(current):
current_packet_ids.append(None)
# Keep the existing page's leading separator even when the older page
# falls in the same hour. Removing it makes a timestamp that the user
# is looking at jump out of view as soon as another page is loaded.
ui_state.all_messages[channel] = older + current
ui_state.message_packet_ids[channel] = older_packet_ids + current_packet_ids
ui_state.oldest_message_rowid[channel] = db_messages[0][0]
ui_state.has_older_messages[channel] = has_older
return len(db_messages)
except sqlite3.Error as e:
logging.error(f"SQLite error loading older messages for channel '{channel}': {e}")
return 0
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
@@ -228,7 +317,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 +326,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 +402,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 +423,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 +452,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}"'
+227
View File
@@ -0,0 +1,227 @@
import os
import sqlite3
import tempfile
from dataclasses import dataclass
from typing import Dict, List, Tuple, Union
import contact.ui.default_config as config
from contact.utilities.db_handler import get_table_name
from contact.utilities.singleton import interface_state
DEMO_DB_FILENAME = "contact_demo_client.db"
DEMO_LOCAL_NODE_NUM = 0xC0DEC0DE
DEMO_BASE_TIMESTAMP = 1738717200 # 2025-02-04 17:00:00 UTC
DEMO_CHANNELS = ["MediumFast", "Another Channel"]
@dataclass
class DemoChannelSettings:
name: str
@dataclass
class DemoChannel:
role: bool
settings: DemoChannelSettings
@dataclass
class DemoLoRaConfig:
region: int = 1
modem_preset: int = 0
@dataclass
class DemoLocalConfig:
lora: DemoLoRaConfig
class DemoLocalNode:
def __init__(self, interface: "DemoInterface", channels: List[DemoChannel]) -> None:
self._interface = interface
self.channels = channels
self.localConfig = DemoLocalConfig(lora=DemoLoRaConfig())
def setFavorite(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isFavorite"] = True
def removeFavorite(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isFavorite"] = False
def setIgnored(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isIgnored"] = True
def removeIgnored(self, node_num: int) -> None:
self._interface.nodesByNum[node_num]["isIgnored"] = False
def removeNode(self, node_num: int) -> None:
self._interface.nodesByNum.pop(node_num, None)
class DemoInterface:
def __init__(self, nodes: Dict[int, Dict[str, object]], channels: List[DemoChannel]) -> None:
self.nodesByNum = nodes
self.nodes = self.nodesByNum
self.localNode = DemoLocalNode(self, channels)
def getMyNodeInfo(self) -> Dict[str, int]:
return {"num": DEMO_LOCAL_NODE_NUM}
def getNode(self, selector: str) -> DemoLocalNode:
if selector != "^local":
raise KeyError(selector)
return self.localNode
def close(self) -> None:
return
def build_demo_interface() -> DemoInterface:
channels = [DemoChannel(role=True, settings=DemoChannelSettings(name=name)) for name in DEMO_CHANNELS]
nodes = {
DEMO_LOCAL_NODE_NUM: _build_node(
DEMO_LOCAL_NODE_NUM,
"Meshtastic fb3c",
"fb3c",
hops=0,
snr=13.7,
last_heard_offset=5,
battery=88,
voltage=4.1,
favorite=True,
),
0xA1000001: _build_node(0xA1000001, "KG7NDX-N2", "N2", hops=1, last_heard_offset=18, battery=79, voltage=4.0),
0xA1000002: _build_node(0xA1000002, "Satellite II Repeater", "SAT2", hops=2, last_heard_offset=31),
0xA1000003: _build_node(0xA1000003, "Search for Discord/Meshtastic", "DISC", hops=1, last_heard_offset=46),
0xA1000004: _build_node(0xA1000004, "K7EOK Mobile", "MOBL", hops=1, last_heard_offset=63, battery=52),
0xA1000005: _build_node(0xA1000005, "Turtle", "TRTL", hops=3, last_heard_offset=87),
0xA1000006: _build_node(0xA1000006, "CARS Trewvilliger Plaza", "CARS", hops=2, last_heard_offset=121),
0xA1000007: _build_node(0xA1000007, "No Hands!", "NHDS", hops=1, last_heard_offset=155),
0xA1000008: _build_node(0xA1000008, "McCutie", "MCCU", hops=2, last_heard_offset=211, ignored=True),
0xA1000009: _build_node(0xA1000009, "K1PDX", "K1PX", hops=2, last_heard_offset=267),
0xA100000A: _build_node(0xA100000A, "Arnold Creek", "ARND", hops=1, last_heard_offset=301),
0xA100000B: _build_node(0xA100000B, "Nansen", "NANS", hops=1, last_heard_offset=355),
0xA100000C: _build_node(0xA100000C, "Kodin 1", "KOD1", hops=2, last_heard_offset=402),
0xA100000D: _build_node(0xA100000D, "PH1", "PH1", hops=3, last_heard_offset=470),
0xA100000E: _build_node(0xA100000E, "Luna", "LUNA", hops=1, last_heard_offset=501),
0xA100000F: _build_node(0xA100000F, "sputnik1", "SPUT", hops=1, last_heard_offset=550),
0xA1000010: _build_node(0xA1000010, "K7EOK Maplewood West", "MAPL", hops=2, last_heard_offset=602),
0xA1000011: _build_node(0xA1000011, "KE7YVU 2", "YVU2", hops=2, last_heard_offset=655),
0xA1000012: _build_node(0xA1000012, "DNET", "DNET", hops=1, last_heard_offset=702),
0xA1000013: _build_node(0xA1000013, "Green Bluff", "GBLF", hops=1, last_heard_offset=780),
0xA1000014: _build_node(0xA1000014, "Council Crest Solar", "CCST", hops=2, last_heard_offset=830),
0xA1000015: _build_node(0xA1000015, "Meshtastic 61c7", "61c7", hops=1, last_heard_offset=901),
0xA1000016: _build_node(0xA1000016, "Bella", "BELA", hops=2, last_heard_offset=950),
0xA1000017: _build_node(0xA1000017, "Mojo Solar Base 4f12", "MOJO", hops=1, last_heard_offset=1010, favorite=True),
}
return DemoInterface(nodes=nodes, channels=channels)
def configure_demo_database(base_dir: str = "") -> str:
if not base_dir:
base_dir = tempfile.mkdtemp(prefix="contact_demo_")
os.makedirs(base_dir, exist_ok=True)
db_path = os.path.join(base_dir, DEMO_DB_FILENAME)
if os.path.exists(db_path):
os.remove(db_path)
config.db_file_path = db_path
return db_path
def seed_demo_messages() -> None:
schema = """
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
"""
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
cursor = db_connection.cursor()
for channel_name, rows in _demo_messages().items():
table_name = get_table_name(channel_name)
cursor.execute(f"CREATE TABLE IF NOT EXISTS {table_name} ({schema})")
cursor.executemany(
f"""
INSERT INTO {table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
""",
rows,
)
db_connection.commit()
def _build_node(
node_num: int,
long_name: str,
short_name: str,
*,
hops: int,
last_heard_offset: int,
snr: float = 0.0,
battery: int = 0,
voltage: float = 0.0,
favorite: bool = False,
ignored: bool = False,
) -> Dict[str, object]:
node = {
"num": node_num,
"user": {
"longName": long_name,
"shortName": short_name,
"hwModel": "TBEAM",
"role": "CLIENT",
"publicKey": f"pk-{node_num:08x}",
"isLicensed": True,
},
"lastHeard": DEMO_BASE_TIMESTAMP + 3600 - last_heard_offset,
"hopsAway": hops,
"isFavorite": favorite,
"isIgnored": ignored,
}
if snr:
node["snr"] = snr
if battery:
node["deviceMetrics"] = {
"batteryLevel": battery,
"voltage": voltage or 4.0,
"uptimeSeconds": 86400 + node_num % 10000,
"channelUtilization": 12.5 + (node_num % 7),
"airUtilTx": 4.5 + (node_num % 5),
}
if node_num % 3 == 0:
node["position"] = {
"latitude": 45.5231 + ((node_num % 50) * 0.0001),
"longitude": -122.6765 - ((node_num % 50) * 0.0001),
"altitude": 85 + (node_num % 20),
}
return node
def _demo_messages() -> Dict[Union[str, int], List[Tuple[str, str, int, Union[str, None]]]]:
return {
"MediumFast": [
(str(DEMO_LOCAL_NODE_NUM), "Help, I'm stuck in a ditch!", DEMO_BASE_TIMESTAMP + 45, "Ack"),
("2701131778", "Do you require a alpinist?", DEMO_BASE_TIMESTAMP + 80, None),
(str(DEMO_LOCAL_NODE_NUM), "I don't know what that is.", DEMO_BASE_TIMESTAMP + 104, "Implicit"),
],
"Another Channel": [
("2701131788", "Weather is holding for the summit push.", DEMO_BASE_TIMESTAMP + 220, None),
(str(DEMO_LOCAL_NODE_NUM), "Copy that. Keep me posted.", DEMO_BASE_TIMESTAMP + 260, "Ack"),
],
2701131788: [
("2701131788", "Ping me when you are back at the trailhead.", DEMO_BASE_TIMESTAMP + 330, None),
(str(DEMO_LOCAL_NODE_NUM), "Will do.", DEMO_BASE_TIMESTAMP + 350, "Ack"),
],
}
+54
View File
@@ -0,0 +1,54 @@
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
EMOJI_MODIFIER_REPLACEMENTS = {
"\u200d": "",
"\u20e3": "",
"\ufe0e": "",
"\ufe0f": "",
"\U0001F3FB": "",
"\U0001F3FC": "",
"\U0001F3FD": "",
"\U0001F3FE": "",
"\U0001F3FF": "",
}
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
def _regional_indicator_to_letter(char: str) -> str:
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
def _normalize_flag_emoji(text: str) -> str:
"""Convert flag emoji built from regional indicators into ASCII country codes."""
normalized = []
index = 0
while index < len(text):
current = text[index]
current_ord = ord(current)
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
next_char = text[index + 1]
next_ord = ord(next_char)
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
normalized.append(_regional_indicator_to_letter(current))
normalized.append(_regional_indicator_to_letter(next_char))
index += 2
continue
normalized.append(current)
index += 1
return "".join(normalized)
def normalize_message_text(text: str) -> str:
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
if not text:
return text
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))
+5 -2
View File
@@ -462,7 +462,10 @@ from contact.utilities.singleton import menu_state # Ensure this is imported
def get_fixed32_input(current_value: int) -> int:
original_value = current_value
ip_string = str(ipaddress.IPv4Address(current_value))
try:
ip_string = str(ipaddress.IPv4Address(int(current_value).to_bytes(4, "little", signed=False)))
except Exception:
ip_string = str(ipaddress.IPv4Address(current_value))
height = 10
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
@@ -524,7 +527,7 @@ def get_fixed32_input(current_value: int) -> int:
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
return int(ipaddress.ip_address(user_input))
return int.from_bytes(ipaddress.IPv4Address(user_input).packed, "little", signed=False)
else:
fixed32_win.addstr(
7,
+25
View File
@@ -1,4 +1,5 @@
import logging
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
@@ -41,3 +42,27 @@ def initialize_interface(args):
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
last_error = None
for attempt in range(attempts):
try:
interface = initialize_interface(args)
if interface is not None and getattr(interface, "localNode", None) is not None and getattr(
interface.localNode, "localConfig", None
) is not None and (not hasattr(interface, "stream") or interface.stream is not None):
return interface
last_error = RuntimeError("interface did not complete connection setup")
try:
interface.close()
except Exception:
pass
except Exception as ex:
last_error = ex
if attempt < attempts - 1:
time.sleep(delay_seconds)
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
+85 -9
View File
@@ -4,8 +4,81 @@ import logging
import base64
import time
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
POWER_REBOOT_KEYS = {
"device_battery_ina_address",
"is_power_saving",
"ls_secs",
"min_wake_secs",
"on_battery_shutdown_after_secs",
"sds_secs",
"wait_bluetooth_secs",
}
DISPLAY_REBOOT_KEYS = {"screen_on_secs", "flip_screen", "oled", "displaymode"}
LORA_REBOOT_KEYS = {
"use_preset",
"region",
"modem_preset",
"bandwidth",
"spread_factor",
"coding_rate",
"tx_power",
"frequency_offset",
"override_frequency",
"channel_num",
"sx126x_rx_boosted_gain",
}
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
def save_changes(interface, modified_settings, menu_state):
def _collect_changed_keys(modified_settings):
changed = set()
for key, value in modified_settings.items():
if isinstance(value, dict):
changed.update(_collect_changed_keys(value))
else:
changed.add(key)
return changed
def _requires_reconnect(menu_state, modified_settings) -> bool:
if not modified_settings or len(menu_state.menu_path) < 2:
return False
section = menu_state.menu_path[1]
changed_keys = _collect_changed_keys(modified_settings)
if section == "Module Settings":
return True
if section == "User Settings":
return bool(changed_keys & USER_RECONNECT_KEYS)
if section == "Channels":
return False
if section != "Radio Settings" or len(menu_state.menu_path) < 3:
return False
config_category = menu_state.menu_path[2].lower()
if config_category in {"network", "bluetooth"}:
return True
if config_category == "security":
return not changed_keys.issubset(SECURITY_NON_REBOOT_KEYS)
if config_category == "device":
return bool(changed_keys & DEVICE_REBOOT_KEYS)
if config_category == "power":
return bool(changed_keys & POWER_REBOOT_KEYS)
if config_category == "display":
return bool(changed_keys & DISPLAY_REBOOT_KEYS)
if config_category == "lora":
return bool(changed_keys & LORA_REBOOT_KEYS)
# Firmware defaults most config writes to reboot-required unless a handler
# explicitly clears that flag.
return True
def save_changes(interface, modified_settings, menu_state, node=None):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -15,9 +88,9 @@ def save_changes(interface, modified_settings, menu_state):
try:
if not modified_settings:
logging.info("No changes to save. modified_settings is empty.")
return
return False
node = interface.getNode("^local")
node = node or interface.getNode("^local")
admin_key_backup = None
if "admin_key" in modified_settings:
# Get reference to security config
@@ -51,7 +124,7 @@ def save_changes(interface, modified_settings, menu_state):
# Return early if there are no other settings left to process
if not modified_settings:
return
return _requires_reconnect(menu_state, {"admin_key": admin_key_backup})
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs
@@ -61,9 +134,9 @@ def save_changes(interface, modified_settings, menu_state):
lon = float(modified_settings.get("longitude", 0.0))
alt = int(modified_settings.get("altitude", 0))
interface.localNode.setFixedPosition(lat, lon, alt)
node.setFixedPosition(lat, lon, alt)
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return
return False
elif menu_state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings"
@@ -78,7 +151,7 @@ def save_changes(interface, modified_settings, menu_state):
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
)
return
return _requires_reconnect(menu_state, modified_settings)
elif menu_state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
@@ -107,7 +180,7 @@ def save_changes(interface, modified_settings, menu_state):
logging.info(f"Updated Channel {channel_num} in {config_category}")
logging.info(node.channels)
return
return False
else:
config_category = None
@@ -120,7 +193,7 @@ def save_changes(interface, modified_settings, menu_state):
config_container = getattr(node.moduleConfig, config_category)
else:
logging.warning(f"Config category '{config_category}' not found in config.")
return
return False
if len(menu_state.menu_path) >= 4:
nested_key = menu_state.menu_path[3]
@@ -164,8 +237,11 @@ def save_changes(interface, modified_settings, menu_state):
if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup
return _requires_reconnect(menu_state, modified_settings)
except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}")
return False
except Exception as e:
logging.error(f"Error saving changes: {e}")
return False
+9 -9
View File
@@ -68,19 +68,19 @@ def get_chunks(data):
# Leave it string as last resort
value = value
match key:
# Python 3.9-compatible alternative to match/case.
if key == "uptime_seconds":
# convert seconds to hours, for our sanity
case "uptime_seconds":
value = round(value / 60 / 60, 1)
value = round(value / 60 / 60, 1)
elif key in ("longitude_i", "latitude_i"):
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
# truncate to 6th digit after floating point, which would be still accurate
case "longitude_i" | "latitude_i":
value = round(value * 1e-7, 6)
value = round(value * 1e-7, 6)
elif key == "wind_direction":
# Convert wind direction from degrees to abbreviation
case "wind_direction":
value = humanize_wind_direction(value)
case "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
value = humanize_wind_direction(value)
elif key == "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
if key in sensors:
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
+81 -21
View File
@@ -1,4 +1,5 @@
import datetime
import re
import time
from typing import Optional, Union
from google.protobuf.message import DecodeError
@@ -10,35 +11,50 @@ from contact.utilities.singleton import ui_state, interface_state
import contact.utilities.telemetry_beautifier as tb
def _get_channel_name(device_channel, node):
if device_channel.settings.name:
return device_channel.settings.name
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
return convert_to_camel_case(modem_preset_string)
def get_channels():
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
"""Retrieve channels from the node and rebuild named channel state."""
node = interface_state.interface.getNode("^local")
device_channels = node.channels
previous_channel_list = list(ui_state.channel_list)
previous_messages = dict(ui_state.all_messages)
# Clear and rebuild channel list
# ui_state.channel_list = []
named_channels = []
for device_channel in device_channels:
if device_channel.role:
# Use the channel name if available, otherwise use the modem preset
if device_channel.settings.name:
channel_name = device_channel.settings.name
else:
# If channel name is blank, use the modem preset
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[
modem_preset_enum
].name
channel_name = convert_to_camel_case(modem_preset_string)
named_channels.append(_get_channel_name(device_channel, node))
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
previous_named_channels = [channel for channel in previous_channel_list if isinstance(channel, str)]
preserved_direct_channels = [channel for channel in previous_channel_list if isinstance(channel, int)]
rebuilt_messages = {}
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
for index, channel_name in enumerate(named_channels):
previous_name = previous_named_channels[index] if index < len(previous_named_channels) else channel_name
if previous_name in previous_messages:
rebuilt_messages[channel_name] = previous_messages[previous_name]
elif channel_name in previous_messages:
rebuilt_messages[channel_name] = previous_messages[channel_name]
else:
rebuilt_messages[channel_name] = []
for channel in preserved_direct_channels:
if channel in previous_messages:
rebuilt_messages[channel] = previous_messages[channel]
ui_state.channel_list = named_channels + preserved_direct_channels
ui_state.all_messages = rebuilt_messages
if ui_state.channel_list:
ui_state.selected_channel = max(0, min(ui_state.selected_channel, len(ui_state.channel_list) - 1))
return ui_state.channel_list
@@ -141,10 +157,52 @@ def get_time_ago(timestamp):
return "now"
def add_new_message(channel_id, prefix, message):
REPLY_EXCERPT_LENGTH = 5
def _local_short_name() -> str:
"""Return this node's short name without depending on the message database."""
nodes = getattr(interface_state.interface, "nodes", {}) or {}
for node in nodes.values():
if node.get("num") == interface_state.myNodeNum:
short_name = node.get("user", {}).get("shortName")
if short_name:
return str(short_name)
return ""
def build_reply_prefix(prefix: str, message: str) -> str:
"""Create Contact's local display marker for a native Meshtastic reply."""
sender_text = re.sub(r"^\[[^]]+\]\s*", "", prefix).strip()
if sender_text.startswith(config.sent_message_prefix.strip()):
sender = _local_short_name()
else:
sender_text = re.sub(r"^(?:>>|<<)\s*(?:\[[^]]+\]\s*)?", "", sender_text)
sender_match = re.search(r"(.+?)\s*:\s*$", sender_text)
sender = sender_match.group(1).strip() if sender_match else ""
message_without_reply_marker = re.sub(r"^<Re:\s*[^>]*>\s*", "", message.replace("\x00", ""))
excerpt = " ".join(message_without_reply_marker.split())[:REPLY_EXCERPT_LENGTH]
return f"<Re: {sender}: {excerpt}> "
def get_reply_context(reply_id):
"""Find the locally displayed message referred to by a Meshtastic reply ID."""
for channel, packet_ids in ui_state.message_packet_ids.items():
for index, packet_id in enumerate(packet_ids):
if packet_id == reply_id and index < len(ui_state.all_messages.get(channel, [])):
prefix, message = ui_state.all_messages[channel][index]
return build_reply_prefix(prefix, message)
return ""
def add_new_message(channel_id, prefix, message, packet_id=None):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
packet_ids = ui_state.message_packet_ids.setdefault(channel_id, [])
while len(packet_ids) < len(ui_state.all_messages[channel_id]):
packet_ids.append(None)
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
@@ -165,10 +223,12 @@ def add_new_message(channel_id, prefix, message):
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
packet_ids.append(None)
# Add the message
ts_str = time.strftime("[%H:%M:%S] ")
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
packet_ids.append(packet_id)
def parse_protobuf(packet: dict) -> Union[str, dict]:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.4.13"
version = "1.6.2"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+1
View File
@@ -0,0 +1 @@
+13
View File
@@ -0,0 +1,13 @@
import unittest
from contact.utilities.arg_parser import setup_parser
class ArgParserTests(unittest.TestCase):
def test_demo_screenshot_flag_is_supported(self) -> None:
args = setup_parser().parse_args(["--demo-screenshot"])
self.assertTrue(args.demo_screenshot)
def test_demo_screenshot_defaults_to_false(self) -> None:
args = setup_parser().parse_args([])
self.assertFalse(args.demo_screenshot)
+63
View File
@@ -0,0 +1,63 @@
import unittest
import importlib
import sys
import types
from unittest import mock
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state
from tests.test_support import reset_singletons
class BotHandlerTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
sys.modules.setdefault(
"contact.message_handlers.tx_handler",
types.SimpleNamespace(send_message=mock.Mock()),
)
cls.bot_handler = importlib.import_module("contact.message_handlers.bot_handler")
def setUp(self) -> None:
reset_singletons()
self.bot_handler.send_message.reset_mock()
def tearDown(self) -> None:
reset_singletons()
def test_is_bot_message_uses_configured_catch_words(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", "ping; test; pong"):
self.assertTrue(self.bot_handler.is_bot_message("PING"))
self.assertTrue(self.bot_handler.is_bot_message("test"))
self.assertFalse(self.bot_handler.is_bot_message("hello"))
def test_is_bot_message_ignores_empty_config_values(self) -> None:
with mock.patch.object(config, "ping_bot_catch_words", " ; ; "):
self.assertTrue(self.bot_handler.is_bot_message("ping"))
def test_bot_response_replies_to_triggering_packet(self) -> None:
interface_state.myNodeNum = 111
packet = {"id": 900, "from": 222, "decoded": {}}
with mock.patch.object(config, "ping_bot_enabled", "True"):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
self.bot_handler.send_message.assert_called_once_with(
"Pong!", channel=0, reply_id=900
)
def test_bot_response_requires_enabled_app_setting(self) -> None:
with mock.patch.object(config, "ping_bot_enabled", "False"):
self.assertFalse(self.bot_handler.bot_respond({"from": 222}, "ping", 0))
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -0,0 +1,21 @@
import unittest
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
class ConfigIoTests(unittest.TestCase):
def test_split_compound_name_preserves_multi_part_values(self) -> None:
self.assertEqual(splitCompoundName("config.device.role"), ["config", "device", "role"])
def test_split_compound_name_duplicates_single_part_values(self) -> None:
self.assertEqual(splitCompoundName("owner"), ["owner", "owner"])
def test_is_repeated_field_prefers_new_style_attribute(self) -> None:
field = type("Field", (), {"is_repeated": True})()
self.assertTrue(_is_repeated_field(field))
def test_is_repeated_field_falls_back_to_label_comparison(self) -> None:
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
self.assertTrue(_is_repeated_field(field_type()))
+537
View File
@@ -0,0 +1,537 @@
import unittest
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config as config
from contact.ui import contact_ui
from contact.ui.nav_utils import text_width
from contact.utilities.singleton import ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class ContactUiTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
def test_handle_backtick_refreshes_channels_after_settings_menu(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 1
config.single_pane_mode = "False"
with mock.patch.object(contact_ui.curses, "curs_set") as curs_set:
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch.object(contact_ui, "get_channels") as get_channels:
with mock.patch.object(contact_ui, "refresh_node_list") as refresh_node_list:
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_backtick(stdscr)
settings_menu.assert_called_once()
get_channels.assert_called_once_with()
refresh_node_list.assert_called_once_with()
handle_resize.assert_called_once_with(stdscr, False)
self.assertEqual(curs_set.call_args_list[0].args, (0,))
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
self.assertEqual(ui_state.current_window, 1)
def test_handle_backtick_shows_error_when_remote_admin_is_rejected(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 2
ui_state.node_list = [123]
ui_state.selected_node = 0
contact_ui.interface_state.interface = mock.Mock()
contact_ui.interface_state.interface.getNode.return_value = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "get_channels"):
with mock.patch.object(contact_ui, "refresh_node_list"):
with mock.patch.object(contact_ui, "handle_resize"):
with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"):
with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"):
with mock.patch.object(contact_ui, "settings_menu", side_effect=SystemExit(1)):
with mock.patch("contact.ui.dialog.dialog") as dialog:
contact_ui.handle_backtick(stdscr)
dialog.assert_called_once_with(
"Remote admin rejected",
"The selected node did not authorize this admin request.",
)
def test_show_remote_admin_wait_draws_status(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 100)
wait_win = mock.Mock()
with mock.patch.object(contact_ui.curses, "newwin", return_value=wait_win):
result = contact_ui.show_remote_admin_wait(stdscr, "Remote")
self.assertIs(result, wait_win)
self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2])
wait_win.refresh.assert_called_once()
def test_update_remote_admin_wait_replaces_status_line(self) -> None:
wait_win = mock.Mock()
wait_win.getmaxyx.return_value = (5, 40)
contact_ui.update_remote_admin_wait(wait_win, "Requesting lora config…")
self.assertIn("Requesting lora config", wait_win.addstr.call_args_list[-1].args[2])
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_channels = True
ui_state.redraw_messages = True
ui_state.redraw_nodes = True
ui_state.redraw_packetlog = True
ui_state.scroll_messages_to_bottom = True
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win:
contact_ui.process_pending_ui_updates(stdscr)
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_packetlog_win.assert_called_once_with()
def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_full_ui = True
ui_state.redraw_channels = True
ui_state.redraw_messages = True
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.process_pending_ui_updates(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_messages = True
ui_state.scroll_messages_to_bottom = True
ui_state.preserve_message_selection = True
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
contact_ui.process_pending_ui_updates(stdscr)
draw_messages_window.assert_called_once_with(True, preserve_selection=True)
self.assertFalse(ui_state.preserve_message_selection)
def test_draw_messages_resizes_pad_once(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]}
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (2, 40)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 40)
contact_ui.messages_win.getbegyx.return_value = (0, 0)
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_packetlog_win"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_messages_window()
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> [6] B1G1: ",
"This is a message long enough to be shortened for the reply marker.",
)
self.assertEqual(reply, "<Re: B1G1: This > ")
def test_build_reply_prefix_uses_me_for_an_outgoing_message(self) -> None:
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = SimpleNamespace(
nodes={"!0000007b": {"num": 123, "user": {"shortName": "LRY"}}}
)
reply = contact_ui.build_reply_prefix("[06:27:25] >> Sent[☼]: ", "Thanks Lairy")
self.assertEqual(reply, "<Re: LRY: Thank> ")
def test_build_reply_prefix_does_not_nest_an_existing_reply_marker(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> xT1e: ",
"<Re: CATS: Woot!> Woot indeed",
)
self.assertEqual(reply, "<Re: xT1e: Woot > ")
def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.message_packet_ids = {"Primary": [1234]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
self.assertEqual(ui_state.reply_id, 1234)
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
ui_state.current_window = 1
ui_state.reply_id = 1234
ui_state.reply_context = "<Re: B1G1: Good > "
ui_state.reply_id_unavailable = False
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
self.assertIsNone(ui_state.reply_id)
self.assertEqual(ui_state.reply_context, "")
self.assertFalse(ui_state.reply_id_unavailable)
def test_draw_text_field_clears_stale_reply_context(self) -> None:
win = mock.Mock()
win.getmaxyx.return_value = (3, 30)
contact_ui.draw_text_field(win, "Message: ", 1)
self.assertIn(mock.call(1, 1, " " * 28, 1), win.addstr.call_args_list)
self.assertIn(mock.call(1, 1, "Message: ", 1), win.addstr.call_args_list)
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
self.assertTrue(ui_state.reply_id_unavailable)
def test_refresh_message_highlight_marks_selected_message(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
ui_state.selected_message = 2
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
ui_state.current_window = 0
ui_state.highlighted_message_range = (2, 3, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
self.assertEqual(ui_state.highlighted_message_range, ())
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
ui_state.current_window = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
with mock.patch.object(contact_ui, "refresh_message_highlight"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.handle_end()
self.assertEqual(ui_state.selected_message, 99)
self.assertGreater(ui_state.start_index[1], 0)
def test_message_selection_can_reach_last_rendered_line(self) -> None:
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.set_message_selection(99)
self.assertEqual(ui_state.selected_message, 99)
self.assertEqual(ui_state.start_index[1], 90)
def test_switching_to_messages_redraws_at_bottom(self) -> None:
ui_state.current_window = 0
ui_state.single_pane_mode = False
with mock.patch.object(contact_ui, "refresh_main_window"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "refresh_message_highlight"):
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
draw_messages_window.assert_called_once_with(True)
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
ui_state.selected_message = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 3)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 5)
contact_ui.move_message_selection(-1)
self.assertEqual(ui_state.selected_message, 3)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
ui_state.start_index = [0, 0, 0]
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_pad.getmaxyx.return_value = (4, 20)
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
interface = mock.Mock()
interface.nodesByNum = {101: {}, 202: {}}
with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]):
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
contact_ui.refresh_node_selection(old_index=0, highlight=True)
self.assertEqual(
contact_ui.nodes_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
refresh_pad.assert_called_once_with(2)
draw_window_arrows.assert_called_once_with(2)
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
ui_state.channel_list = ["VeryLongChannelName"]
ui_state.notifications = []
ui_state.selected_channel = 0
ui_state.current_window = 0
contact_ui.channel_pad = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_color", return_value=1):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "remove_notification"):
contact_ui.draw_channel_list()
text = contact_ui.channel_pad.addstr.call_args.args[2]
self.assertEqual(len(text), 16)
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101]
ui_state.current_window = 2
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
contact_ui.entry_win = mock.Mock()
interface = mock.Mock()
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_node_list()
text = contact_ui.nodes_pad.addstr.call_args.args[2]
self.assertEqual(text_width(text), 16)
self.assertIn("…", text)
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 80)
ui_state.single_pane_mode = True
ui_state.current_window = 1
contact_ui.entry_win = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.packetlog_win = mock.Mock()
contact_ui.messages_pad = mock.Mock()
contact_ui.nodes_pad = mock.Mock()
contact_ui.channel_pad = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
contact_ui.handle_resize(stdscr, False)
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
contact_ui.channel_win.box.assert_not_called()
contact_ui.nodes_win.box.assert_not_called()
contact_ui.messages_win.box.assert_called_once_with()
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_window_arrows.assert_called_once_with(1)
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
ui_state.single_pane_mode = True
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
self.assertEqual(contact_ui.get_window_title(0), "")
self.assertEqual(contact_ui.get_window_title(1), "Primary")
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
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)
def test_search_ignores_no_input_from_curses(self) -> None:
ui_state.node_list = [101]
ui_state.selected_node = 0
contact_ui.entry_win = mock.Mock()
contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input")
with mock.patch.object(contact_ui, "draw_centered_text_field"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
contact_ui.search(2)
contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)])
contact_ui.entry_win.erase.assert_called()
def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27]
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
self.assertEqual(dialog_win.getch.call_count, 2)
handle_resize.assert_called_once_with(stdscr, False)
def test_f5_node_details_tolerates_none_metrics(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.return_value = 27
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"snr": None,
"hopsAway": None,
"deviceMetrics": {
"batteryLevel": None,
"channelUtilization": None,
"airUtilTx": None,
"uptimeSeconds": None,
},
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
+112
View File
@@ -0,0 +1,112 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
from contact.ui import control_ui
from contact.utilities.singleton import interface_state
from tests.test_support import reset_singletons
class ControlUiTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
def tearDown(self) -> None:
reset_singletons()
def test_reconnect_interface_with_splash_replaces_interface(self) -> None:
old_interface = mock.Mock()
new_interface = mock.Mock()
stdscr = mock.Mock()
parser = mock.Mock()
parser.parse_args.return_value = Namespace()
with mock.patch.object(control_ui, "setup_parser", return_value=parser):
with mock.patch.object(control_ui, "draw_splash") as draw_splash:
with mock.patch.object(control_ui, "reconnect_interface", return_value=new_interface) as reconnect:
with mock.patch.object(control_ui, "redraw_main_ui_after_reconnect") as redraw:
result = control_ui.reconnect_interface_with_splash(stdscr, old_interface)
old_interface.close.assert_called_once_with()
stdscr.clear.assert_called_once_with()
stdscr.refresh.assert_called_once_with()
draw_splash.assert_called_once_with(stdscr)
reconnect.assert_called_once_with(parser.parse_args.return_value)
redraw.assert_called_once_with(stdscr)
self.assertIs(result, new_interface)
self.assertIs(interface_state.interface, new_interface)
def test_reconnect_after_admin_action_runs_action_then_reconnects(self) -> None:
stdscr = mock.Mock()
interface = mock.Mock()
new_interface = mock.Mock()
action = mock.Mock()
with mock.patch.object(control_ui, "reconnect_interface_with_splash", return_value=new_interface) as reconnect:
result = control_ui.reconnect_after_admin_action(
stdscr, interface, action, "Factory Reset Requested by menu"
)
action.assert_called_once_with()
reconnect.assert_called_once_with(stdscr, interface)
self.assertIs(result, new_interface)
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
stdscr = mock.Mock()
with mock.patch("contact.utilities.utils.get_channels") as get_channels:
with mock.patch("contact.utilities.utils.refresh_node_list") as refresh_node_list:
with mock.patch("contact.ui.contact_ui.handle_resize") as handle_resize:
control_ui.redraw_main_ui_after_reconnect(stdscr)
get_channels.assert_called_once_with()
refresh_node_list.assert_called_once_with()
handle_resize.assert_called_once_with(stdscr, False)
def test_request_factory_reset_uses_library_helper_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node)
node.factoryReset.assert_called_once_with(full=False)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node, full=True)
node.factoryReset.assert_called_once_with(full=True)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_config, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node, full=True)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_device, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
+15
View File
@@ -0,0 +1,15 @@
import unittest
from contact.utilities.control_utils import transform_menu_path
class ControlUtilsTests(unittest.TestCase):
def test_transform_menu_path_applies_replacements_and_normalization(self) -> None:
transformed = transform_menu_path(["Main Menu", "Radio Settings", "Channel 2", "Detail"])
self.assertEqual(transformed, ["config", "channel", "Detail"])
def test_transform_menu_path_preserves_unmatched_entries(self) -> None:
transformed = transform_menu_path(["Main Menu", "Module Settings", "WiFi"])
self.assertEqual(transformed, ["module", "WiFi"])
+206
View File
@@ -0,0 +1,206 @@
import os
import sqlite3
import tempfile
import unittest
import contact.ui.default_config as config
from contact.utilities import db_handler
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.utils import decimal_to_hex
from tests.test_support import reset_singletons, restore_config, snapshot_config
class DbHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config(
"db_file_path",
"message_prefix",
"sent_message_prefix",
"ack_str",
"ack_implicit_str",
"ack_unknown_str",
"nak_str",
)
self.tempdir = tempfile.TemporaryDirectory()
config.db_file_path = os.path.join(self.tempdir.name, "client.db")
interface_state.myNodeNum = 123
def tearDown(self) -> None:
self.tempdir.cleanup()
restore_config(self.saved_config)
reset_singletons()
def test_save_message_to_db_and_update_ack_roundtrip(self) -> None:
timestamp = db_handler.save_message_to_db("Primary", "123", "hello")
self.assertIsInstance(timestamp, int)
db_handler.update_ack_nak("Primary", timestamp, "hello", "Ack")
with sqlite3.connect(config.db_file_path) as conn:
row = conn.execute('SELECT user_id, message_text, ack_type FROM "123_Primary_messages"').fetchone()
self.assertEqual(row, ("123", "hello", "Ack"))
def test_message_ids_are_migrated_and_reloaded_with_history(self) -> None:
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
("456", "original message", 1700000000, None),
)
db_handler.save_message_to_db("Primary", "456", "reply", packet_id=902, reply_id=901)
with sqlite3.connect(config.db_file_path) as conn:
columns = {row[1] for row in conn.execute('PRAGMA table_info("123_Primary_messages")')}
stored_ids = conn.execute(
'SELECT packet_id, reply_id FROM "123_Primary_messages" WHERE message_text = ?', ("reply",)
).fetchone()
conn.execute(
'UPDATE "123_Primary_messages" SET packet_id = ? WHERE message_text = ?', (901, "original message")
)
conn.commit()
db_handler.load_messages_from_db()
self.assertTrue({"packet_id", "reply_id"}.issubset(columns))
self.assertEqual(stored_ids, (902, 901))
self.assertEqual([packet_id for packet_id in ui_state.message_packet_ids["Primary"] if packet_id], [901, 902])
self.assertEqual(
ui_state.all_messages["Primary"][-1][1],
f"<Re: {decimal_to_hex(456)}: origi> reply",
)
def test_update_node_info_in_db_fills_defaults_and_preserves_existing_values(self) -> None:
db_handler.update_node_info_in_db(999, short_name="ABCD")
original_long_name = db_handler.get_name_from_database(999, "long")
self.assertTrue(original_long_name.startswith("Meshtastic "))
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
self.assertEqual(db_handler.is_chat_archived(999), 0)
db_handler.update_node_info_in_db(999, chat_archived=1)
self.assertEqual(db_handler.get_name_from_database(999, "long"), original_long_name)
self.assertEqual(db_handler.get_name_from_database(999, "short"), "ABCD")
self.assertEqual(db_handler.is_chat_archived(999), 1)
def test_get_name_from_database_returns_hex_when_user_is_missing(self) -> None:
user_id = 0x1234ABCD
db_handler.ensure_node_table_exists()
self.assertEqual(db_handler.get_name_from_database(user_id, "short"), decimal_to_hex(user_id))
self.assertEqual(db_handler.is_chat_archived(user_id), 0)
def test_load_messages_from_db_populates_channels_and_messages(self) -> None:
db_handler.update_node_info_in_db(123, long_name="Local Node", short_name="ME")
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.update_node_info_in_db(789, long_name="Archived", short_name="AR", chat_archived=1)
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"""
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
""",
)
db_handler.ensure_table_exists(
'"123_789_messages"',
"""
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
""",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("123", "sent", 1700000000, "Ack"))
conn.execute('INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)', ("456", "reply", 1700000001, None))
conn.execute('INSERT INTO "123_789_messages" VALUES (?, ?, ?, ?)', ("789", "hidden", 1700000002, None))
conn.commit()
ui_state.channel_list = []
ui_state.all_messages = {}
db_handler.load_messages_from_db()
self.assertIn("Primary", ui_state.channel_list)
self.assertNotIn(789, ui_state.channel_list)
self.assertIn("Primary", ui_state.all_messages)
self.assertIn(789, ui_state.all_messages)
messages = ui_state.all_messages["Primary"]
self.assertTrue(messages[0][0].startswith("-- "))
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in messages))
self.assertTrue(any("RM:" in prefix for prefix, _ in messages))
self.assertEqual(ui_state.all_messages[789][-1][1], "hidden")
def test_message_history_is_loaded_in_bounded_pages(self) -> None:
db_handler.update_node_info_in_db(456, long_name="Remote Node", short_name="RM")
db_handler.ensure_table_exists(
'"123_Primary_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.executemany(
'INSERT INTO "123_Primary_messages" VALUES (?, ?, ?, ?)',
[("456", f"message-{i}", 1700000000 + i, None) for i in range(7)],
)
db_handler.load_messages_from_db(page_size=3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, ["message-4", "message-5", "message-6"])
self.assertTrue(ui_state.has_older_messages["Primary"])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 3)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(
loaded_text,
["message-1", "message-2", "message-3", "message-4", "message-5", "message-6"],
)
self.assertTrue(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 2)
self.assertEqual(separators[0], separators[1])
self.assertEqual(db_handler.load_older_messages("Primary", page_size=3), 1)
loaded_text = [message for _, message in ui_state.all_messages["Primary"] if message]
self.assertEqual(loaded_text, [f"message-{i}" for i in range(7)])
self.assertFalse(ui_state.has_older_messages["Primary"])
separators = [prefix for prefix, _ in ui_state.all_messages["Primary"] if prefix.startswith("--")]
self.assertEqual(len(separators), 3)
def test_message_table_channel_name_may_contain_underscores(self) -> None:
db_handler.ensure_node_table_exists()
db_handler.ensure_table_exists(
'"123_My_Channel_messages"',
"user_id TEXT, message_text TEXT, timestamp INTEGER, ack_type TEXT",
)
with sqlite3.connect(config.db_file_path) as conn:
conn.execute(
'INSERT INTO "123_My_Channel_messages" VALUES (?, ?, ?, ?)',
("123", "hello", 1700000000, None),
)
db_handler.load_messages_from_db()
self.assertIn("My_Channel", ui_state.channel_list)
self.assertEqual(ui_state.all_messages["My_Channel"][-1][1], "hello")
def test_init_nodedb_inserts_nodes_from_interface(self) -> None:
interface_state.interface = build_demo_interface()
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
db_handler.init_nodedb()
self.assertEqual(db_handler.get_name_from_database(2701131778, "short"), "SAT2")
+38
View File
@@ -0,0 +1,38 @@
import tempfile
import unittest
from contact.ui import default_config
class DefaultConfigTests(unittest.TestCase):
def test_get_localisation_options_filters_hidden_and_non_ini_files(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
for filename in ("en.ini", "ru.ini", ".hidden.ini", "notes.txt"):
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
handle.write("")
self.assertEqual(default_config.get_localisation_options(tmpdir), ["en", "ru"])
def test_get_localisation_file_normalizes_extensions_and_falls_back_to_english(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
for filename in ("en.ini", "ru.ini"):
with open(f"{tmpdir}/{filename}", "w", encoding="utf-8") as handle:
handle.write("")
self.assertTrue(default_config.get_localisation_file("RU.ini", tmpdir).endswith("/ru.ini"))
self.assertTrue(default_config.get_localisation_file("missing", tmpdir).endswith("/en.ini"))
def test_update_dict_only_adds_missing_values(self) -> None:
default = {"theme": "dark", "nested": {"language": "en", "sound": True}}
actual = {"nested": {"language": "ru"}}
updated = default_config.update_dict(default, actual)
self.assertTrue(updated)
self.assertEqual(actual, {"theme": "dark", "nested": {"language": "ru", "sound": True}})
def test_format_json_single_line_arrays_keeps_arrays_inline(self) -> None:
rendered = default_config.format_json_single_line_arrays({"items": [1, 2], "nested": {"flags": ["a", "b"]}})
self.assertIn('"items": [1, 2]', rendered)
self.assertIn('"flags": ["a", "b"]', rendered)
+51
View File
@@ -0,0 +1,51 @@
import tempfile
import unittest
from unittest import mock
import contact.__main__ as entrypoint
import contact.ui.default_config as config
from contact.utilities.db_handler import get_name_from_database
from contact.utilities.demo_data import DEMO_CHANNELS, DEMO_LOCAL_NODE_NUM, build_demo_interface, configure_demo_database
from contact.utilities.singleton import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class DemoDataTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("db_file_path", "node_sort", "single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
def test_build_demo_interface_exposes_expected_shape(self) -> None:
interface = build_demo_interface()
self.assertEqual(interface.getMyNodeInfo()["num"], DEMO_LOCAL_NODE_NUM)
self.assertEqual([channel.settings.name for channel in interface.getNode("^local").channels], DEMO_CHANNELS)
self.assertIn(DEMO_LOCAL_NODE_NUM, interface.nodesByNum)
def test_initialize_globals_seed_demo_populates_ui_state_and_db(self) -> None:
interface_state.interface = build_demo_interface()
with tempfile.TemporaryDirectory() as tmpdir:
demo_db_path = configure_demo_database(tmpdir)
with mock.patch.object(entrypoint.pub, "subscribe"):
entrypoint.initialize_globals(seed_demo=True)
self.assertEqual(config.db_file_path, demo_db_path)
self.assertIn("MediumFast", ui_state.channel_list)
self.assertIn("Another Channel", ui_state.channel_list)
self.assertIn(2701131788, ui_state.channel_list)
self.assertEqual(ui_state.node_list[0], DEMO_LOCAL_NODE_NUM)
self.assertEqual(get_name_from_database(2701131778, "short"), "SAT2")
medium_fast = ui_state.all_messages["MediumFast"]
self.assertTrue(medium_fast[0][0].startswith("-- "))
self.assertTrue(any(config.sent_message_prefix in prefix and config.ack_str in prefix for prefix, _ in medium_fast))
self.assertTrue(any("SAT2:" in prefix for prefix, _ in medium_fast))
direct_messages = ui_state.all_messages[2701131788]
self.assertEqual(len(direct_messages), 3)
+88
View File
@@ -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()
+11
View File
@@ -0,0 +1,11 @@
import unittest
from contact.utilities.emoji_utils import normalize_message_text
class EmojiUtilsTests(unittest.TestCase):
def test_strips_modifiers_from_keycaps_and_skin_tones(self) -> None:
self.assertEqual(normalize_message_text("👍🏽 7️⃣"), "👍 7")
def test_rewrites_flag_emoji_to_country_codes(self) -> None:
self.assertEqual(normalize_message_text("🇺🇸 hello 🇩🇪"), "US hello DE")
+70
View File
@@ -0,0 +1,70 @@
import os
import tempfile
import unittest
from unittest import mock
import contact.ui.default_config as config
from contact.utilities import i18n
from contact.utilities.ini_utils import parse_ini_file
from tests.test_support import restore_config, snapshot_config
class I18nTests(unittest.TestCase):
def setUp(self) -> None:
self.saved_config = snapshot_config("language")
i18n._translations = {}
i18n._language = None
def tearDown(self) -> None:
restore_config(self.saved_config)
i18n._translations = {}
i18n._language = None
def test_t_loads_translation_file_and_formats_placeholders(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
translation_file = os.path.join(tmpdir, "xx.ini")
with open(translation_file, "w", encoding="utf-8") as handle:
handle.write('[ui]\n')
handle.write('greeting,"Hello {name}"\n')
config.language = "xx"
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
self.assertEqual(i18n.t("ui.greeting", name="Ben"), "Hello Ben")
def test_t_falls_back_to_default_and_returns_unformatted_text_on_error(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
translation_file = os.path.join(tmpdir, "xx.ini")
with open(translation_file, "w", encoding="utf-8") as handle:
handle.write('[ui]\n')
handle.write('greeting,"Hello {name}"\n')
config.language = "xx"
with mock.patch.object(config, "get_localisation_file", return_value=translation_file):
self.assertEqual(i18n.t("ui.greeting"), "Hello {name}")
self.assertEqual(i18n.t("ui.missing", default="Fallback"), "Fallback")
self.assertEqual(i18n.t_text("Literal {value}", value=7), "Literal 7")
def test_loader_cache_is_reused_until_language_changes(self) -> None:
config.language = "en"
with mock.patch.object(i18n, "parse_ini_file", return_value=({"key": "value"}, {})) as parse_ini_file:
self.assertEqual(i18n.t("key"), "value")
self.assertEqual(i18n.t("key"), "value")
self.assertEqual(parse_ini_file.call_count, 1)
config.language = "ru"
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
self.assertEqual(parse_ini_file.call_count, 2)
def test_ping_bot_settings_translation_keys_exist_in_all_locales(self) -> None:
required_keys = {
"app_settings.ping_bot",
"app_settings.ping_bot.enabled",
"app_settings.ping_bot.catch_words",
"app_settings.ping_bot.response_word",
}
for language in config.get_localisation_options():
field_mapping, _ = parse_ini_file(config.get_localisation_file(language))
self.assertTrue(required_keys.issubset(field_mapping), msg=f"Missing bot translation keys in {language}")
+40
View File
@@ -0,0 +1,40 @@
import os
import tempfile
import unittest
from unittest import mock
from contact.utilities.ini_utils import parse_ini_file
class IniUtilsTests(unittest.TestCase):
def test_parse_ini_file_reads_sections_fields_and_help_text(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ini_path = os.path.join(tmpdir, "settings.ini")
with open(ini_path, "w", encoding="utf-8") as handle:
handle.write('; comment\n')
handle.write('[config.device]\n')
handle.write('title,"Device","Device help"\n')
handle.write('name,"Node Name","Node help"\n')
handle.write('empty_help,"Fallback",""\n')
with mock.patch("contact.utilities.ini_utils.i18n.t", return_value="No help available."):
mapping, help_text = parse_ini_file(ini_path)
self.assertEqual(mapping["config.device"], "Device")
self.assertEqual(help_text["config.device"], "Device help")
self.assertEqual(mapping["config.device.name"], "Node Name")
self.assertEqual(help_text["config.device.name"], "Node help")
self.assertEqual(help_text["config.device.empty_help"], "No help available.")
def test_parse_ini_file_uses_builtin_help_fallback_when_i18n_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ini_path = os.path.join(tmpdir, "settings.ini")
with open(ini_path, "w", encoding="utf-8") as handle:
handle.write('[section]\n')
handle.write('name,"Name"\n')
with mock.patch("contact.utilities.ini_utils.i18n.t", side_effect=RuntimeError("boom")):
mapping, help_text = parse_ini_file(ini_path)
self.assertEqual(mapping["section.name"], "Name")
self.assertEqual(help_text["section.name"], "No help available.")
+26
View File
@@ -0,0 +1,26 @@
from argparse import Namespace
import unittest
from unittest import mock
from contact.utilities.interfaces import reconnect_interface
class InterfacesTests(unittest.TestCase):
def test_reconnect_interface_retries_until_connection_succeeds(self) -> None:
args = Namespace()
with mock.patch("contact.utilities.interfaces.initialize_interface", side_effect=[None, None, "iface"]) as initialize:
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
self.assertEqual(result, "iface")
self.assertEqual(initialize.call_count, 3)
self.assertEqual(sleep.call_count, 2)
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
args = Namespace()
with mock.patch("contact.utilities.interfaces.initialize_interface", return_value=None):
with mock.patch("contact.utilities.interfaces.time.sleep"):
with self.assertRaises(RuntimeError):
reconnect_interface(args, attempts=2, delay_seconds=0)
+253
View File
@@ -0,0 +1,253 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.__main__ as entrypoint
import contact.ui.default_config as config
from contact.utilities.singleton import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class MainRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("single_pane_mode")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
def test_initialize_runtime_interface_uses_demo_branch(self) -> None:
args = Namespace(demo_screenshot=True)
with mock.patch.object(entrypoint, "configure_demo_database") as configure_demo_database:
with mock.patch.object(entrypoint, "build_demo_interface", return_value="demo-interface") as build_demo:
with mock.patch.object(entrypoint, "initialize_interface") as initialize_interface:
result = entrypoint.initialize_runtime_interface(args)
self.assertEqual(result, "demo-interface")
configure_demo_database.assert_called_once_with()
build_demo.assert_called_once_with()
initialize_interface.assert_not_called()
def test_initialize_runtime_interface_uses_live_branch_without_demo_flag(self) -> None:
args = Namespace(demo_screenshot=False)
with mock.patch.object(entrypoint, "initialize_interface", return_value="live-interface") as initialize_interface:
result = entrypoint.initialize_runtime_interface(args)
self.assertEqual(result, "live-interface")
initialize_interface.assert_called_once_with(args)
def test_interface_is_ready_detects_missing_local_node(self) -> None:
self.assertFalse(entrypoint.interface_is_ready(object()))
self.assertTrue(entrypoint.interface_is_ready(SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))))
def test_initialize_runtime_interface_with_retry_retries_until_node_is_ready(self) -> None:
args = Namespace(demo_screenshot=False)
stdscr = mock.Mock()
bad_interface = mock.Mock(spec=["close"])
good_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=mock.Mock()))
with mock.patch.object(entrypoint, "initialize_runtime_interface", side_effect=[bad_interface, good_interface]):
with mock.patch.object(entrypoint, "get_list_input", return_value="Retry") as get_list_input:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
self.assertIs(result, good_interface)
get_list_input.assert_called_once()
bad_interface.close.assert_called_once_with()
draw_splash.assert_called_once_with(stdscr)
def test_initialize_runtime_interface_with_retry_returns_none_when_user_closes(self) -> None:
args = Namespace(demo_screenshot=False)
stdscr = mock.Mock()
bad_interface = mock.Mock(spec=["close"])
with mock.patch.object(entrypoint, "initialize_runtime_interface", return_value=bad_interface):
with mock.patch.object(entrypoint, "get_list_input", return_value="Close") as get_list_input:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
result = entrypoint.initialize_runtime_interface_with_retry(stdscr, args)
self.assertIsNone(result)
get_list_input.assert_called_once()
bad_interface.close.assert_called_once_with()
draw_splash.assert_not_called()
def test_prompt_region_if_unset_reinitializes_interface_after_confirmation(self) -> None:
args = Namespace()
old_interface = mock.Mock()
new_interface = mock.Mock()
stdscr = mock.Mock()
interface_state.interface = old_interface
with mock.patch.object(entrypoint, "get_list_input", return_value="Yes"):
with mock.patch.object(entrypoint, "set_region") as set_region:
with mock.patch.object(entrypoint, "draw_splash") as draw_splash:
with mock.patch.object(entrypoint, "reconnect_interface", return_value=new_interface) as reconnect:
entrypoint.prompt_region_if_unset(args, stdscr)
set_region.assert_called_once_with(old_interface)
old_interface.close.assert_called_once_with()
draw_splash.assert_called_once_with(stdscr)
reconnect.assert_called_once_with(args)
self.assertIs(interface_state.interface, new_interface)
def test_prompt_region_if_unset_leaves_interface_unchanged_when_declined(self) -> None:
args = Namespace()
interface = mock.Mock()
interface_state.interface = interface
with mock.patch.object(entrypoint, "get_list_input", return_value="No"):
with mock.patch.object(entrypoint, "set_region") as set_region:
with mock.patch.object(entrypoint, "reconnect_interface") as reconnect:
entrypoint.prompt_region_if_unset(args)
set_region.assert_not_called()
reconnect.assert_not_called()
interface.close.assert_not_called()
self.assertIs(interface_state.interface, interface)
def test_initialize_globals_resets_and_populates_runtime_state(self) -> None:
ui_state.channel_list = ["stale"]
ui_state.all_messages = {"stale": [("old", "message")]}
ui_state.notifications = [1]
ui_state.packet_buffer = ["packet"]
ui_state.node_list = [99]
ui_state.selected_channel = 3
ui_state.selected_message = 4
ui_state.selected_node = 5
ui_state.start_index = [9, 9, 9]
config.single_pane_mode = "True"
with mock.patch.object(entrypoint, "get_nodeNum", return_value=123):
with mock.patch.object(entrypoint, "get_channels", return_value=["Primary"]) as get_channels:
with mock.patch.object(entrypoint, "get_node_list", return_value=[123, 456]) as get_node_list:
with mock.patch.object(entrypoint.pub, "subscribe") as subscribe:
with mock.patch.object(entrypoint, "init_nodedb") as init_nodedb:
with mock.patch.object(entrypoint, "seed_demo_messages") as seed_demo_messages:
with mock.patch.object(entrypoint, "load_messages_from_db") as load_messages:
entrypoint.initialize_globals(seed_demo=True)
self.assertEqual(ui_state.channel_list, ["Primary"])
self.assertEqual(ui_state.all_messages, {})
self.assertEqual(ui_state.notifications, [])
self.assertEqual(ui_state.packet_buffer, [])
self.assertEqual(ui_state.node_list, [123, 456])
self.assertEqual(ui_state.selected_channel, 0)
self.assertEqual(ui_state.selected_message, 0)
self.assertEqual(ui_state.selected_node, 0)
self.assertEqual(ui_state.start_index, [0, 0, 0])
self.assertTrue(ui_state.single_pane_mode)
self.assertEqual(interface_state.myNodeNum, 123)
get_channels.assert_called_once_with()
get_node_list.assert_called_once_with()
subscribe.assert_called_once_with(entrypoint.on_receive, "meshtastic.receive")
init_nodedb.assert_called_once_with()
seed_demo_messages.assert_called_once_with()
load_messages.assert_called_once_with()
def test_ensure_min_rows_retries_until_terminal_is_large_enough(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.side_effect = [(10, 80), (11, 80)]
with mock.patch.object(entrypoint, "dialog") as dialog:
with mock.patch.object(entrypoint.curses, "update_lines_cols") as update_lines_cols:
entrypoint.ensure_min_rows(stdscr, min_rows=11)
dialog.assert_called_once()
update_lines_cols.assert_called_once_with()
stdscr.clear.assert_called_once_with()
stdscr.refresh.assert_called_once_with()
def test_start_prints_help_and_exits_zero(self) -> None:
parser = mock.Mock()
with mock.patch.object(entrypoint.sys, "argv", ["contact", "--help"]):
with mock.patch.object(entrypoint, "setup_parser", return_value=parser):
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 0)
parser.print_help.assert_called_once_with()
exit_mock.assert_called_once_with(0)
def test_start_runs_curses_wrapper_and_closes_interface(self) -> None:
interface = mock.Mock()
interface_state.interface = interface
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
entrypoint.start()
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)
with mock.patch.object(entrypoint, "setup_colors"):
with mock.patch.object(entrypoint, "ensure_min_rows"):
with mock.patch.object(entrypoint, "draw_splash"):
with mock.patch.object(entrypoint, "setup_parser") as setup_parser:
with mock.patch.object(entrypoint, "initialize_runtime_interface_with_retry", return_value=None):
with mock.patch.object(entrypoint, "initialize_globals") as initialize_globals:
setup_parser.return_value.parse_args.return_value = args
entrypoint.main(stdscr)
initialize_globals.assert_not_called()
def test_start_handles_keyboard_interrupt(self) -> None:
interface = mock.Mock()
interface_state.interface = interface
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 0)
interface.close.assert_called_once_with()
exit_mock.assert_called_once_with(0)
def test_start_handles_keyboard_interrupt_with_no_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 0)
exit_mock.assert_called_once_with(0)
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
with mock.patch("builtins.print") as print_mock:
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 1)
endwin.assert_called_once_with()
print_exc.assert_called_once_with()
print_mock.assert_any_call("Fatal error:", mock.ANY)
exit_mock.assert_called_once_with(1)
+46
View File
@@ -0,0 +1,46 @@
from types import SimpleNamespace
import unittest
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.ui.menus import generate_menu_from_protobuf
class MenusTests(unittest.TestCase):
def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {
"user": {"longName": "Test User", "shortName": "TU", "isLicensed": False},
"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0},
},
)
menu = generate_menu_from_protobuf(interface)
keys = list(menu["Main Menu"].keys())
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
ringtone="tone",
cannedPluginMessage="Hi|Bye",
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
)
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
+36
View File
@@ -0,0 +1,36 @@
import unittest
from unittest import mock
from contact.ui import nav_utils
from contact.ui.nav_utils import truncate_with_ellipsis, wrap_text
from contact.utilities.singleton import ui_state
class NavUtilsTests(unittest.TestCase):
def setUp(self) -> None:
ui_state.current_window = 0
ui_state.node_list = []
ui_state.start_index = [0, 0, 0]
def test_wrap_text_splits_wide_characters_by_display_width(self) -> None:
self.assertEqual(wrap_text("🔐🔐🔐", 4), ["🔐", "🔐", "🔐"])
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None:
ui_state.current_window = 2
ui_state.start_index = [0, 0, 0]
menu_win = mock.Mock()
menu_win.getbegyx.return_value = (0, 0)
menu_win.getmaxyx.return_value = (8, 20)
menu_pad = mock.Mock()
menu_pad.getmaxyx.return_value = (4, 20)
with mock.patch.object(nav_utils, "get_node_color", side_effect=[11, 22]):
nav_utils.highlight_line(menu_win, menu_pad, 0, 1, 5)
self.assertEqual(
menu_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
+153
View File
@@ -0,0 +1,153 @@
import unittest
from unittest import mock
import contact.ui.default_config as config
from contact.message_handlers import rx_handler
from contact.utilities.singleton import interface_state, menu_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class RxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("notification_sound", "message_prefix")
config.notification_sound = "False"
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 3,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=True):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "add_notification") as add_notification:
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(
request_ui_redraw.call_args_list,
[
mock.call(nodes=True),
mock.call(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=False,
),
],
)
add_notification.assert_not_called()
save_message_to_db.assert_called_once_with("Primary", 222, "hello", packet_id=None, reply_id=None)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
self.assertIn("SAT2:", ui_state.all_messages["Primary"][-1][0])
self.assertIn("[2]", ui_state.all_messages["Primary"][-1][0])
def test_on_receive_preserves_message_selection_while_showing_new_message(self) -> None:
interface_state.myNodeNum = 111
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "save_message_to_db"):
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
request_ui_redraw.assert_called_once_with(
messages=True,
scroll_messages_to_bottom=True,
preserve_message_selection=True,
)
def test_on_receive_direct_message_adds_channel_and_notification(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
ui_state.selected_channel = 0
packet = {
"from": 222,
"to": 111,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"dm"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
with mock.patch.object(rx_handler, "add_notification") as add_notification:
with mock.patch.object(rx_handler, "update_node_info_in_db") as update_node_info_in_db:
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="SAT2"):
rx_handler.on_receive(packet, interface=None)
self.assertIn(222, ui_state.channel_list)
self.assertIn(222, ui_state.all_messages)
request_ui_redraw.assert_called_once_with(channels=True)
add_notification.assert_called_once_with(1)
update_node_info_in_db.assert_called_once_with(222, chat_archived=False)
save_message_to_db.assert_called_once_with(222, 222, "dm", packet_id=None, reply_id=None)
def test_on_receive_displays_context_for_native_reply_id(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:00:00] >> SAT2: ", "hello world")]}
ui_state.message_packet_ids = {"Primary": [900]}
ui_state.selected_channel = 0
packet = {
"id": 901,
"from": 222,
"to": 999,
"channel": 0,
"hopStart": 1,
"hopLimit": 1,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hi", "replyId": 900},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "request_ui_redraw"):
with mock.patch.object(rx_handler, "save_message_to_db") as save_message_to_db:
with mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"):
rx_handler.on_receive(packet, interface=None)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: SAT2: hello> hi")
self.assertEqual(ui_state.message_packet_ids["Primary"][-1], 901)
save_message_to_db.assert_called_once_with("Primary", 222, "hi", packet_id=901, reply_id=900)
def test_on_receive_trims_packet_buffer_even_when_packet_is_undecoded(self) -> None:
ui_state.packet_buffer = list(range(25))
ui_state.display_log = True
ui_state.current_window = 4
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
rx_handler.on_receive({"id": "new"}, interface=None)
request_ui_redraw.assert_called_once_with(packetlog=True)
self.assertEqual(len(ui_state.packet_buffer), 20)
self.assertEqual(ui_state.packet_buffer[-1], {"id": "new"})
self.assertTrue(menu_state.need_redraw)
+114
View File
@@ -0,0 +1,114 @@
from types import SimpleNamespace
import unittest
from unittest import mock
from contact.utilities.save_to_radio import save_changes
class SaveToRadioTests(unittest.TestCase):
def build_interface(self):
node = mock.Mock()
node.localConfig = SimpleNamespace(
lora=SimpleNamespace(region=0, serial_enabled=False),
device=SimpleNamespace(role="CLIENT", name="node"),
security=SimpleNamespace(debug_log_api_enabled=False, serial_enabled=False, admin_key=[]),
display=SimpleNamespace(flip_screen=False, units=0),
power=SimpleNamespace(is_power_saving=False, adc_enabled=False),
network=SimpleNamespace(wifi_enabled=False),
bluetooth=SimpleNamespace(enabled=False),
)
node.moduleConfig = SimpleNamespace(mqtt=SimpleNamespace(enabled=False))
interface = mock.Mock()
interface.getNode.return_value = node
return interface, node
def test_save_changes_returns_true_for_lora_writes_that_require_reconnect(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
reconnect_required = save_changes(interface, {"region": 7}, menu_state)
self.assertTrue(reconnect_required)
self.assertEqual(node.localConfig.lora.region, 7)
node.writeConfig.assert_called_once_with("lora")
def test_save_changes_returns_false_when_nothing_changed(self) -> None:
interface = mock.Mock()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Lora"])
self.assertFalse(save_changes(interface, {}, menu_state))
def test_save_changes_returns_false_for_non_rebooting_security_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"serial_enabled": True}, menu_state)
self.assertFalse(reconnect_required)
self.assertTrue(node.localConfig.security.serial_enabled)
def test_save_changes_returns_true_for_rebooting_security_fields(self) -> None:
interface, _node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"admin_key": [b"12345678"]}, menu_state)
self.assertTrue(reconnect_required)
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
self.assertFalse(save_changes(interface, {"name": "renamed"}, menu_state))
self.assertEqual(node.localConfig.device.name, "renamed")
node.writeConfig.reset_mock()
self.assertTrue(save_changes(interface, {"role": "ROUTER"}, menu_state))
self.assertEqual(node.localConfig.device.role, "ROUTER")
def test_save_changes_returns_true_for_network_settings(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Network"])
reconnect_required = save_changes(interface, {"wifi_enabled": True}, menu_state)
self.assertTrue(reconnect_required)
self.assertTrue(node.localConfig.network.wifi_enabled)
def test_save_changes_returns_true_only_for_rebooting_power_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Power"])
self.assertFalse(save_changes(interface, {"adc_enabled": True}, menu_state))
self.assertTrue(node.localConfig.power.adc_enabled)
node.writeConfig.reset_mock()
self.assertTrue(save_changes(interface, {"is_power_saving": True}, menu_state))
self.assertTrue(node.localConfig.power.is_power_saving)
def test_save_changes_returns_true_for_module_settings(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Module Settings", "Mqtt"])
reconnect_required = save_changes(interface, {"enabled": True}, menu_state)
self.assertTrue(reconnect_required)
self.assertTrue(node.moduleConfig.mqtt.enabled)
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
reconnect_required = save_changes(interface, {"longName": "Node"}, menu_state)
self.assertTrue(reconnect_required)
node.setOwner.assert_called_once()
def test_save_changes_returns_true_for_user_license_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
reconnect_required = save_changes(interface, {"isLicensed": True}, menu_state)
self.assertTrue(reconnect_required)
node.setOwner.assert_called_once()
+75
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()
+27
View File
@@ -0,0 +1,27 @@
import threading
import contact.ui.default_config as config
from contact.ui.ui_state import AppState, ChatUIState, InterfaceState, MenuState
from contact.utilities.singleton import app_state, interface_state, menu_state, ui_state
def reset_singletons() -> None:
_reset_instance(ui_state, ChatUIState())
_reset_instance(interface_state, InterfaceState())
_reset_instance(menu_state, MenuState())
_reset_instance(app_state, AppState())
app_state.lock = threading.Lock()
def restore_config(saved: dict) -> None:
for key, value in saved.items():
setattr(config, key, value)
def snapshot_config(*keys: str) -> dict:
return {key: getattr(config, key) for key in keys}
def _reset_instance(target: object, replacement: object) -> None:
target.__dict__.clear()
target.__dict__.update(replacement.__dict__)
+27
View File
@@ -0,0 +1,27 @@
import unittest
from unittest import mock
from contact.utilities.telemetry_beautifier import get_chunks, humanize_wind_direction
class TelemetryBeautifierTests(unittest.TestCase):
def test_humanize_wind_direction_handles_boundaries(self) -> None:
self.assertEqual(humanize_wind_direction(0), "N")
self.assertEqual(humanize_wind_direction(90), "E")
self.assertEqual(humanize_wind_direction(225), "SW")
self.assertIsNone(humanize_wind_direction(-1))
def test_get_chunks_formats_known_and_unknown_values(self) -> None:
rendered = get_chunks("uptime_seconds:7200\nwind_direction:90\nlatitude_i:123456789\nunknown:abc\n")
self.assertIn("🆙 2.0h", rendered)
self.assertIn("⮆ E", rendered)
self.assertIn("🌍 12.345679", rendered)
self.assertIn("unknown:abc", rendered)
def test_get_chunks_formats_time_values(self) -> None:
with mock.patch("contact.utilities.telemetry_beautifier.datetime.datetime") as mocked_datetime:
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "01.01.1970 00:00"
rendered = get_chunks("time:0\n")
self.assertIn("🕔 01.01.1970 00:00", rendered)
+122
View File
@@ -0,0 +1,122 @@
from types import SimpleNamespace
import unittest
from unittest import mock
from meshtastic import BROADCAST_NUM
import contact.ui.default_config as config
from contact.message_handlers import tx_handler
from contact.utilities.singleton import interface_state, ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class TxHandlerTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
tx_handler.ack_naks.clear()
self.saved_config = snapshot_config("sent_message_prefix", "ack_str", "ack_implicit_str", "nak_str", "ack_unknown_str")
def tearDown(self) -> None:
tx_handler.ack_naks.clear()
restore_config(self.saved_config)
reset_singletons()
def test_send_message_on_named_channel_tracks_ack_request(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-1")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=999) as save_message_to_db:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
tx_handler.send_message("hello", channel=0)
interface.sendText.assert_called_once_with(
text="hello",
destinationId=BROADCAST_NUM,
wantAck=True,
wantResponse=False,
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
save_message_to_db.assert_called_once_with("Primary", 111, "hello", packet_id="req-1", reply_id=None)
self.assertEqual(tx_handler.ack_naks["req-1"]["channel"], "Primary")
self.assertEqual(tx_handler.ack_naks["req-1"]["messageIndex"], 1)
self.assertEqual(tx_handler.ack_naks["req-1"]["timestamp"], 999)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "hello")
def test_send_message_to_direct_node_uses_node_as_destination(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-2")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = [222]
ui_state.all_messages = {222: []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[00:00:00] "):
tx_handler.send_message("dm", channel=0)
interface.sendText.assert_called_once_with(
text="dm",
destinationId=222,
wantAck=True,
wantResponse=False,
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
self.assertEqual(tx_handler.ack_naks["req-2"]["channel"], 222)
def test_send_message_uses_native_reply_id_but_only_displays_reply_context_locally(self) -> None:
interface = mock.Mock()
interface.sendText.return_value = SimpleNamespace(id="req-3")
interface_state.interface = interface
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": []}
with mock.patch.object(tx_handler, "save_message_to_db", return_value=123):
tx_handler.send_message("hello", channel=0, reply_id=77, reply_context="<Re: NODE: hello> ")
self.assertEqual(interface.sendText.call_args.kwargs["text"], "hello")
self.assertEqual(interface.sendText.call_args.kwargs["replyId"], 77)
self.assertEqual(ui_state.all_messages["Primary"][-1][1], "<Re: NODE: hello> hello")
def test_on_ack_nak_updates_message_for_explicit_ack(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 222, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
with mock.patch("contact.ui.contact_ui.request_ui_redraw") as request_ui_redraw:
tx_handler.onAckNak(packet)
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Ack")
request_ui_redraw.assert_called_once_with(messages=True)
self.assertIn(config.sent_message_prefix, ui_state.all_messages["Primary"][0][0])
self.assertIn(config.ack_str, ui_state.all_messages["Primary"][0][0])
def test_on_ack_nak_uses_implicit_marker_for_self_ack(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.all_messages = {"Primary": [("pending", "hello")]}
tx_handler.ack_naks["req"] = {"channel": "Primary", "messageIndex": 0, "timestamp": 55, "dbMessage": "hello"}
packet = {"from": 111, "decoded": {"requestId": "req", "routing": {"errorReason": "NONE"}}}
with mock.patch.object(tx_handler, "update_ack_nak") as update_ack_nak:
with mock.patch("contact.message_handlers.tx_handler.time.strftime", return_value="[01:02:03] "):
with mock.patch("contact.ui.contact_ui.request_ui_redraw"):
tx_handler.onAckNak(packet)
update_ack_nak.assert_called_once_with("Primary", 55, "hello", "Implicit")
self.assertIn(config.ack_implicit_str, ui_state.all_messages["Primary"][0][0])
+18
View File
@@ -0,0 +1,18 @@
import unittest
from unittest import mock
import contact.ui.default_config # Initialize config before the color helpers.
from contact.ui import user_config
class UserConfigTests(unittest.TestCase):
def test_ping_bot_enabled_uses_boolean_picker(self) -> None:
with mock.patch.object(user_config, "get_list_input", return_value="True") as picker:
result = user_config.edit_value("enabled", "Enabled", "False")
self.assertEqual(result, "True")
picker.assert_called_once_with("Enabled", "False", ["True", "False"])
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import unittest
from unittest import mock
import contact.ui.default_config as config
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
from tests.test_support import reset_singletons, restore_config, snapshot_config
class UtilsTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("node_sort")
def tearDown(self) -> None:
restore_config(self.saved_config)
reset_singletons()
def test_get_node_list_keeps_local_first_and_ignored_last(self) -> None:
config.node_sort = "lastHeard"
interface = build_demo_interface()
interface_state.interface = interface
interface_state.myNodeNum = DEMO_LOCAL_NODE_NUM
node_list = get_node_list()
self.assertEqual(node_list[0], DEMO_LOCAL_NODE_NUM)
self.assertEqual(node_list[-1], 0xA1000008)
def test_add_new_message_groups_messages_by_hour(self) -> None:
ui_state.all_messages = {"MediumFast": []}
with mock.patch("contact.utilities.utils.time.time", side_effect=[1000, 1000]):
with mock.patch("contact.utilities.utils.time.strftime", return_value="[00:16:40] "):
with mock.patch("contact.utilities.utils.datetime.datetime") as mocked_datetime:
mocked_datetime.fromtimestamp.return_value.strftime.return_value = "2025-02-04 17:00"
add_new_message("MediumFast", ">> Test: ", "First")
add_new_message("MediumFast", ">> Test: ", "Second")
self.assertEqual(
ui_state.all_messages["MediumFast"],
[
("-- 2025-02-04 17:00 --", ""),
("[00:16:40] >> Test: ", "First"),
("[00:16:40] >> Test: ", "Second"),
],
)
def test_get_channels_populates_message_buckets_for_device_channels(self) -> None:
interface_state.interface = build_demo_interface()
ui_state.channel_list = []
ui_state.all_messages = {}
channels = get_channels()
self.assertIn("MediumFast", channels)
self.assertIn("Another Channel", channels)
self.assertIn("MediumFast", ui_state.all_messages)
self.assertIn("Another Channel", ui_state.all_messages)
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
interface = build_demo_interface()
interface.localNode.channels[0].settings.name = "Renamed Channel"
interface_state.interface = interface
ui_state.channel_list = ["MediumFast", "Another Channel", 2701131788]
ui_state.all_messages = {
"MediumFast": [("prefix", "first")],
"Another Channel": [("prefix", "second")],
2701131788: [("prefix", "dm")],
}
ui_state.selected_channel = 2
channels = get_channels()
self.assertEqual(channels[0], "Renamed Channel")
self.assertEqual(channels[1], "Another Channel")
self.assertEqual(channels[2], 2701131788)
self.assertEqual(ui_state.all_messages["Renamed Channel"], [("prefix", "first")])
self.assertEqual(ui_state.all_messages["Another Channel"], [("prefix", "second")])
self.assertEqual(ui_state.all_messages[2701131788], [("prefix", "dm")])
self.assertNotIn("MediumFast", ui_state.all_messages)
def test_parse_protobuf_returns_string_payload_unchanged(self) -> None:
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": "hello"}}
self.assertEqual(parse_protobuf(packet), "hello")
def test_parse_protobuf_returns_placeholder_for_text_messages(self) -> None:
packet = {"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"}}
self.assertEqual(parse_protobuf(packet), "✉️")
+14
View File
@@ -0,0 +1,14 @@
import unittest
from contact.utilities.validation_rules import get_validation_for
class ValidationRulesTests(unittest.TestCase):
def test_get_validation_for_matches_exact_keys(self) -> None:
self.assertEqual(get_validation_for("shortName"), {"max_length": 4})
def test_get_validation_for_matches_substrings(self) -> None:
self.assertEqual(get_validation_for("config.position.latitude"), {"min_value": -90, "max_value": 90})
def test_get_validation_for_returns_empty_dict_for_unknown_key(self) -> None:
self.assertEqual(get_validation_for("totally_unknown"), {})