From 7273e806390c9cf097bb151bbd1beac93951ac5b Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 09:55:53 -0700 Subject: [PATCH 01/10] Add new configuration options for device UI and modules --- contact/localisations/en.ini | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index 9d803bc..c68d031 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -252,6 +252,8 @@ heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' o wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button." compass_orientation, "Compass orientation", "Whether to rotate the compass." use_12h_clock, "Use 12 hour clock" +use_long_node_name, "Use long node name", "Show each node's long name on the device display when space permits." +enable_message_bubbles, "Enable message bubbles", "Display messages in chat-style bubbles on supported device displays." [config.device_ui] title, "Device UI" @@ -270,6 +272,10 @@ node_filter, "Node Filter", "" node_highlight, "Node Highlight", "" calibration_data, "Calibration Data", "" map_data, "Map Data", "" +compass_mode, "Compass mode", "Controls whether the compass is dynamic, fixed, or freezes its heading." +screen_rgb_color, "Screen RGB color", "Sets the display color on supported RGB screens." +is_clockface_analog, "Analog clock face", "Use an analog clock face on supported device displays." +gps_format, "GPS coordinate format", "Select the coordinate format used by the device UI." [config.device_ui.node_filter] title, "Node Filter" @@ -295,6 +301,12 @@ home, "Home", "" style, "Style", "" follow_gps, "Follow GPS", "" +[config.device_ui.map_data.home] +title, "Home location", "" +zoom, "Zoom", "Map zoom level for the home location." +latitude, "Latitude", "Latitude of the home location." +longitude, "Longitude", "Longitude of the home location." + [config.lora] title, "LoRa" use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset." @@ -314,6 +326,8 @@ override_frequency, "Override frequency in MHz", "Overrides frequency slot. May pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN" ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device." config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers." +fem_lna_mode, "FEM LNA mode", "Configures the front-end module low-noise amplifier mode on supported hardware." +serial_hal_only, "Serial HAL only", "Use only the serial hardware abstraction layer on supported hardware." [config.bluetooth] title, "Bluetooth" @@ -330,6 +344,7 @@ serial_enabled, "Enable serial console", "" debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active." admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes." admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3." +packet_signature_policy, "Packet signature policy", "Controls how the node validates packet signatures." [module.mqtt] title, "MQTT" @@ -391,6 +406,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" @@ -408,6 +424,7 @@ 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" @@ -468,5 +485,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." From 0143d9bacce510ef6b38afeda36e51550a5b2681 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 15:25:07 -0700 Subject: [PATCH 02/10] Clear stale reply context in draw_text_field function and add corresponding test --- contact/ui/contact_ui.py | 12 ++++++++---- tests/test_contact_ui.py | 9 +++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 4a61d7f..b4b5062 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -1675,21 +1675,25 @@ def remove_notification(channel_number: int) -> None: def draw_text_field(win: curses.window, text: str, color: int) -> None: win.border() + height, width = win.getmaxyx() # Put a small hint in the border of the message entry field. # We key off the "Message:" prompt to avoid affecting other bordered fields. if isinstance(text, str) and text.startswith("Message:"): hint = " Ctrl+K Help " - h, w = win.getmaxyx() - x = max(2, w - len(hint) - 2) + x = max(2, width - len(hint) - 2) try: win.addstr(0, x, hint, get_color("commands")) except curses.error: pass - # Draw the actual field text + # Clear the row before redrawing. Reply context can be longer than the + # next value (or empty after Ctrl+R cancels it), so simply overwriting the + # new text leaves trailing characters from the old context on screen. try: - win.addstr(1, 1, text, color) + if height > 2 and width > 2: + win.addstr(1, 1, " " * (width - 2), color) + win.addstr(1, 1, text[: max(0, width - 2)], color) except curses.error: pass diff --git a/tests/test_contact_ui.py b/tests/test_contact_ui.py index 13c8dad..366a167 100644 --- a/tests/test_contact_ui.py +++ b/tests/test_contact_ui.py @@ -152,6 +152,15 @@ class ContactUiTests(unittest.TestCase): 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"] From f63c27e9ade221349e36358998312a8b997535d2 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 22:57:30 -0700 Subject: [PATCH 03/10] typo --- contact/localisations/en.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index a40fcb6..38ceadc 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -503,7 +503,7 @@ rate_limit_max_packets, "Rate-limit maximum packets", "Maximum packets allowed p 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" +title, "Tak" team, "Team", "Your team color." role, "Role", "Your role in the team." From b1b9f22203820c36e3c483f2249c7a3edd06f5f9 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 23:12:21 -0700 Subject: [PATCH 04/10] Add notification sound feature and update configuration --- README.md | 8 ++++++ contact/localisations/en.ini | 2 +- contact/message_handlers/rx_handler.py | 33 ++++++++----------------- contact/sounds/alert.mp3 | Bin 0 -> 50155 bytes contact/ui/default_config.py | 11 ++++++++- contact/ui/user_config.py | 16 +++++++++++- 6 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 contact/sounds/alert.mp3 diff --git a/README.md b/README.md index 9c1543f..448be15 100644 --- a/README.md +++ b/README.md @@ -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`: Screenshot 2025-08-22 at 11 15 54 PM diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index 38ceadc..b7f47d4 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -112,7 +112,7 @@ language, "Language", "UI language for labels and help text." message_prefix, "Message prefix", "" sent_message_prefix, "Sent message prefix", "" notification_symbol, "Notification symbol", "" -notification_sound, "Notification sound", "" +notification_sound, "Notification sound", "Select a sound file from Contact's sounds folder, or None to disable notification audio." ack_implicit_str, "ACK (implicit)", "" ack_str, "ACK", "" nak_str, "NAK", "" diff --git a/contact/message_handlers/rx_handler.py b/contact/message_handlers/rx_handler.py index 44c8b27..6ffb1e5 100644 --- a/contact/message_handlers/rx_handler.py +++ b/contact/message_handlers/rx_handler.py @@ -66,29 +66,15 @@ 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 shutil.which("ffplay") or shutil.which("mpg123") if executable and sound_path: cmd = [executable, sound_path] @@ -97,6 +83,7 @@ def play_sound(): subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return + logging.warning("No suitable sound player found for notification sound") except subprocess.CalledProcessError as e: logging.error(f"Sound playback failed: {e}") @@ -144,7 +131,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"] diff --git a/contact/sounds/alert.mp3 b/contact/sounds/alert.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fc30cfd7b4a6806e65ac5bd11a224026df0e32a8 GIT binary patch literal 50155 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 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]", @@ -264,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) diff --git a/contact/ui/user_config.py b/contact/ui/user_config.py index ad04a2e..b0336c7 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -115,9 +115,23 @@ def edit_color_pair(key: str, display_label: str, current_value: List[str]) -> L def edit_value(key: str, display_label: str, current_value: str) -> str: - if key in ("notification_sound", "single_pane_mode", "enabled"): + 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: " From 72956321daddb05b28aa004c6f6f7384df81d4f0 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 23:16:02 -0700 Subject: [PATCH 05/10] bump version to 1.6.0 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0412f9f..8611bee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contact" -version = "1.5.13" +version = "1.6.0" 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"} From 0cee9777bc56b520f14a68b0c4bfe7c2a12e4f4f Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 23:39:46 -0700 Subject: [PATCH 06/10] Improve sound playback handling by adding support for multiple audio players and enhancing error logging --- contact/message_handlers/rx_handler.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/contact/message_handlers/rx_handler.py b/contact/message_handlers/rx_handler.py index 6ffb1e5..746c15a 100644 --- a/contact/message_handlers/rx_handler.py +++ b/contact/message_handlers/rx_handler.py @@ -74,16 +74,26 @@ def play_sound(): logging.warning("Configured notification sound is unavailable: %s", sound_path) return - executable = "afplay" if platform.system() == "Darwin" else shutil.which("ffplay") or shutil.which("mpg123") + 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") + 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}") From ece37f2a36e89faa98d640fdb84d7437e9c6dbdd Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 24 Jul 2026 23:40:07 -0700 Subject: [PATCH 07/10] bump version to 1.6.1 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8611bee..219256a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contact" -version = "1.6.0" +version = "1.6.1" 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"} From 7afc025b9d7eb3b364e8af3e48932760f2756c09 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 25 Jul 2026 14:30:58 -0700 Subject: [PATCH 08/10] Refactor settings menu to improve channel request handling and status updates --- contact/ui/contact_ui.py | 4 +++- contact/ui/control_ui.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 4627cee..cfbbbef 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -964,7 +964,9 @@ def handle_backtick(stdscr: curses.window) -> None: settings_menu( stdscr, interface, - node=interface.getNode(remote_node_num), + # Settings requests configs explicitly; avoid getNode's + # implicit channel request racing that setup. + node=interface.getNode(remote_node_num, False), remote=True, status_callback=lambda message: update_remote_admin_wait(wait_win, message), ) diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index 7684a89..bcd7a4f 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -326,7 +326,10 @@ def settings_menu( _request_remote_with_timeout(node.requestConfig, config_type) if status_callback: status_callback("Requesting channels…") - _request_remote_with_timeout(node.requestChannels) + node.requestChannels() + if status_callback: + status_callback("Waiting for channel list…") + _request_remote_with_timeout(node.waitForConfig, "channels") menu = generate_menu_from_protobuf(interface, node=node, include_app_settings=not remote) menu_state.current_menu = menu["Main Menu"] From 4ed3cbf370fefdc5d85e018bc250747eabcc939f Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 25 Jul 2026 14:31:22 -0700 Subject: [PATCH 09/10] bump version to 1.6.2 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 219256a..470f69c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contact" -version = "1.6.1" +version = "1.6.2" description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores." authors = [ {name = "Ben Lipsey",email = "ben@pdxlocations.com"} From 7f6fa49385bbaced109def84d181e7522480e481 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 25 Jul 2026 21:46:27 -0700 Subject: [PATCH 10/10] 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. --- contact/ui/contact_ui.py | 79 ++++++++++++++++++++++++++++++--- contact/ui/control_ui.py | 4 -- contact/ui/ui_state.py | 1 + contact/utilities/interfaces.py | 12 +++-- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index cfbbbef..ab25d19 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -383,6 +383,41 @@ def main_ui(stdscr: curses.window) -> None: handle_resize(stdscr, True) while True: + interface = interface_state.interface + if ( + hasattr(interface, "stream") + and interface.stream is None + and not ui_state.reconnect_attempted + ): + ui_state.reconnect_attempted = True + status_win = show_connection_status(stdscr, "Disconnected", "Trying to reconnect…") + try: + from contact.ui.control_ui import reconnect_interface_with_splash + + reconnect_interface_with_splash(stdscr, interface) + if status_win is not None: + status_win.erase() + status_win.refresh() + ui_state.reconnect_attempted = False + handle_resize(stdscr, False) + except Exception: + if status_win is not None: + status_win.erase() + status_win.refresh() + handle_resize(stdscr, False) + logging.exception("Automatic reconnect after transport disconnect failed") + retry = get_list_input( + "Contact could not reconnect after 20 seconds.", + "Retry", + ["Retry", "Cancel"], + mandatory=True, + ) + if retry == "Retry": + ui_state.reconnect_attempted = False + handle_resize(stdscr, False) + continue + handle_resize(stdscr, False) + with app_state.lock: process_pending_ui_updates(stdscr) entry_display = f"{ui_state.reply_context}{input_text or ''}" @@ -652,12 +687,27 @@ def handle_enter(input_text: str) -> str: ) return input_text # Enter key pressed, send user input as message - send_message( - input_text, - channel=ui_state.selected_channel, - reply_id=ui_state.reply_id, - reply_context=ui_state.reply_context, - ) + try: + send_message( + input_text, + channel=ui_state.selected_channel, + reply_id=ui_state.reply_id, + reply_context=ui_state.reply_context, + ) + except Exception as exc: + logging.warning("Message send failed; reconnecting interface: %s", exc) + try: + from contact.ui.control_ui import reconnect_interface_with_splash + + reconnect_interface_with_splash(root_win, interface_state.interface) + contact.ui.dialog.dialog("Reconnected", "Connection restored. Your message was not sent; press Enter to retry.") + except Exception as reconnect_exc: + logging.exception("Automatic reconnect failed") + contact.ui.dialog.dialog( + "Reconnect failed", + "Contact could not reconnect after 20 seconds. Check the radio connection and retry your message.", + ) + return input_text ui_state.reply_id = None ui_state.reply_context = "" ui_state.reply_id_unavailable = False @@ -1012,6 +1062,23 @@ def show_remote_admin_wait(stdscr: curses.window, node_name: str) -> curses.wind return None +def show_connection_status(stdscr: curses.window, title: str, message: str) -> curses.window | None: + """Display a non-blocking connection-status dialog over the current UI.""" + try: + height, width = stdscr.getmaxyx() + box_width = min(width - 4, max(len(message) + 4, len(title) + 6, 32)) + win = curses.newwin(5, box_width, max(0, (height - 5) // 2), max(0, (width - box_width) // 2)) + win.bkgd(get_color("background")) + win.attrset(get_color("window_frame")) + win.border() + win.addstr(0, 2, f" {title} ", get_color("settings_default")) + win.addstr(2, 2, message[: box_width - 4], get_color("settings_default")) + win.refresh() + return win + except curses.error: + return None + + def update_remote_admin_wait(wait_win: curses.window | None, message: str) -> None: """Append the current remote-admin request to the waiting overlay.""" if wait_win is None: diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index bcd7a4f..7d34d3d 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -26,7 +26,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 @@ -236,9 +235,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) diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index 514e4cc..4c29963 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -39,6 +39,7 @@ class ChatUIState: redraw_full_ui: bool = False scroll_messages_to_bottom: bool = False preserve_message_selection: bool = False + reconnect_attempted: bool = False oldest_message_rowid: Dict[Union[str, int], int] = field(default_factory=dict) has_older_messages: Dict[Union[str, int], bool] = field(default_factory=dict) message_line_ranges: Dict[Union[str, int], List[tuple]] = field(default_factory=dict) diff --git a/contact/utilities/interfaces.py b/contact/utilities/interfaces.py index bd33ed6..9b6d53d 100644 --- a/contact/utilities/interfaces.py +++ b/contact/utilities/interfaces.py @@ -44,15 +44,21 @@ 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 for attempt in range(attempts): try: interface = initialize_interface(args) - if interface is not None: + if interface is not None and getattr(interface, "localNode", None) is not None and getattr( + interface.localNode, "localConfig", None + ) is not None and (not hasattr(interface, "stream") or interface.stream is not None): return interface - last_error = RuntimeError("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