From c55f6afef8fa15e87596b7fc7b7a81d93a7cecbc Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 4 Sep 2026 23:50:46 -0700 Subject: [PATCH] Enhance channel management by adding 'enabled' state to channels and updating role handling in save_changes. --- contact/localisations/en.ini | 1 + contact/ui/control_ui.py | 29 ++++++++++++++++++++++++++--- contact/ui/menus.py | 4 ++++ contact/utilities/input_handlers.py | 5 ++++- contact/utilities/save_to_radio.py | 15 +++++++++------ tests/test_menus.py | 20 +++++++++++++++++++- tests/test_save_to_radio.py | 17 +++++++++++++++++ 7 files changed, 80 insertions(+), 11 deletions(-) diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index 5216ab7..6ab5d1e 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -160,6 +160,7 @@ node_ignored, "Node ignored", "" [Channels.channel] title, "Channels" +enabled, "Enabled", "Enable or disable this channel without clearing its name or encryption key." channel_num, "Channel number", "The index number of this channel." psk, "PSK", "The channel's encryption key." name, "Name", "The channel's name." diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index 23206fd..63a7d83 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -97,6 +97,17 @@ def get_translated_header(menu_path: List[str]) -> str: return f"{admin_target_label} | {breadcrumb}" if admin_target_label else breadcrumb +def get_settings_option_label(option, field_info, menu_path): + if menu_path == ["Main Menu", "Channels"] and isinstance(field_info, dict): + name = field_info.get("name", (None, ""))[1] + if name: + return name + full_key = ".".join(transform_menu_path(menu_path) + [option]) + if len(menu_path) == 3 and menu_path[1] == "Channels" and option == "enabled": + return field_mapping.get(full_key, "Enabled") + return field_mapping.get(full_key, option) + + def display_menu() -> tuple[object, object]: # if help_win: # min_help_window_height = 6 @@ -140,7 +151,7 @@ def display_menu() -> tuple[object, object]: field_info = menu_state.current_menu[option] current_value = field_info[1] if isinstance(field_info, tuple) else "" full_key = ".".join(transformed_path + [option]) - display_name = field_mapping.get(full_key, option) + display_name = get_settings_option_label(option, field_info, menu_state.menu_path) if full_key.startswith("config.network.ipv4_config.") and option in {"ip", "gateway", "subnet", "dns"}: if isinstance(current_value, int): @@ -719,7 +730,7 @@ def settings_menu( full_key = ".".join(transformed_path + [selected_option]) # Fetch human-readable name from field_mapping - human_readable_name = field_mapping.get(full_key, selected_option) + human_readable_name = get_settings_option_label(selected_option, field_info, menu_state.menu_path) if field is None and selected_option in ("ringtone", "messages"): is_ringtone = selected_option == "ringtone" @@ -779,6 +790,11 @@ def settings_menu( new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values] menu_state.start_index.pop() + elif field is None and selected_option == "enabled" and menu_state.menu_path[1] == "Channels": + new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) + new_value = new_value == "True" or new_value is True + menu_state.start_index.pop() + elif field.type == 8: # Handle boolean type new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) if new_value == "Not Set": @@ -820,8 +836,15 @@ def settings_menu( else: # Handle other field types input_type = get_input_type_for_field(field) + allow_empty = ( + selected_option == "name" + and len(menu_state.menu_path) == 3 + and menu_state.menu_path[1] == "Channels" + and menu_state.menu_path[2] != "Channel 1" + ) new_value = get_text_input( - f"{human_readable_name} is currently: {current_value}", selected_option, input_type + f"{human_readable_name} is currently: {current_value}", selected_option, input_type, + allow_empty=allow_empty, ) new_value = current_value if new_value is None else new_value menu_state.start_index.pop() diff --git a/contact/ui/menus.py b/contact/ui/menus.py index 81fa439..fdb1353 100644 --- a/contact/ui/menus.py +++ b/contact/ui/menus.py @@ -93,6 +93,10 @@ def generate_menu_from_protobuf(interface: object, node: Any = None, include_app current_channel = node.getChannelByChannelIndex(i) if current_channel: channel_config = extract_fields(channel, current_channel.settings) + channel_config = { + "enabled": (None, current_channel.role != channel_pb2.Channel.Role.DISABLED), + **channel_config, + } menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config # Add Radio Settings diff --git a/contact/utilities/input_handlers.py b/contact/utilities/input_handlers.py index b75de61..d861a5e 100644 --- a/contact/utilities/input_handlers.py +++ b/contact/utilities/input_handlers.py @@ -38,7 +38,7 @@ def invalid_input(window: curses.window, message: str, redraw_func: Optional[cal curses.curs_set(1) -def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]: +def get_text_input(prompt: str, selected_config: str, input_type: str, *, allow_empty: bool = False) -> Optional[str]: """Handles user input with wrapped text for long prompts.""" def redraw_input_win(): @@ -127,6 +127,9 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option menu_state.need_redraw = True if not user_input.strip(): + if allow_empty: + user_input = "" + break invalid_input( input_win, t("ui.error.value_empty", default="Value cannot be empty."), diff --git a/contact/utilities/save_to_radio.py b/contact/utilities/save_to_radio.py index 57a8135..daecb53 100644 --- a/contact/utilities/save_to_radio.py +++ b/contact/utilities/save_to_radio.py @@ -160,7 +160,15 @@ def save_changes(interface, modified_settings, menu_state, node=None): channel = node.channels[channel_num] for key, value in modified_settings.items(): - if key == "psk": # Special case: decode Base64 for psk + if key == "enabled": + if not value: + channel.role = channel_pb2.Channel.Role.DISABLED + elif channel.role == channel_pb2.Channel.Role.DISABLED: + channel.role = ( + channel_pb2.Channel.Role.PRIMARY if channel_num == 0 + else channel_pb2.Channel.Role.SECONDARY + ) + elif key == "psk": # Special case: decode Base64 for psk channel.settings.psk = base64.b64decode(value) elif key in {"position_precision", "is_muted"}: # These fields belong to ChannelSettings.module_settings, @@ -169,11 +177,6 @@ def save_changes(interface, modified_settings, menu_state, node=None): else: setattr(channel.settings, key, value) # Use setattr for other fields - if channel_num == 0: - channel.role = channel_pb2.Channel.Role.PRIMARY - else: - channel.role = channel_pb2.Channel.Role.SECONDARY - node.writeChannel(channel_num) logging.info(f"Updated Channel {channel_num} in {config_category}") diff --git a/tests/test_menus.py b/tests/test_menus.py index ffae64d..2bdec5c 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -1,12 +1,30 @@ from types import SimpleNamespace import unittest -from meshtastic.protobuf import config_pb2, module_config_pb2 +from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2 from contact.ui.menus import generate_menu_from_protobuf class MenusTests(unittest.TestCase): + def test_channels_include_enabled_state_and_display_names(self): + from contact.ui.control_ui import get_settings_option_label + + channels = [ + channel_pb2.Channel(role=channel_pb2.Channel.PRIMARY, settings=channel_pb2.ChannelSettings(name="Local")), + channel_pb2.Channel(role=channel_pb2.Channel.DISABLED, settings=channel_pb2.ChannelSettings(name="Local")), + channel_pb2.Channel(role=channel_pb2.Channel.DISABLED), + ] + node = SimpleNamespace( + localConfig=config_pb2.Config(), moduleConfig=module_config_pb2.ModuleConfig(), + getChannelByChannelIndex=lambda index: channels[index] if index < len(channels) else None, + ) + menu = generate_menu_from_protobuf(None, node=node)["Main Menu"]["Channels"] + self.assertEqual(menu["Channel 1"]["enabled"], (None, True)) + self.assertEqual(menu["Channel 2"]["enabled"], (None, False)) + labels = [get_settings_option_label(key, value, ["Main Menu", "Channels"]) for key, value in menu.items()] + self.assertEqual(labels, ["Local", "Local", "Channel 3"]) + def test_main_menu_includes_factory_reset_config_after_factory_reset(self) -> None: local_node = SimpleNamespace( localConfig=config_pb2.Config(), diff --git a/tests/test_save_to_radio.py b/tests/test_save_to_radio.py index b9c7970..e5f1446 100644 --- a/tests/test_save_to_radio.py +++ b/tests/test_save_to_radio.py @@ -1,11 +1,28 @@ from types import SimpleNamespace import unittest from unittest import mock +from meshtastic.protobuf import channel_pb2 from contact.utilities.save_to_radio import save_changes class SaveToRadioTests(unittest.TestCase): + def test_channel_enable_disable_and_unrelated_edits_preserve_role(self): + for index in (0, 1): + interface, node = self.build_interface() + node.channels = [channel_pb2.Channel(index=i) for i in range(2)] + channel = node.channels[index] + path = SimpleNamespace(menu_path=["Main Menu", "Channels", f"Channel {index + 1}"]) + save_changes(interface, {"name": "Test"}, path) + self.assertEqual(channel.role, channel_pb2.Channel.DISABLED) + save_changes(interface, {"enabled": True}, path) + expected = channel_pb2.Channel.PRIMARY if index == 0 else channel_pb2.Channel.SECONDARY + self.assertEqual(channel.role, expected) + save_changes(interface, {"enabled": False}, path) + self.assertEqual(channel.role, channel_pb2.Channel.DISABLED) + self.assertEqual(channel.settings.name, "Test") + self.assertEqual(node.writeChannel.call_args_list, [mock.call(index)] * 3) + def build_interface(self): node = mock.Mock() node.localConfig = SimpleNamespace(