Compare commits

...
95 Commits
Author SHA1 Message Date
pdxlocations a89d4f8c0d Refactor paint_frame and refresh_node_selection functions to include optional refresh parameter; add stage_node_details_background function for improved window management in F5 key handling tests. 2026-09-05 13:35:36 -07:00
pdxlocations 808fee4fe0 Bump version to 1.6.9 in pyproject.toml 2026-09-05 00:00:11 -07:00
pdxlocations 94d0a9053e Enhance interface initialization and splash screen status display
- Updated `initialize_runtime_interface` to accept `stdscr` for better integration with the UI.
- Modified `initialize_interface` to include a status callback for improved user feedback during connection attempts.
- Refactored `draw_splash` to dynamically display status messages, improving user experience during startup.
2026-09-04 23:59:53 -07:00
pdxlocations f38167db1a Bump version to 1.6.8 in pyproject.toml 2026-09-04 23:51:29 -07:00
pdxlocations 57caf463bc Merge pull request #305 from pdxlocations:edit-message-text
Edit message text with arrows
2026-09-04 23:30:23 -07:00
pdxlocations 36408a1f37 Edit message text with arrows 2026-09-04 23:30:08 -07:00
pdxlocations 07b9ed22d8 Bump version to 1.6.7 in pyproject.toml 2026-07-30 22:26:46 -07:00
pdxlocations bdba69677b Refactor error handling in start function to use print_exception for better traceback output and enhance tests for channel name resolution with new modem presets 2026-07-30 22:18:25 -07:00
pdxlocations 1f88a37ac1 Refactor save_changes to streamline admin key updates and enhance test coverage for key replacement 2026-07-26 20:34:55 -07:00
pdxlocations 8f33cbc22d Refactor handle_backtick to improve local settings selection 2026-07-26 20:32:21 -07:00
pdxlocations 40665ff076 Bump version to 1.6.6 in pyproject.toml 2026-07-26 14:26:01 -07:00
pdxlocations d1b6da6f26 Refactor channel settings to rename 'is_client_muted' to 'is_muted' and update related save functionality and tests 2026-07-26 14:23:11 -07:00
pdxlocations ac90b4af3e Enhance remote admin verification to establish authorized sessions and update related tests 2026-07-26 14:07:09 -07:00
pdxlocations 7af693ddea Enhance bot response to include reply context in message sending 2026-07-26 13:47:51 -07:00
pdxlocations dae6a43652 Bump version to 1.6.5 in pyproject.toml 2026-07-26 11:09:26 -07:00
pdxlocations 22e5e89828 Bump version to 1.6.4 in pyproject.toml 2026-07-26 11:08:57 -07:00
pdxlocations c2db76d5ec Merge pull request #298 from pdxlocations:log-viewer
Add log viewer functionality and enhance reconnect handling
2026-07-26 11:08:27 -07:00
pdxlocations d8dc4876b6 Add log viewer functionality and enhance reconnect handling
- Introduced a log viewer in the UI to display live logs.
- Added functionality to load the last few lines of the log file.
- Enhanced reconnect logic to handle UI state during connection attempts.
- Updated UI state management to track log viewer status.
- Added tests for log loading functionality.
2026-07-26 11:07:33 -07:00
pdxlocations 0ae644e3fb Keep Meshtastic logs out of the curses terminal 2026-07-26 10:44:02 -07:00
pdxlocations bfd20bfa49 Verify remote admin access before opening settings 2026-07-26 10:31:00 -07:00
pdxlocations fa2dbccf44 Bump version to 1.6.3 in pyproject.toml 2026-07-25 22:31:42 -07:00
pdxlocations 66256f0a4c Enhance config import/export to support selected remote nodes 2026-07-25 22:30:33 -07:00
pdxlocations e570ce4e48 Merge pull request #296 from pdxlocations:unmessagable
Add 'isUnmessagable' setting to user configuration
2026-07-25 22:12:28 -07:00
pdxlocations d0d9c1dd55 Add 'isUnmessagable' setting to user configuration and update related functionality 2026-07-25 22:12:04 -07:00
pdxlocations 043418bfde Implement remote admin cancellation handling and improve timeout management 2026-07-25 22:01:23 -07:00
pdxlocations a8a1527a84 Add interface connection check utility and refactor connection logic 2026-07-25 21:58:52 -07:00
pdxlocations 7d2092e25f Merge pull request #295 from pdxlocations:remote-admin
Refactor remote admin handling
2026-07-25 21:55:34 -07:00
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
45 changed files with 3806 additions and 566 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"]
}
]
}
+8
View File
@@ -45,6 +45,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
### Notification sounds
Add audio files to Contact's `contact/sounds/` folder, then choose one in Settings → App Settings → Notification sound. `alert.mp3` is the default selection; choose `None` to disable notification audio. Supported file types are MP3, WAV, OGG, AIFF, and FLAC.
### Remote administration
With the Nodes pane selected, press `` ` `` or `F12` and choose **Remote admin** for the highlighted node. Contact requests the node's settings through Meshtastic Remote Admin and displays an error if the node does not authorize your public key. Remote App Settings are intentionally unavailable because they apply only to the local Contact client.
For smaller displays you may wish to enable `single_pane_mode`:
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54 PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
+56 -12
View File
@@ -52,11 +52,19 @@ if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
logging.basicConfig(
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
filename=config.log_file_path,
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
# Meshtastic or another dependency may have installed a stderr handler
# before Contact starts curses. Replace it so background warnings cannot
# overwrite the terminal UI.
force=True,
)
app_state.lock = threading.Lock()
DEFAULT_CLOSE_TIMEOUT_SECONDS = 5.0
# ------------------------------------------------------------------------------
# Main Program Logic
@@ -72,11 +80,36 @@ def prompt_region_if_unset(args: object, stdscr: Optional[curses.window] = None)
interface_state.interface = reconnect_interface(args)
def close_interface(interface: object) -> None:
def close_interface(interface: object, timeout_seconds: float = DEFAULT_CLOSE_TIMEOUT_SECONDS) -> bool:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
return True
close_errors = []
def _close_target() -> None:
try:
interface.close()
except BaseException as error: # Keep shutdown resilient even for KeyboardInterrupt/SystemExit from libraries.
close_errors.append(error)
close_thread = threading.Thread(target=_close_target, name="meshtastic-interface-close", daemon=True)
close_thread.start()
close_thread.join(timeout_seconds)
if close_thread.is_alive():
logging.warning("Timed out closing interface after %.1fs; continuing shutdown", timeout_seconds)
return False
if not close_errors:
return True
error = close_errors[0]
if isinstance(error, KeyboardInterrupt):
logging.info("Interrupted while closing interface; continuing shutdown")
return True
logging.warning("Ignoring error while closing interface: %r", error)
return True
def interface_is_ready(interface: object) -> bool:
@@ -88,7 +121,7 @@ def interface_is_ready(interface: object) -> bool:
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
while True:
interface = initialize_runtime_interface(args)
interface = initialize_runtime_interface(args, stdscr)
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
return interface
@@ -129,10 +162,12 @@ def initialize_globals(seed_demo: bool = False) -> None:
load_messages_from_db()
def initialize_runtime_interface(args: object):
def initialize_runtime_interface(args: object, stdscr=None):
if getattr(args, "demo_screenshot", False):
configure_demo_database()
return build_demo_interface()
if stdscr is not None:
return initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
return initialize_interface(args)
@@ -205,23 +240,32 @@ def start() -> None:
setup_parser().print_help()
sys.exit(0)
interrupted = False
fatal_error = None
try:
curses.wrapper(main)
close_interface(interface_state.interface)
except KeyboardInterrupt:
interrupted = True
logging.info("User exited with Ctrl+C")
close_interface(interface_state.interface)
sys.exit(0)
except Exception as e:
fatal_error = e
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
print("Fatal error:", e)
traceback.print_exc()
finally:
close_interface(interface_state.interface)
if fatal_error is not None:
print("Fatal error:", fatal_error)
traceback.print_exception(type(fatal_error), fatal_error, fatal_error.__traceback__)
sys.exit(1)
if interrupted:
sys.exit(0)
if __name__ == "__main__":
start()
+83 -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,6 +76,7 @@ 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", ""
@@ -94,6 +99,7 @@ user, "User"
longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign."
shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis."
isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling."
isUnmessagable, "Disable incoming messages", "When enabled, this node advertises that it cannot receive messages."
[app_settings]
title, "App Settings", ""
@@ -107,17 +113,25 @@ language, "Language", "UI language for labels and help text."
message_prefix, "Message prefix", ""
sent_message_prefix, "Sent message prefix", ""
notification_symbol, "Notification symbol", ""
notification_sound, "Notification sound", ""
notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio."
view_log, "View Log", "Open a scrollable live view of Contact's log file."
ack_implicit_str, "ACK (implicit)", ""
ack_str, "ACK", ""
nak_str, "NAK", ""
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", ""
@@ -154,7 +168,7 @@ uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT s
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
module_settings, "Module settings", "Position precision and Client Mute."
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
module_settings.is_muted, "Is muted", "Controls whether or not the client or device should mute this channel. Useful for noisy public channels you do not necessarily want to disable."
[config.device]
title, "Device"
@@ -240,6 +254,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 +274,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 +303,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 +328,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 +346,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 +378,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 +409,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 +427,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 +489,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", ""
+9
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, "Подождите", ""
@@ -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, "Фон", ""
+103
View File
@@ -0,0 +1,103 @@
# 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.utilities.utils import get_reply_context
from contact.message_handlers.tx_handler import send_message
BOT_RESPONSE_DELAY_SECONDS = 2.3
def _get_bot_catch_words() -> set[str]:
"""Return normalized bot trigger words from app settings."""
raw_words = getattr(config, "ping_bot_catch_words", "ping; test")
words = {
word.strip().casefold()
for word in raw_words.replace(";", ",").split(",")
if word.strip()
}
return words or {"ping"}
def is_bot_message(message: str) -> bool:
"""Return True when the incoming message should trigger an automatic response."""
return message.strip().casefold() in _get_bot_catch_words()
def is_bot_enabled() -> bool:
"""Return whether automatic responses are enabled in app settings."""
value = getattr(config, "ping_bot_enabled", False)
return value if isinstance(value, bool) else str(value).strip().casefold() == "true"
def bot_respond(packet: Dict[str, Any], message: str, send_channel: int) -> bool:
"""Send a basic response when bot mode is enabled."""
if not is_bot_enabled():
return False
if not is_bot_message(message):
""" Only respond to specific messages. """
return False
from_node = packet.get("from")
if from_node is None:
return False
if from_node == interface_state.myNodeNum:
return False
snr = packet.get('rxSnr', -128)
rssi = packet.get('rxRssi', -128)
incoming_reply_id = (packet.get('decoded') or {}).get('replyId')
packet_id = packet.get("id")
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
transport_type = packet.get('transportMechanism', None)
hops = hop_start - hop_limit
details = []
if snr != -128:
details.append(f"SNR: {snr}")
if rssi != -128:
details.append(f"RSSI: {rssi}")
if hops != 0:
details.append(f"Hops: {hops}")
if incoming_reply_id:
details.append(f"Relay: {incoming_reply_id}")
transport_text = str(transport_type).upper() if transport_type is not None else ""
for transport_name in ("UDP", "MQTT"):
if transport_name in transport_text:
details.append(f"Via: {transport_name}")
response_data_string = getattr(config, "ping_bot_response_word", "Pong!")
if details:
response_data_string += f" {', '.join(details)}"
def send_response_delayed() -> None:
try:
time.sleep(BOT_RESPONSE_DELAY_SECONDS)
with app_state.lock:
if not is_bot_enabled():
return
# Use the triggering packet's ID so Meshtastic clients render this
# automatic response as a native reply when that ID is available.
reply_context = get_reply_context(packet_id) if packet_id is not None else ""
send_message(
response_data_string,
channel=send_channel,
reply_id=packet_id,
reply_context=reply_context,
)
# Import locally to avoid circular import at module import time.
from contact.ui.contact_ui import request_ui_redraw
request_ui_redraw(channels=True, messages=True, scroll_messages_to_bottom=True)
logging.info("Bot response sent to %s on channel index %s", from_node, send_channel)
except Exception:
logging.exception("Bot response send failed for destination %s", from_node)
threading.Thread(target=send_response_delayed, name="bot-response", daemon=True).start()
return True
+50 -34
View File
@@ -46,13 +46,11 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
get_reply_context,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
draw_messages_window,
draw_channel_list,
add_notification,
request_ui_redraw,
)
from contact.utilities.db_handler import (
save_message_to_db,
@@ -63,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}")
@@ -121,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
@@ -132,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"]:
@@ -145,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"]
@@ -171,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]:
@@ -183,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}")
+122 -111
View File
@@ -1,6 +1,6 @@
import time
from typing import Any, Dict
from typing import Any, Dict, Optional
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
@@ -15,7 +15,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
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: Optional[int] = 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,
}
+17 -7
View File
@@ -6,19 +6,26 @@ import sys
import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
from contact.ui.dialog import dialog
from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.i18n import t
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface, reconnect_interface
def close_interface(interface: object) -> None:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
interface = None
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
@@ -29,7 +36,7 @@ def main(stdscr: curses.window) -> None:
parser = setup_parser()
args = parser.parse_args()
interface = initialize_interface(args)
interface = initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input(
@@ -39,7 +46,7 @@ def main(stdscr: curses.window) -> None:
)
if confirmation == "Yes":
set_region(interface)
interface.close()
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
@@ -52,6 +59,8 @@ def main(stdscr: curses.window) -> None:
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output before crash:\n%s", console_output)
raise
finally:
close_interface(interface)
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:
@@ -76,6 +85,7 @@ logging.basicConfig( # Run `tail -f client.log` in another terminal to view liv
filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
force=True,
)
if __name__ == "__main__":
Binary file not shown.
+903 -163
View File
File diff suppressed because it is too large. Load diff
+217 -25
View File
@@ -4,7 +4,10 @@ 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
@@ -24,7 +27,6 @@ from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.splash import draw_splash
from contact.ui.user_config import json_editor
from contact.utilities.arg_parser import setup_parser
from contact.utilities.singleton import interface_state, menu_state
@@ -33,8 +35,14 @@ from contact.utilities.singleton import interface_state, menu_state
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"]
class RemoteAdminCancelled(Exception):
"""Raised when the user dismisses an in-progress remote-admin request."""
# Compute the effective menu width for the current terminal
@@ -43,7 +51,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__))
@@ -85,7 +93,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]:
@@ -231,9 +240,6 @@ def reconnect_interface_with_splash(stdscr: object, interface: object) -> object
except Exception:
pass
stdscr.clear()
stdscr.refresh()
draw_splash(stdscr)
new_interface = reconnect_interface(setup_parser().parse_args())
interface_state.interface = new_interface
redraw_main_ui_after_reconnect(stdscr)
@@ -246,6 +252,26 @@ def reconnect_after_admin_action(stdscr: object, interface: object, action, log_
return reconnect_interface_with_splash(stdscr, interface)
def request_factory_reset(node: object, full: bool = False):
try:
return node.factoryReset(full=full)
except TypeError as ex:
field_name = "factory_reset_device" if full else "factory_reset_config"
field = admin_pb2.AdminMessage.DESCRIPTOR.fields_by_name[field_name]
if field.cpp_type != field.CPPTYPE_INT32:
raise
node.ensureSessionKey()
message = admin_pb2.AdminMessage()
setattr(message, field_name, 1)
if node == node.iface.localNode:
on_response = None
else:
on_response = node.onAckNak
return node._sendAdmin(message, onResponse=on_response)
def redraw_main_ui_after_reconnect(stdscr: object) -> None:
try:
from contact.ui import contact_ui
@@ -253,15 +279,118 @@ def redraw_main_ui_after_reconnect(stdscr: object) -> None:
get_channels()
refresh_node_list()
contact_ui.handle_resize(stdscr, False)
if not contact_ui.ui_state.log_viewer_open:
contact_ui.handle_resize(stdscr, False)
except Exception:
logging.debug("Skipping main UI redraw after reconnect", exc_info=True)
def settings_menu(stdscr: object, interface: object) -> None:
curses.update_lines_cols()
def _request_remote_with_timeout(request, *args, cancel_callback=None) -> None:
"""Run a Meshtastic remote request without allowing its ACK wait to hang the UI."""
failure = []
menu = generate_menu_from_protobuf(interface)
def run_request() -> None:
try:
request(*args)
except BaseException as exc:
failure.append(exc)
worker = threading.Thread(target=run_request, name="remote-admin-request", daemon=True)
worker.start()
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
while worker.is_alive() and time.monotonic() < deadline:
worker.join(0.1)
if cancel_callback and cancel_callback():
raise RemoteAdminCancelled()
if worker.is_alive():
raise TimeoutError("Timed out waiting for the remote node to answer the admin request.")
if failure:
raise failure[0]
def _request_remote_channels_with_timeout(node: object, cancel_callback=None) -> None:
"""Retrieve a remote node's channel list without leaving the UI waiting forever."""
node.requestChannels()
deadline = time.monotonic() + REMOTE_ADMIN_REQUEST_TIMEOUT_SECONDS
while getattr(node, "channels", None) is None:
if cancel_callback and cancel_callback():
raise RemoteAdminCancelled()
if time.monotonic() >= deadline:
raise TimeoutError("Timed out waiting for the remote node's channel list.")
time.sleep(0.1)
def verify_remote_admin(node: object, status_callback=None, cancel_callback=None) -> None:
"""Establish and verify an authorized remote-admin session.
Config reads alone are not a sufficient authorization check. The
Meshtastic admin-session exchange only provides a session passkey when
the remote node accepts this client's administrative credentials.
"""
if status_callback:
status_callback("Checking remote-admin permission…")
_request_remote_with_timeout(node.ensureSessionKey, cancel_callback=cancel_callback)
try:
remote_session = node.iface._getOrCreateByNum(node.nodeNum).get("adminSessionPassKey")
except (AttributeError, TypeError):
remote_session = None
if not remote_session:
raise PermissionError("The selected node did not authorize this admin request.")
def _request_remote_section(
node: object, menu_path: List[str], selected_option: str, status_callback=None, cancel_callback=None
) -> bool:
"""Fetch exactly the remote-admin section the user is opening.
Meshtastic returns radio and module settings one protobuf section at a
time. Asking for all of them on menu entry is both slow and unreliable on
long paths, so leave the top-level menu immediately available.
"""
if menu_path == ["Main Menu"] and selected_option == "Channels":
if status_callback:
status_callback("Requesting channel list…")
_request_remote_channels_with_timeout(node, cancel_callback=cancel_callback)
return True
if len(menu_path) != 2:
return False
category = menu_path[-1]
if category == "Radio Settings":
config = node.localConfig
elif category == "Module Settings":
config = node.moduleConfig
else:
return False
field = config.DESCRIPTOR.fields_by_name.get(selected_option)
if field is None or field.name in {"version", "sessionkey"}:
return False
if status_callback:
status_callback(f"Requesting {selected_option} config…")
_request_remote_with_timeout(node.requestConfig, field, cancel_callback=cancel_callback)
return True
def settings_menu(
stdscr: object,
interface: object,
node: object = None,
remote: bool = False,
status_callback=None,
cancel_callback=None,
) -> None:
curses.update_lines_cols()
global admin_target_label
node = node or interface.localNode
node_num = getattr(node, "nodeNum", 0)
node_info = interface.getMyNodeInfo() if not remote else getattr(interface, "nodesByNum", {}).get(node_num, {})
node_name = node_info.get("user", {}).get("longName") if isinstance(node_info, dict) else None
admin_target_label = node_name or f"!{node_num:08x}"
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote)
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
@@ -366,12 +495,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):
reconnect_required = save_changes(interface, modified_settings, menu_state)
reconnect_required = save_changes(interface, modified_settings, menu_state, node=node)
modified_settings.clear()
logging.info("Changes Saved")
if remote:
break
if reconnect_required:
interface = reconnect_interface_with_splash(stdscr, interface)
menu = generate_menu_from_protobuf(interface)
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()
@@ -401,7 +532,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
filename += ".yaml"
try:
config_text = config_export(interface)
config_text = config_export(interface, node=node)
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
@@ -466,12 +597,12 @@ def settings_menu(stdscr: object, interface: object) -> None:
["Yes", "No"],
)
if overwrite == "Yes":
config_import(interface, file_path)
config_import(interface, file_path, node=node)
menu_state.start_index.pop()
continue
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
current_value = node.getURL()
new_value = get_text_input(
t(
"ui.prompt.config_url_current",
@@ -489,7 +620,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
@@ -500,7 +631,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr, interface, interface.localNode.reboot, "Node Reboot Requested by menu"
stdscr, interface, node.reboot, "Node Reboot Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
@@ -514,7 +645,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr, interface, interface.localNode.resetNodeDb, "Node DB Reset Requested by menu"
stdscr, interface, node.resetNodeDb, "Node DB Reset Requested by menu"
)
menu = rebuild_menu_at_current_path(interface, menu_state)
menu_state.start_index.pop()
@@ -525,7 +656,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
@@ -538,7 +669,27 @@ def settings_menu(stdscr: object, interface: object) -> None:
)
if confirmation == "Yes":
interface = reconnect_after_admin_action(
stdscr, interface, interface.localNode.factoryReset, "Factory Reset Requested by menu"
stdscr,
interface,
lambda: request_factory_reset(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()
@@ -549,12 +700,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_win.refresh()
menu_state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu
open_log_viewer = json_editor(stdscr, menu_state) # Open the App Settings menu
reload_translations()
menu_state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"]
menu_state.start_index.pop()
menu_state.selected_index = 4
if open_log_viewer:
break
continue
field_info = menu_state.current_menu.get(selected_option)
@@ -568,7 +721,25 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option)
if selected_option in ["longName", "shortName", "isLicensed"]:
if field is None and selected_option in ("ringtone", "messages"):
is_ringtone = selected_option == "ringtone"
getter_name = "get_ringtone" if is_ringtone else "get_canned_message"
setter_name = "set_ringtone" if is_ringtone else "set_canned_message"
getter = getattr(node, getter_name, None)
setter = getattr(node, setter_name, None)
fetched_value = getter() if callable(getter) else None
current_value = fetched_value if fetched_value is not None else current_value
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
)
if new_value is not None and new_value != current_value and callable(setter):
setter(new_value)
menu_state.current_menu[selected_option] = (field, new_value)
menu_state.start_index.pop()
menu_state.need_redraw = True
continue
if selected_option in ["longName", "shortName", "isLicensed", "isUnmessagable"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, None
@@ -576,7 +747,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
elif selected_option == "isLicensed":
elif selected_option in ["isLicensed", "isUnmessagable"]:
new_value = get_list_input(
f"{human_readable_name} is currently: {current_value}",
str(current_value),
@@ -676,7 +847,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, cancel_callback
)
finally:
if status_callback:
status_callback(None)
if requested_remote_section:
# The response updates the node's protobufs. Rebuild
# the menu before entering the requested section so
# the newly received values are what the user sees.
menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=False)
refreshed_menu = menu["Main Menu"]
for path_step in menu_state.menu_path[1:]:
refreshed_menu = refreshed_menu[path_step]
menu_state.current_menu = refreshed_menu[selected_option]
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
else:
menu_state.current_menu = menu_state.current_menu[selected_option]
menu_state.menu_path.append(selected_option)
menu_state.menu_index.append(menu_state.selected_index)
menu_state.selected_index = 0
+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:
+30 -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)
@@ -73,34 +76,35 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
"longName": (None, current_user_config.get("longName", "Not Set")),
"shortName": (None, current_user_config.get("shortName", "Not Set")),
"isLicensed": (None, current_user_config.get("isLicensed", "False")),
"isUnmessagable": (None, current_user_config.get("isUnmessagable", "False")),
}
else:
logging.info("User settings not found in Node Info")
menu_structure["Main Menu"]["User Settings"] = "No user settings available"
else:
logging.info("Node Info not available")
menu_structure["Main Menu"]["User Settings"] = "Node Info not available"
menu_structure["Main Menu"]["User Settings"] = {}
# Add Channels
channel = channel_pb2.ChannelSettings()
menu_structure["Main Menu"]["Channels"] = {}
if interface:
if node:
for i in range(8):
current_channel = interface.localNode.getChannelByChannelIndex(i)
current_channel = node.getChannelByChannelIndex(i)
if current_channel:
channel_config = extract_fields(channel, current_channel.settings)
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 +121,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 +150,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():
+10 -5
View File
@@ -1,8 +1,9 @@
import curses
import textwrap
from contact.ui.colors import get_color
def draw_splash(stdscr: object) -> None:
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
@@ -13,16 +14,20 @@ def draw_splash(stdscr: object) -> None:
message_1 = "/ Λ"
message_2 = "/ / \\"
message_3 = "P W R D"
message_4 = "connecting..."
start_x = width // 2 - len(message_1) // 2
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.move(start_y + 5, start_x2)
status_lines = []
for line in status.splitlines():
status_lines.extend(textwrap.wrap(line, max(1, width - 4)) or [""])
for offset, line in enumerate(status_lines):
row = start_y + 4 + offset
if row >= height - 1:
break
stdscr.addstr(row, max(1, (width - len(line)) // 2), line, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
+25 -1
View File
@@ -25,14 +25,38 @@ class ChatUIState:
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
input_focused: bool = False
input_cursor: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
redraw_channels: bool = False
redraw_messages: bool = False
redraw_nodes: bool = False
redraw_packetlog: bool = False
redraw_full_ui: bool = False
scroll_messages_to_bottom: bool = False
preserve_message_selection: bool = False
reconnect_attempted: bool = False
reconnect_prompt_open: bool = False
log_viewer_open: bool = False
log_viewer_follow: bool = True
log_viewer_start_line: int = 0
log_viewer_lines: List[str] = field(default_factory=list)
log_viewer_signature: Any = None
log_viewer_loaded: bool = False
oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict)
has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict)
message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict)
highlighted_message_range: tuple = field(default_factory=tuple)
message_packet_ids: Dict[Union[str, int], List[Any]] = field(default_factory=dict)
reply_id: Any = None
reply_context: str = ""
reply_id_unavailable: bool = False
@dataclass
+69 -14
View File
@@ -9,12 +9,14 @@ from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.utilities.ini_utils import parse_ini_file
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.utilities.singleton import menu_state
from contact.utilities.singleton import menu_state, ui_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
save_option = "Save Changes"
VIEW_LOG_OPTION = "__view_log__"
LOG_VIEWER_LINE_LIMIT = 500
translation_file = config.get_localisation_file(config.language)
field_mapping, help_text = parse_ini_file(translation_file)
translation_language = config.language
@@ -86,6 +88,32 @@ def get_effective_width() -> int:
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def load_log_tail(path: str, max_lines: int = LOG_VIEWER_LINE_LIMIT) -> List[str]:
"""Read the final log lines without loading an arbitrarily large log file."""
if max_lines <= 0:
return []
try:
with open(path, "rb") as log_file:
log_file.seek(0, os.SEEK_END)
position = log_file.tell()
chunks = []
newline_count = 0
while position > 0 and newline_count <= max_lines:
read_size = min(8192, position)
position -= read_size
log_file.seek(position)
chunk = log_file.read(read_size)
chunks.append(chunk)
newline_count += chunk.count(b"\n")
data = b"".join(reversed(chunks)).decode("utf-8", errors="replace")
lines = data.splitlines()
if position > 0 and lines:
lines = lines[1:]
return lines[-max_lines:]
except OSError as exc:
return [f"Unable to read log: {exc}"]
def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
@@ -115,6 +143,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 +215,6 @@ def edit_value(key: str, display_label: str, current_value: str) -> str:
sort_options = ["lastHeard", "name", "hops"]
return get_list_input(display_label, current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input(display_label, current_value, sound_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, t("ui.label.new_value", default="New Value: "), get_color("settings_default"))
curses.curs_set(1)
@@ -264,6 +301,8 @@ def display_menu() -> tuple[Any, Any, List[str]]:
# Determine menu items based on the type of current_menu
if isinstance(menu_state.current_menu, dict):
options = list(menu_state.current_menu.keys())
if len(menu_state.menu_path) <= 2:
options.insert(0, VIEW_LOG_OPTION)
elif isinstance(menu_state.current_menu, list):
options = [f"[{i}]" for i in range(len(menu_state.current_menu))]
else:
@@ -302,14 +341,20 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_state.current_menu[key]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
) if key != VIEW_LOG_OPTION else ""
if isinstance(menu_state.current_menu, dict):
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
if key == VIEW_LOG_OPTION:
display_key = field_mapping.get("app_settings.view_log", "View Log")
else:
full_key = get_app_settings_key(menu_state.menu_path, key)
display_key = lookup_app_settings_label(full_key, key)
else:
display_key = key
display_key = f"{display_key}"[: w // 2 - 2]
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)
@@ -379,7 +424,7 @@ def update_app_settings_help(menu_win: curses.window, options: List[str]) -> Non
)
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
def json_editor(stdscr: curses.window, menu_state: Any) -> bool:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
@@ -458,6 +503,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
if selected_key == VIEW_LOG_OPTION:
ui_state.log_viewer_open = True
ui_state.log_viewer_follow = True
ui_state.log_viewer_start_line = 0
ui_state.log_viewer_lines = []
ui_state.log_viewer_signature = None
ui_state.log_viewer_loaded = False
return True
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index)
@@ -561,6 +614,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
break
return False
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
+28 -23
View File
@@ -134,47 +134,47 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename):
def config_import(interface, filename, node=None):
"""Apply a configuration file to the selected local or remote node."""
node = node or interface.localNode
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
interface.getNode("^local", False).beginSettingsTransaction()
node.beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(configuration["owner"])
node.setOwner(configuration["owner"])
time.sleep(0.5)
if "owner_short" in configuration:
logging.info(f"Setting device owner short to {configuration['owner_short']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
node.setOwner(long_name=None, short_name=configuration["owner_short"])
time.sleep(0.5)
if "ownerShort" in configuration:
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
node.setOwner(long_name=None, short_name=configuration["ownerShort"])
time.sleep(0.5)
if "channel_url" in configuration:
logging.info(f"Setting channel url to {configuration['channel_url']}")
interface.getNode("^local").setURL(configuration["channel_url"])
node.setURL(configuration["channel_url"])
time.sleep(0.5)
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode("^local").setURL(configuration["channelUrl"])
node.setURL(configuration["channelUrl"])
time.sleep(0.5)
if "location" in configuration:
alt = 0
lat = 0.0
lon = 0.0
localConfig = interface.localNode.localConfig
if "alt" in configuration["location"]:
alt = int(configuration["location"]["alt"] or 0)
logging.info(f"Fixing altitude at {alt} meters")
@@ -185,39 +185,44 @@ def config_import(interface, filename):
lon = float(configuration["location"]["lon"] or 0)
logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position")
interface.localNode.setFixedPosition(lat, lon, alt)
node.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
if "config" in configuration:
localConfig = interface.getNode("^local").localConfig
localConfig = node.localConfig
for section in configuration["config"]:
traverseConfig(section, configuration["config"][section], localConfig)
interface.getNode("^local").writeConfig(camel_to_snake(section))
node.writeConfig(camel_to_snake(section))
time.sleep(0.5)
if "module_config" in configuration:
moduleConfig = interface.getNode("^local").moduleConfig
moduleConfig = node.moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
node.writeConfig(camel_to_snake(section))
time.sleep(0.5)
interface.getNode("^local", False).commitSettingsTransaction()
node.commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface) -> str:
"""used in --export-config"""
def config_export(interface, node=None) -> str:
"""Export the selected node's configuration as Meshtastic configure YAML."""
configObj = {}
node = node or interface.localNode
owner = interface.getLongName()
owner_short = interface.getShortName()
channel_url = interface.localNode.getURL()
myinfo = interface.getMyNodeInfo()
if node is interface.localNode:
myinfo = interface.getMyNodeInfo()
else:
myinfo = getattr(interface, "nodesByNum", {}).get(getattr(node, "nodeNum", None), {})
user = myinfo.get("user", {}) if isinstance(myinfo, dict) else {}
owner = user.get("longName")
owner_short = user.get("shortName")
channel_url = node.getURL()
pos = myinfo.get("position")
lat = None
lon = None
@@ -242,7 +247,7 @@ def config_export(interface) -> str:
if alt:
configObj["location"]["alt"] = alt
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below
config = MessageToDict(node.localConfig) # checkme - Used as a dictionary here and a string below
if config:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -265,7 +270,7 @@ def config_export(interface) -> str:
else:
configObj["config"] = config
module_config = MessageToDict(interface.localNode.moduleConfig)
module_config = MessageToDict(node.moduleConfig)
if module_config:
# Convert inner keys to correct snake/camelCase
prefs = {}
+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}"'
+2 -1
View File
@@ -141,7 +141,8 @@ def seed_demo_messages() -> None:
ack_type TEXT
"""
with sqlite3.connect(config.db_file_path) as db_connection:
with sqlite3.connect(config.db_file_path, timeout=10.0) as db_connection:
db_connection.execute("PRAGMA busy_timeout=10000")
cursor = db_connection.cursor()
for channel_name, rows in _demo_messages().items():
+49 -5
View File
@@ -1,9 +1,33 @@
import logging
import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
def initialize_interface(args):
def interface_is_connected(interface) -> bool:
"""Return the transport-independent connection state for an interface.
TCPInterface deliberately leaves ``stream`` set to ``None`` and uses its
``socket`` instead. Checking only ``stream`` therefore treats every TCP
connection (including meshtasticd) as disconnected.
"""
if interface is None:
return False
connected = getattr(interface, "isConnected", None)
if hasattr(connected, "is_set"):
return connected.is_set()
if isinstance(connected, bool):
return connected
if hasattr(interface, "socket"):
return getattr(interface, "socket", None) is not None
if hasattr(interface, "stream"):
return getattr(interface, "stream", None) is not None
return getattr(interface, "localNode", None) is not None
def initialize_interface(args, status_callback=None):
try:
if args.ble:
@@ -21,7 +45,10 @@ def initialize_interface(args):
logging.error(f"Error connecting to {args.host}. {ex}")
else:
try:
client = meshtastic.serial_interface.SerialInterface(args.port)
# The library prints its fallback notice directly to stdout,
# which would overwrite the curses splash screen.
with contextlib.redirect_stdout(io.StringIO()):
client = meshtastic.serial_interface.SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
@@ -34,6 +61,8 @@ def initialize_interface(args):
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
if client.devPath is None:
try:
if status_callback:
status_callback("No serial device found.\nConnecting via TCP to localhost…")
client = meshtastic.tcp_interface.TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
@@ -44,19 +73,34 @@ def initialize_interface(args):
logging.critical(f"Fatal error initializing interface: {ex}")
def reconnect_interface(args, attempts: int = 15, delay_seconds: float = 1.0):
def reconnect_interface(args, attempts: int = 20, delay_seconds: float = 1.0):
last_error = None
target = getattr(args, "host", None) or getattr(args, "port", None) or getattr(args, "ble", None) or "auto"
for attempt in range(attempts):
attempt_number = attempt + 1
logging.info("Reconnect attempt %d/%d to %s", attempt_number, attempts, target)
try:
interface = initialize_interface(args)
if interface is not None:
if interface_is_connected(interface) and getattr(interface, "localNode", None) is not None and getattr(
interface.localNode, "localConfig", None
) is not None:
node_num = getattr(interface.localNode, "nodeNum", None)
node_label = f" !{node_num:08x}" if isinstance(node_num, int) else ""
logging.info("Reconnected to Meshtastic node%s on attempt %d/%d", node_label, attempt_number, attempts)
return interface
last_error = RuntimeError("initialize_interface returned None")
last_error = RuntimeError("interface did not complete connection setup")
try:
interface.close()
except Exception:
pass
except Exception as ex:
last_error = ex
logging.warning("Reconnect attempt %d/%d failed: %s", attempt_number, attempts, last_error)
if attempt < attempts - 1:
time.sleep(delay_seconds)
logging.error("Unable to reconnect to Meshtastic node after %d attempts", attempts)
raise RuntimeError("Failed to reconnect to the Meshtastic node") from last_error
+24 -26
View File
@@ -2,7 +2,6 @@ from meshtastic.protobuf import channel_pb2
from google.protobuf.message import Message
import logging
import base64
import time
DEVICE_REBOOT_KEYS = {"button_gpio", "buzzer_gpio", "role", "rebroadcast_mode"}
POWER_REBOOT_KEYS = {
@@ -29,7 +28,7 @@ LORA_REBOOT_KEYS = {
"sx126x_rx_boosted_gain",
}
SECURITY_NON_REBOOT_KEYS = {"debug_log_api_enabled", "serial_enabled"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed"}
USER_RECONNECT_KEYS = {"longName", "shortName", "isLicensed", "is_licensed", "isUnmessagable", "is_unmessagable"}
def _collect_changed_keys(modified_settings):
@@ -78,7 +77,7 @@ def _requires_reconnect(menu_state, modified_settings) -> bool:
return True
def save_changes(interface, modified_settings, menu_state):
def save_changes(interface, modified_settings, menu_state, node=None):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -90,7 +89,7 @@ def save_changes(interface, modified_settings, menu_state):
logging.info("No changes to save. modified_settings is empty.")
return False
node = interface.getNode("^local")
node = node or interface.getNode("^local")
admin_key_backup = None
if "admin_key" in modified_settings:
# Get reference to security config
@@ -100,22 +99,14 @@ def save_changes(interface, modified_settings, menu_state):
# Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""]
if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.")
else:
# Clear existing keys if needed
if security_config.admin_key:
logging.info("Clearing existing admin keys...")
del security_config.admin_key[:]
node.writeConfig("security")
time.sleep(2) # Give time for device to process
# Append new keys
for key in valid_keys:
logging.info(f"Adding admin key: {key}")
security_config.admin_key.append(key)
node.writeConfig("security")
logging.info("Admin keys updated successfully!")
# Update the repeated field in memory, then send one complete
# security config. Sending an empty list first can revoke the key
# authorizing this remote-admin session before a second write can
# restore the remaining keys.
del security_config.admin_key[:]
security_config.admin_key.extend(valid_keys)
node.writeConfig("security")
logging.info("Admin keys updated successfully (%d key(s)).", len(valid_keys))
# Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None)
@@ -134,7 +125,7 @@ def save_changes(interface, modified_settings, menu_state):
lon = float(modified_settings.get("longitude", 0.0))
alt = int(modified_settings.get("altitude", 0))
interface.localNode.setFixedPosition(lat, lon, alt)
node.setFixedPosition(lat, lon, alt)
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return False
@@ -143,12 +134,16 @@ def save_changes(interface, modified_settings, menu_state):
long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName")
is_licensed = modified_settings.get("isLicensed")
is_unmessagable = modified_settings.get("isUnmessagable")
is_licensed = is_licensed == "True" or is_licensed is True # Normalize boolean
if is_unmessagable is not None:
is_unmessagable = is_unmessagable == "True" or is_unmessagable is True
node.setOwner(long_name, short_name, is_licensed)
node.setOwner(long_name, short_name, is_licensed, is_unmessagable)
logging.info(
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, "
f"Licensed Mode: {is_licensed}, Unmessageable: {is_unmessagable}"
)
return _requires_reconnect(menu_state, modified_settings)
@@ -157,7 +152,8 @@ def save_changes(interface, modified_settings, menu_state):
config_category = "Channels"
try:
channel = menu_state.menu_path[-1]
channels_index = menu_state.menu_path.index("Channels")
channel = menu_state.menu_path[channels_index + 1]
channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e:
channel_num = None
@@ -166,8 +162,10 @@ def save_changes(interface, modified_settings, menu_state):
for key, value in modified_settings.items():
if key == "psk": # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value)
elif key == "position_precision": # Special case: module_settings
channel.settings.module_settings.position_precision = value
elif key in {"position_precision", "is_muted"}:
# These fields belong to ChannelSettings.module_settings,
# not ChannelSettings itself.
setattr(channel.settings.module_settings, key, value)
else:
setattr(channel.settings, key, value) # Use setattr for other fields
+78 -4
View File
@@ -1,5 +1,8 @@
import datetime
import logging
import re
import time
from functools import lru_cache
from typing import Optional, Union
from google.protobuf.message import DecodeError
@@ -9,14 +12,41 @@ import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
import contact.utilities.telemetry_beautifier as tb
NEW_MODEM_PRESET_NAMES = {
14: "TINY_FAST",
15: "TINY_SLOW",
16: "MEDIUM_TURBO",
}
@lru_cache(maxsize=None)
def _get_modem_preset_name(modem_preset_enum: int) -> str:
try:
return config_pb2.Config.LoRaConfig.ModemPreset.Name(modem_preset_enum)
except ValueError:
compatibility_name = NEW_MODEM_PRESET_NAMES.get(modem_preset_enum)
if compatibility_name is not None:
logging.warning(
"Modem preset %s is newer than the installed Meshtastic protobuf schema; "
"using compatibility name %s",
modem_preset_enum,
compatibility_name,
)
return compatibility_name
logging.warning(
"Modem preset %s is not recognized by the installed Meshtastic protobuf schema",
modem_preset_enum,
)
return f"UNKNOWN_PRESET_{modem_preset_enum}"
def _get_channel_name(device_channel, node):
if device_channel.settings.name:
return device_channel.settings.name
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_enum = node.localConfig.lora.modem_preset
modem_preset_string = _get_modem_preset_name(modem_preset_enum)
return convert_to_camel_case(modem_preset_string)
@@ -156,10 +186,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")
@@ -180,10 +252,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.5.1"
version = "1.6.9"
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"}
+64
View File
@@ -0,0 +1,64 @@
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, "get_reply_context", return_value="<Re: NODE: ping> "):
with mock.patch.object(self.bot_handler.threading, "Thread") as thread:
self.assertTrue(self.bot_handler.bot_respond(packet, "ping", 0))
send_response = thread.call_args.kwargs["target"]
with mock.patch.object(self.bot_handler.time, "sleep"):
with mock.patch.dict(
sys.modules,
{"contact.ui.contact_ui": types.SimpleNamespace(request_ui_redraw=mock.Mock())},
):
send_response()
self.bot_handler.send_message.assert_called_once_with(
"Pong!", channel=0, reply_id=900, reply_context="<Re: NODE: ping> "
)
def test_bot_response_requires_enabled_app_setting(self) -> None:
with mock.patch.object(config, "ping_bot_enabled", "False"):
self.assertFalse(self.bot_handler.bot_respond({"from": 222}, "ping", 0))
if __name__ == "__main__":
unittest.main()
+46 -1
View File
@@ -1,6 +1,12 @@
import unittest
from types import SimpleNamespace
from tempfile import NamedTemporaryFile
from unittest.mock import Mock
from contact.utilities.config_io import _is_repeated_field, splitCompoundName
import yaml
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.utilities.config_io import _is_repeated_field, config_export, config_import, splitCompoundName
class ConfigIoTests(unittest.TestCase):
@@ -19,3 +25,42 @@ class ConfigIoTests(unittest.TestCase):
field_type = type("Field", (), {"label": 3, "LABEL_REPEATED": 3})
self.assertTrue(_is_repeated_field(field_type()))
def test_config_export_uses_selected_remote_node(self) -> None:
local_node = SimpleNamespace(nodeNum=1)
remote_node = SimpleNamespace(
nodeNum=2,
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getURL=lambda: "remote-channel-url",
)
interface = SimpleNamespace(
localNode=local_node,
nodesByNum={
2: {
"user": {"longName": "Remote Node", "shortName": "REM"},
"position": {"latitude": 1.25, "longitude": 2.5, "altitude": 12},
}
},
)
exported = yaml.safe_load(config_export(interface, node=remote_node))
self.assertEqual(exported["owner"], "Remote Node")
self.assertEqual(exported["owner_short"], "REM")
self.assertEqual(exported["channel_url"], "remote-channel-url")
self.assertEqual(exported["location"], {"lat": 1.25, "lon": 2.5, "alt": 12})
def test_config_import_uses_selected_remote_node(self) -> None:
remote_node = Mock()
interface = SimpleNamespace(localNode=Mock())
with NamedTemporaryFile("w", suffix=".yaml") as config_file:
yaml.safe_dump({"owner": "Remote Node"}, config_file)
config_file.flush()
config_import(interface, config_file.name, node=remote_node)
remote_node.beginSettingsTransaction.assert_called_once_with()
remote_node.setOwner.assert_called_once_with("Remote Node")
remote_node.commitSettingsTransaction.assert_called_once_with()
interface.localNode.setOwner.assert_not_called()
+579
View File
@@ -1,14 +1,63 @@
import unittest
from contextlib import ExitStack
from types import SimpleNamespace
from unittest import mock
import contact.ui.default_config as config
from contact.ui import contact_ui
from contact.ui.nav_utils import text_width
from contact.utilities.singleton import ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
class ContactUiTests(unittest.TestCase):
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
ui_state.node_list = [101, 102]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
102: {"user": {"shortName": "SMALL", "longName": "B"}},
})
win = mock.Mock()
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
events = mock.Mock()
events.attach_mock(win.noutrefresh, "dialog")
stdscr = mock.Mock()
with ExitStack() as stack:
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
for name, value in (("LINES", 40), ("COLS", 100)):
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("get_color", "refresh_node_selection", "handle_resize"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
events.attach_mock(restore, "restore")
contact_ui.handle_f5_key(stdscr)
self.assertEqual(events.mock_calls, [
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
mock.call.restore(stdscr, 2), mock.call.dialog(),
])
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
self.assertEqual(win.refresh.call_count, 1) # Only on close.
def test_background_restore_stages_panes_without_flushing(self):
ui_state.current_window = 4
ui_state.single_pane_mode = True
stdscr = mock.Mock()
with ExitStack() as stack:
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
contact_ui.stage_node_details_background(stdscr, 2)
refresh.assert_called_once_with(2, commit=False)
update.assert_not_called()
stdscr.noutrefresh.assert_called_once()
self.assertEqual(ui_state.current_window, 4)
def setUp(self) -> None:
reset_singletons()
self.saved_config = snapshot_config("single_pane_mode")
@@ -36,3 +85,533 @@ class ContactUiTests(unittest.TestCase):
self.assertEqual(curs_set.call_args_list[0].args, (0,))
self.assertEqual(curs_set.call_args_list[-1].args, (1,))
self.assertEqual(ui_state.current_window, 1)
def test_handle_backtick_shows_error_when_remote_admin_is_rejected(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 2
ui_state.node_list = [123]
ui_state.selected_node = 0
contact_ui.interface_state.interface = mock.Mock()
contact_ui.interface_state.interface.getNode.return_value = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "get_channels"):
with mock.patch.object(contact_ui, "refresh_node_list"):
with mock.patch.object(contact_ui, "handle_resize"):
with mock.patch.object(contact_ui, "get_list_input", return_value="Remote admin: node"):
with mock.patch.object(contact_ui, "get_name_from_database", return_value="node"):
with mock.patch.object(contact_ui, "verify_remote_admin", side_effect=SystemExit(1)):
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch("contact.ui.dialog.dialog") as dialog:
contact_ui.handle_backtick(stdscr)
dialog.assert_called_once_with(
"Remote admin rejected",
"The selected node did not authorize this admin request.",
)
settings_menu.assert_not_called()
def test_handle_backtick_opens_local_settings_for_selected_local_node(self) -> None:
stdscr = mock.Mock()
ui_state.current_window = 2
ui_state.node_list = [123]
ui_state.selected_node = 0
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "get_list_input") as chooser:
with mock.patch.object(contact_ui, "settings_menu") as settings_menu:
with mock.patch.object(contact_ui, "get_channels"):
with mock.patch.object(contact_ui, "refresh_node_list"):
with mock.patch.object(contact_ui, "handle_resize"):
contact_ui.handle_backtick(stdscr)
chooser.assert_not_called()
settings_menu.assert_called_once_with(stdscr, contact_ui.interface_state.interface)
def test_show_remote_admin_wait_draws_status(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 100)
wait_win = mock.Mock()
with mock.patch.object(contact_ui.curses, "newwin", return_value=wait_win):
result = contact_ui.show_remote_admin_wait(stdscr, "Remote")
self.assertIs(result, wait_win)
self.assertIn("Attempting remote admin of Remote", wait_win.addstr.call_args_list[-1].args[2])
wait_win.refresh.assert_called_once()
def test_update_remote_admin_wait_replaces_status_line(self) -> None:
wait_win = mock.Mock()
wait_win.getmaxyx.return_value = (5, 40)
contact_ui.update_remote_admin_wait(wait_win, "Requesting lora config…")
self.assertIn("Requesting lora config", wait_win.addstr.call_args_list[-1].args[2])
def test_process_pending_ui_updates_draws_requested_windows(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_channels = True
ui_state.redraw_messages = True
ui_state.redraw_nodes = True
ui_state.redraw_packetlog = True
ui_state.scroll_messages_to_bottom = True
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_packetlog_win") as draw_packetlog_win:
contact_ui.process_pending_ui_updates(stdscr)
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_packetlog_win.assert_called_once_with()
def test_process_pending_ui_updates_full_redraw_uses_handle_resize(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_full_ui = True
ui_state.redraw_channels = True
ui_state.redraw_messages = True
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.process_pending_ui_updates(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_process_pending_ui_updates_preserves_selection_when_scrolling_to_new_messages(self) -> None:
stdscr = mock.Mock()
ui_state.redraw_messages = True
ui_state.scroll_messages_to_bottom = True
ui_state.preserve_message_selection = True
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
contact_ui.process_pending_ui_updates(stdscr)
draw_messages_window.assert_called_once_with(True, preserve_selection=True)
self.assertFalse(ui_state.preserve_message_selection)
def test_draw_messages_resizes_pad_once(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[10:00] RX: ", "one"), ("[10:01] RX: ", "two")]}
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (2, 40)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 40)
contact_ui.messages_win.getbegyx.return_value = (0, 0)
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_packetlog_win"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_messages_window()
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> [6] B1G1: ",
"This is a message long enough to be shortened for the reply marker.",
)
self.assertEqual(reply, "<Re: B1G1: This > ")
def test_build_reply_prefix_uses_me_for_an_outgoing_message(self) -> None:
contact_ui.interface_state.myNodeNum = 123
contact_ui.interface_state.interface = SimpleNamespace(
nodes={"!0000007b": {"num": 123, "user": {"shortName": "LRY"}}}
)
reply = contact_ui.build_reply_prefix("[06:27:25] >> Sent[☼]: ", "Thanks Lairy")
self.assertEqual(reply, "<Re: LRY: Thank> ")
def test_build_reply_prefix_does_not_nest_an_existing_reply_marker(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> xT1e: ",
"<Re: CATS: Woot!> Woot indeed",
)
self.assertEqual(reply, "<Re: xT1e: Woot > ")
def test_handle_ctrl_r_prefills_reply_for_message_at_cursor(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.message_packet_ids = {"Primary": [1234]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "Same to you!")
self.assertEqual(ui_state.reply_id, 1234)
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
def test_handle_ctrl_r_clears_an_existing_reply(self) -> None:
ui_state.current_window = 1
ui_state.reply_id = 1234
ui_state.reply_context = "<Re: B1G1: Good > "
ui_state.reply_id_unavailable = False
self.assertEqual(contact_ui.handle_ctrl_r("Same to you!"), "")
self.assertIsNone(ui_state.reply_id)
self.assertEqual(ui_state.reply_context, "")
self.assertFalse(ui_state.reply_id_unavailable)
def test_draw_text_field_clears_stale_reply_context(self) -> None:
win = mock.Mock()
win.getmaxyx.return_value = (3, 30)
contact_ui.draw_text_field(win, "Message: ", 1)
self.assertIn(mock.call(1, 1, " " * 28, 1), win.addstr.call_args_list)
self.assertIn(mock.call(1, 1, "Message: ", 1), win.addstr.call_args_list)
def test_handle_ctrl_r_shows_context_when_message_id_is_unavailable(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.all_messages = {"Primary": [("[06:27:25] >> [6] B1G1: ", "Good morning all.")]}
ui_state.selected_message = 0
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 80)
self.assertEqual(contact_ui.handle_ctrl_r(""), "")
self.assertEqual(ui_state.reply_context, "<Re: B1G1: Good > ")
self.assertTrue(ui_state.reply_id_unavailable)
def test_refresh_message_highlight_marks_selected_message(self) -> None:
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 2, 10), (2, 3, 20)]}
ui_state.selected_message = 2
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20 | contact_ui.curses.A_REVERSE)
def test_refresh_message_highlight_clears_when_messages_pane_is_not_active(self) -> None:
ui_state.current_window = 0
ui_state.highlighted_message_range = (2, 3, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (10, 40)
contact_ui.refresh_message_highlight()
contact_ui.messages_pad.chgat.assert_called_once_with(2, 1, 38, 20)
self.assertEqual(ui_state.highlighted_message_range, ())
def test_handle_end_moves_messages_viewport_to_bottom(self) -> None:
ui_state.current_window = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
with mock.patch.object(contact_ui, "refresh_message_highlight"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.handle_end()
self.assertEqual(ui_state.selected_message, 99)
self.assertGreater(ui_state.start_index[1], 0)
def test_message_selection_can_reach_last_rendered_line(self) -> None:
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (100, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.set_message_selection(99)
self.assertEqual(ui_state.selected_message, 99)
self.assertEqual(ui_state.start_index[1], 90)
def test_switching_to_messages_redraws_at_bottom(self) -> None:
ui_state.current_window = 0
ui_state.single_pane_mode = False
with mock.patch.object(contact_ui, "refresh_main_window"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "refresh_message_highlight"):
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
draw_messages_window.assert_called_once_with(True)
def test_move_message_selection_skips_all_lines_of_wrapped_message(self) -> None:
ui_state.channel_list = ["Primary"]
ui_state.message_line_ranges = {"Primary": [(0, 3, 10), (3, 4, 20), (4, 6, 30)]}
ui_state.selected_message = 1
ui_state.start_index = [0, 0, 0]
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (6, 80)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getmaxyx.return_value = (12, 80)
contact_ui.packetlog_win = mock.Mock()
contact_ui.packetlog_win.getmaxyx.return_value = (1, 80)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 3)
contact_ui.move_message_selection(1)
self.assertEqual(ui_state.selected_message, 5)
contact_ui.move_message_selection(-1)
self.assertEqual(ui_state.selected_message, 3)
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
ui_state.start_index = [0, 0, 0]
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_pad.getmaxyx.return_value = (4, 20)
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
interface = mock.Mock()
interface.nodesByNum = {101: {}, 202: {}}
with mock.patch.object(contact_ui, "refresh_pad") as refresh_pad:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
with mock.patch.object(contact_ui, "get_node_row_color", side_effect=[11, 22]):
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
contact_ui.refresh_node_selection(old_index=0, highlight=True)
self.assertEqual(
contact_ui.nodes_pad.chgat.call_args_list,
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
refresh_pad.assert_called_once_with(2)
draw_window_arrows.assert_called_once_with(2)
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
ui_state.channel_list = ["VeryLongChannelName"]
ui_state.notifications = []
ui_state.selected_channel = 0
ui_state.current_window = 0
contact_ui.channel_pad = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_color", return_value=1):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "remove_notification"):
contact_ui.draw_channel_list()
text = contact_ui.channel_pad.addstr.call_args.args[2]
self.assertEqual(len(text), 16)
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101]
ui_state.current_window = 2
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
contact_ui.entry_win = mock.Mock()
interface = mock.Mock()
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_node_list()
text = contact_ui.nodes_pad.addstr.call_args.args[2]
self.assertEqual(text_width(text), 16)
self.assertIn("…", text)
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 80)
ui_state.single_pane_mode = True
ui_state.current_window = 1
contact_ui.entry_win = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.messages_win = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.packetlog_win = mock.Mock()
contact_ui.messages_pad = mock.Mock()
contact_ui.nodes_pad = mock.Mock()
contact_ui.channel_pad = mock.Mock()
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list:
with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window:
with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list:
with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows:
contact_ui.handle_resize(stdscr, False)
contact_ui.channel_win.resize.assert_called_once_with(21, 80)
contact_ui.messages_win.resize.assert_called_once_with(21, 80)
contact_ui.nodes_win.resize.assert_called_once_with(21, 80)
contact_ui.channel_win.mvwin.assert_called_once_with(0, 0)
contact_ui.messages_win.mvwin.assert_called_once_with(0, 0)
contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0)
contact_ui.channel_win.box.assert_not_called()
contact_ui.nodes_win.box.assert_not_called()
contact_ui.messages_win.box.assert_called_once_with()
draw_channel_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_node_list.assert_called_once_with()
draw_window_arrows.assert_called_once_with(1)
def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None:
ui_state.single_pane_mode = True
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
self.assertEqual(contact_ui.get_window_title(0), "")
self.assertEqual(contact_ui.get_window_title(1), "Primary")
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
self.addCleanup(mock.patch.stopall)
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
ui_state.single_pane_mode = True
ui_state.current_window = 1
ui_state.channel_list = ["Primary"]
ui_state.selected_channel = 0
ui_state.start_index = [0, 0, 0]
ui_state.display_log = False
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
contact_ui.messages_pad = mock.Mock()
contact_ui.messages_pad.getmaxyx.return_value = (5, 20)
contact_ui.messages_win = mock.Mock()
contact_ui.messages_win.getbegyx.return_value = (0, 0)
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
with mock.patch.object(contact_ui.curses, "doupdate") as update:
contact_ui.refresh_pad(1)
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
contact_ui.messages_win.refresh.assert_not_called()
contact_ui.messages_pad.refresh.assert_not_called()
contact_ui.messages_win.noutrefresh.assert_called_once()
contact_ui.messages_pad.touchwin.assert_called_once()
contact_ui.messages_pad.noutrefresh.assert_called_once()
update.assert_called_once()
def test_search_ignores_no_input_from_curses(self) -> None:
ui_state.node_list = [101]
ui_state.selected_node = 0
contact_ui.entry_win = mock.Mock()
contact_ui.entry_win.get_wch.side_effect = contact_ui.curses.error("no input")
with mock.patch.object(contact_ui, "draw_centered_text_field"):
with mock.patch.object(contact_ui, "get_color", return_value=0):
contact_ui.search(2)
contact_ui.entry_win.timeout.assert_has_calls([mock.call(-1), mock.call(200)])
contact_ui.entry_win.erase.assert_called()
def test_f5_node_details_ignores_no_input_from_curses(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.side_effect = [contact_ui.curses.error("no input"), 27]
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
self.assertEqual(dialog_win.getch.call_count, 2)
# Idle polling must never present an erased dialog before its text.
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
handle_resize.assert_called_once_with(stdscr, False)
def test_f5_node_details_tolerates_none_metrics(self) -> None:
stdscr = mock.Mock()
ui_state.node_list = [101]
ui_state.selected_node = 0
ui_state.current_window = 2
dialog_win = mock.Mock()
dialog_win.getch.return_value = 27
msg_win = mock.Mock()
dialog_win.derwin.return_value = msg_win
interface = mock.Mock()
interface.nodesByNum = {
101: {
"num": 101,
"snr": None,
"hopsAway": None,
"deviceMetrics": {
"batteryLevel": None,
"channelUtilization": None,
"airUtilTx": None,
"uptimeSeconds": None,
},
"user": {
"longName": "Test Node",
"shortName": "TN",
"hwModel": "T-Beam",
"role": "CLIENT",
"publicKey": "abc",
},
}
}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui.curses, "LINES", 24, create=True):
with mock.patch.object(contact_ui.curses, "COLS", 80, create=True):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui.curses, "update_lines_cols"):
with mock.patch.object(contact_ui.curses, "doupdate"):
with mock.patch.object(contact_ui.curses, "newwin", return_value=dialog_win):
with mock.patch.object(contact_ui, "get_color", return_value=0):
with mock.patch.object(contact_ui, "refresh_node_selection"):
with mock.patch.object(contact_ui, "handle_resize") as handle_resize:
contact_ui.handle_f5_key(stdscr)
handle_resize.assert_called_once_with(stdscr, False)
+73
View File
@@ -1,4 +1,5 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
@@ -52,6 +53,32 @@ class ControlUiTests(unittest.TestCase):
reconnect.assert_called_once_with(stdscr, interface)
self.assertIs(result, new_interface)
def test_verify_remote_admin_establishes_an_authorized_session(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={"adminSessionPassKey": b"key"})),
)
status_callback = mock.Mock()
with mock.patch.object(control_ui, "_request_remote_with_timeout") as request:
control_ui.verify_remote_admin(node, status_callback=status_callback)
status_callback.assert_called_once_with("Checking remote-admin permission…")
request.assert_called_once_with(node.ensureSessionKey, cancel_callback=None)
node.iface._getOrCreateByNum.assert_called_once_with(123)
def test_verify_remote_admin_rejects_missing_session_key(self) -> None:
node = SimpleNamespace(
nodeNum=123,
ensureSessionKey=mock.Mock(),
iface=SimpleNamespace(_getOrCreateByNum=mock.Mock(return_value={})),
)
with mock.patch.object(control_ui, "_request_remote_with_timeout"):
with self.assertRaises(PermissionError):
control_ui.verify_remote_admin(node)
def test_redraw_main_ui_after_reconnect_refreshes_channels_nodes_and_layout(self) -> None:
stdscr = mock.Mock()
@@ -63,3 +90,49 @@ class ControlUiTests(unittest.TestCase):
get_channels.assert_called_once_with()
refresh_node_list.assert_called_once_with()
handle_resize.assert_called_once_with(stdscr, False)
def test_request_factory_reset_uses_library_helper_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node)
node.factoryReset.assert_called_once_with(full=False)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_uses_library_helper_for_full_reset_when_supported(self) -> None:
node = mock.Mock()
control_ui.request_factory_reset(node, full=True)
node.factoryReset.assert_called_once_with(full=True)
node.ensureSessionKey.assert_not_called()
node._sendAdmin.assert_not_called()
def test_request_factory_reset_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_config: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_config, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
def test_request_factory_reset_full_falls_back_to_int_valued_admin_message(self) -> None:
node = mock.Mock()
node.factoryReset.side_effect = TypeError(
"Field meshtastic.protobuf.AdminMessage.factory_reset_device: Expected an int, got a boolean."
)
node.iface = SimpleNamespace(localNode=node)
control_ui.request_factory_reset(node, full=True)
node.ensureSessionKey.assert_called_once_with()
sent_message = node._sendAdmin.call_args.args[0]
self.assertEqual(sent_message.factory_reset_device, 1)
self.assertIsNone(node._sendAdmin.call_args.kwargs["onResponse"])
+85
View File
@@ -45,6 +45,38 @@ class DbHandlerTests(unittest.TestCase):
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")
@@ -112,6 +144,59 @@ class DbHandlerTests(unittest.TestCase):
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
+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()
+13
View File
@@ -5,6 +5,7 @@ from unittest import mock
import contact.ui.default_config as config
from contact.utilities import i18n
from contact.utilities.ini_utils import parse_ini_file
from tests.test_support import restore_config, snapshot_config
@@ -55,3 +56,15 @@ class I18nTests(unittest.TestCase):
config.language = "ru"
self.assertEqual(i18n.t("missing", default="fallback"), "fallback")
self.assertEqual(parse_ini_file.call_count, 2)
def test_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}")
+9 -3
View File
@@ -1,4 +1,5 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
@@ -9,13 +10,18 @@ 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:
ready_interface = SimpleNamespace(localNode=SimpleNamespace(localConfig=object(), nodeNum=123))
with mock.patch(
"contact.utilities.interfaces.initialize_interface", side_effect=[None, None, ready_interface]
) as initialize:
with mock.patch("contact.utilities.interfaces.time.sleep") as sleep:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
with mock.patch("contact.utilities.interfaces.logging.info") as info:
result = reconnect_interface(args, attempts=3, delay_seconds=0.25)
self.assertEqual(result, "iface")
self.assertIs(result, ready_interface)
self.assertEqual(initialize.call_count, 3)
self.assertEqual(sleep.call_count, 2)
self.assertTrue(any("Reconnected to Meshtastic node" in call.args[0] for call in info.call_args_list))
def test_reconnect_interface_raises_after_exhausting_attempts(self) -> None:
args = Namespace()
+26 -2
View File
@@ -186,6 +186,15 @@ class MainRuntimeTests(unittest.TestCase):
wrapper.assert_called_once_with(entrypoint.main)
interface.close.assert_called_once_with()
def test_start_does_not_crash_when_wrapper_returns_without_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper") as wrapper:
entrypoint.start()
wrapper.assert_called_once_with(entrypoint.main)
def test_main_returns_cleanly_when_user_closes_missing_node_dialog(self) -> None:
stdscr = mock.Mock()
args = Namespace(settings=False, demo_screenshot=False)
@@ -215,11 +224,23 @@ class MainRuntimeTests(unittest.TestCase):
interface.close.assert_called_once_with()
exit_mock.assert_called_once_with(0)
def test_start_handles_keyboard_interrupt_with_no_interface(self) -> None:
interface_state.interface = None
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=KeyboardInterrupt):
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(0)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
entrypoint.start()
self.assertEqual(raised.exception.code, 0)
exit_mock.assert_called_once_with(0)
def test_start_handles_fatal_exception_and_exits_one(self) -> None:
with mock.patch.object(entrypoint.sys, "argv", ["contact"]):
with mock.patch.object(entrypoint.curses, "wrapper", side_effect=RuntimeError("boom")):
with mock.patch.object(entrypoint.curses, "endwin") as endwin:
with mock.patch.object(entrypoint.traceback, "print_exc") as print_exc:
with mock.patch.object(entrypoint.traceback, "print_exception") as print_exception:
with mock.patch("builtins.print") as print_mock:
with mock.patch.object(entrypoint.sys, "exit", side_effect=SystemExit(1)) as exit_mock:
with self.assertRaises(SystemExit) as raised:
@@ -227,6 +248,9 @@ class MainRuntimeTests(unittest.TestCase):
self.assertEqual(raised.exception.code, 1)
endwin.assert_called_once_with()
print_exc.assert_called_once_with()
exception_type, exception, exception_traceback = print_exception.call_args_list[-1].args
self.assertIs(exception_type, RuntimeError)
self.assertEqual(str(exception), "boom")
self.assertIsNotNone(exception_traceback)
print_mock.assert_any_call("Fatal error:", mock.ANY)
exit_mock.assert_called_once_with(1)
+63
View File
@@ -0,0 +1,63 @@
from types import SimpleNamespace
import unittest
from meshtastic.protobuf import config_pb2, module_config_pb2
from contact.ui.menus import generate_menu_from_protobuf
class MenusTests(unittest.TestCase):
def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {
"user": {"longName": "Test User", "shortName": "TU", "isLicensed": False},
"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0},
},
)
menu = generate_menu_from_protobuf(interface)
keys = list(menu["Main Menu"].keys())
self.assertLess(keys.index("Factory Reset"), keys.index("factory_reset_config"))
self.assertEqual(keys[keys.index("Factory Reset") + 1], "factory_reset_config")
def test_module_settings_include_ringtone_and_canned_messages(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
ringtone="tone",
cannedPluginMessage="Hi|Bye",
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {"position": {"latitude": 0.0, "longitude": 0.0, "altitude": 0}},
)
module_settings = generate_menu_from_protobuf(interface)["Main Menu"]["Module Settings"]
self.assertEqual(module_settings["external_notification"]["ringtone"], (None, "tone"))
self.assertEqual(module_settings["canned_message"]["messages"], (None, "Hi|Bye"))
def test_user_settings_include_unmessageable_toggle(self) -> None:
local_node = SimpleNamespace(
localConfig=config_pb2.Config(),
moduleConfig=module_config_pb2.ModuleConfig(),
getChannelByChannelIndex=lambda _: None,
)
interface = SimpleNamespace(
localNode=local_node,
getMyNodeInfo=lambda: {
"user": {"longName": "Test User", "shortName": "TU", "isUnmessagable": True},
"position": {},
},
)
user_settings = generate_menu_from_protobuf(interface)["Main Menu"]["User Settings"]
self.assertEqual(user_settings["isUnmessagable"], (None, True))
+95
View File
@@ -0,0 +1,95 @@
import unittest
from contextlib import ExitStack
from unittest import mock
from contact.ui import contact_ui
from contact.utilities.singleton import ui_state
from tests.test_support import reset_singletons
class MessageInputTests(unittest.TestCase):
def setUp(self):
reset_singletons()
ui_state.current_window = 1
for name in ("refresh_message_highlight", "refresh_main_window", "get_color"):
patcher = mock.patch.object(contact_ui, name, return_value=1)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(reset_singletons)
def test_keyboard_loop_sends_edited_draft_after_resize(self):
win = mock.Mock()
win.get_wch.side_effect = [
"a", "b", "c", contact_ui.curses.KEY_LEFT,
contact_ui.curses.KEY_DC, "d", contact_ui.curses.KEY_BACKSPACE,
"e", contact_ui.curses.KEY_RESIZE, "\n", "\x1b",
]
with ExitStack() as stack:
stack.enter_context(mock.patch.object(contact_ui, "entry_win", win))
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
stack.enter_context(mock.patch.object(contact_ui, name))
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
stack.enter_context(mock.patch.object(contact_ui, "drain_resize_events", return_value=None))
send = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
contact_ui.main_ui(mock.Mock())
send.assert_called_once_with("abe")
self.assertEqual(contact_ui.input_text, "")
self.assertEqual(ui_state.input_cursor, 0)
def test_typing_focuses_input_and_edits_at_cursor(self):
draft = contact_ui.insert_input_text("", "helo")
self.assertTrue(ui_state.input_focused)
with mock.patch.object(contact_ui, "input_text", draft):
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
draft = contact_ui.insert_input_text(draft, "l")
self.assertEqual(draft, "hello")
self.assertEqual(ui_state.input_cursor, 4)
draft = contact_ui.handle_backspace(mock.Mock(), draft)
self.assertEqual(draft, "helo")
self.assertEqual(ui_state.input_cursor, 3)
def test_up_and_down_return_to_messages_without_scrolling(self):
for handler in (contact_ui.handle_up, contact_ui.handle_down):
ui_state.input_focused = True
ui_state.input_cursor = 2
with mock.patch.object(contact_ui, "scroll_messages") as scroll:
handler()
self.assertFalse(ui_state.input_focused)
self.assertEqual(ui_state.current_window, 1)
self.assertEqual(ui_state.input_cursor, 2)
scroll.assert_not_called()
def test_cursor_stops_at_draft_boundaries(self):
ui_state.input_focused = True
with mock.patch.object(contact_ui, "input_text", "abc"):
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
self.assertEqual(ui_state.input_cursor, 0)
self.assertEqual(contact_ui.handle_backspace(mock.Mock(), "abc"), "abc")
contact_ui.handle_end()
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
self.assertEqual(ui_state.input_cursor, 3)
contact_ui.handle_home()
self.assertEqual(ui_state.input_cursor, 0)
def test_redraw_clears_line_and_shows_text_after_cursor(self):
win = mock.Mock()
win.getmaxyx.return_value = (3, 30)
ui_state.input_focused = True
ui_state.input_cursor = 2
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
contact_ui.draw_message_input("hello")
win.addstr.assert_any_call(1, 1, " " * 28, 1)
win.addstr.assert_any_call(1, 1, "Message: hello", 1)
win.move.assert_called_once_with(1, 12)
contact_ui.get_color.assert_any_call("window_frame_selected")
def test_long_draft_scrolls_to_cursor_with_reply_context(self):
win = mock.Mock()
win.getmaxyx.return_value = (3, 24)
ui_state.reply_context = "Reply to someone: "
ui_state.input_cursor = 20
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
contact_ui.draw_message_input("x" * 20)
self.assertLess(win.move.call_args.args[1], 23)
rendered = [call.args[2] for call in win.addstr.call_args_list if call.args[:2] == (1, 1)][-1]
self.assertEqual(rendered, "Message: " + "x" * 12)
+36
View File
@@ -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)],
)
+80 -23
View File
@@ -34,23 +34,56 @@ class RxHandlerTests(unittest.TestCase):
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=True):
with mock.patch.object(rx_handler, "draw_node_list") as draw_node_list:
with mock.patch.object(rx_handler, "draw_messages_window") as draw_messages_window:
with mock.patch.object(rx_handler, "draw_channel_list") as draw_channel_list:
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)
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)
draw_node_list.assert_called_once_with()
draw_messages_window.assert_called_once_with(True)
draw_channel_list.assert_not_called()
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")
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"]
@@ -66,31 +99,55 @@ class RxHandlerTests(unittest.TestCase):
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False):
with mock.patch.object(rx_handler, "draw_messages_window") as draw_messages_window:
with mock.patch.object(rx_handler, "draw_channel_list") as draw_channel_list:
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)
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)
draw_messages_window.assert_not_called()
draw_channel_list.assert_called_once_with()
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")
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, "draw_packetlog_win") as draw_packetlog_win:
with mock.patch.object(rx_handler, "request_ui_redraw") as request_ui_redraw:
rx_handler.on_receive({"id": "new"}, interface=None)
draw_packetlog_win.assert_called_once_with()
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)
+35
View File
@@ -55,6 +55,20 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
def test_save_changes_replaces_remote_admin_keys_in_one_write(self) -> None:
interface, node = self.build_interface()
key_one = b"1" * 32
key_two = b"2" * 32
key_three = b"3" * 32
node.localConfig.security.admin_key = [key_one, key_two, key_three]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Security"])
reconnect_required = save_changes(interface, {"admin_key": [key_one, key_two]}, menu_state, node=node)
self.assertTrue(reconnect_required)
self.assertEqual(node.localConfig.security.admin_key, [key_one, key_two])
node.writeConfig.assert_called_once_with("security")
def test_save_changes_returns_true_only_for_rebooting_device_fields(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "Radio Settings", "Device"])
@@ -95,6 +109,18 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
self.assertTrue(node.moduleConfig.mqtt.enabled)
def test_save_changes_writes_channel_is_muted_to_module_settings(self) -> None:
interface, node = self.build_interface()
channel = SimpleNamespace(settings=SimpleNamespace(module_settings=SimpleNamespace(is_muted=False)), role=None)
node.channels = [channel]
menu_state = SimpleNamespace(menu_path=["Main Menu", "Channels", "Channel 1", "module_settings"])
reconnect_required = save_changes(interface, {"is_muted": True}, menu_state)
self.assertFalse(reconnect_required)
self.assertTrue(channel.settings.module_settings.is_muted)
node.writeChannel.assert_called_once_with(0)
def test_save_changes_returns_true_for_user_name_changes(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
@@ -112,3 +138,12 @@ class SaveToRadioTests(unittest.TestCase):
self.assertTrue(reconnect_required)
node.setOwner.assert_called_once()
def test_save_changes_sends_user_unmessageable_setting(self) -> None:
interface, node = self.build_interface()
menu_state = SimpleNamespace(menu_path=["Main Menu", "User Settings"])
reconnect_required = save_changes(interface, {"isUnmessagable": True}, menu_state)
self.assertTrue(reconnect_required)
self.assertEqual(node.setOwner.call_args.args[3], True)
+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()
+21 -6
View File
@@ -42,7 +42,7 @@ class TxHandlerTests(unittest.TestCase):
onResponse=tx_handler.onAckNak,
channelIndex=0,
)
save_message_to_db.assert_called_once_with("Primary", 111, "hello")
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)
@@ -70,22 +70,37 @@ class TxHandlerTests(unittest.TestCase):
)
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}
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.draw_messages_window") as draw_messages_window:
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")
draw_messages_window.assert_called_once_with()
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])
@@ -94,13 +109,13 @@ class TxHandlerTests(unittest.TestCase):
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}
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.draw_messages_window"):
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")
+30
View File
@@ -0,0 +1,30 @@
import unittest
from tempfile import NamedTemporaryFile
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"])
def test_load_log_tail_returns_only_requested_recent_lines(self) -> None:
with NamedTemporaryFile("w") as log_file:
log_file.write("".join(f"line {index}\n" for index in range(600)))
log_file.flush()
lines = user_config.load_log_tail(log_file.name, max_lines=500)
self.assertEqual(len(lines), 500)
self.assertEqual(lines[0], "line 100")
self.assertEqual(lines[-1], "line 599")
if __name__ == "__main__":
unittest.main()
+50 -1
View File
@@ -1,10 +1,19 @@
import unittest
from unittest import mock
from meshtastic.protobuf import config_pb2
import contact.ui.default_config as config
from contact.utilities.demo_data import DEMO_LOCAL_NODE_NUM, build_demo_interface
from contact.utilities.singleton import interface_state, ui_state
from contact.utilities.utils import add_new_message, get_channels, get_node_list, parse_protobuf
from contact.utilities.utils import (
_get_channel_name,
_get_modem_preset_name,
add_new_message,
get_channels,
get_node_list,
parse_protobuf,
)
from tests.test_support import reset_singletons, restore_config, snapshot_config
@@ -12,6 +21,7 @@ from tests.test_support import reset_singletons, restore_config, snapshot_config
class UtilsTests(unittest.TestCase):
def setUp(self) -> None:
reset_singletons()
_get_modem_preset_name.cache_clear()
self.saved_config = snapshot_config("node_sort")
def tearDown(self) -> None:
@@ -60,6 +70,45 @@ class UtilsTests(unittest.TestCase):
self.assertIn("MediumFast", ui_state.all_messages)
self.assertIn("Another Channel", ui_state.all_messages)
def test_get_channel_name_handles_newer_known_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
warning.assert_called_once()
def test_get_channel_name_handles_unknown_modem_preset(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 99
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "UnknownPreset99")
warning.assert_called_once()
def test_get_channel_name_prefers_public_protobuf_enum_over_compatibility_map(self) -> None:
device_channel = mock.Mock()
device_channel.settings.name = ""
node = mock.Mock()
node.localConfig.lora.modem_preset = 14
with mock.patch.object(
config_pb2.Config.LoRaConfig.ModemPreset,
"Name",
return_value="TINY_FAST",
) as enum_name:
with mock.patch("contact.utilities.utils.logging.warning") as warning:
self.assertEqual(_get_channel_name(device_channel, node), "TinyFast")
enum_name.assert_called_once_with(14)
warning.assert_not_called()
def test_get_channels_rebuilds_renamed_channels_and_preserves_messages(self) -> None:
interface = build_demo_interface()
interface.localNode.channels[0].settings.name = "Renamed Channel"