Enhance channel management by adding 'enabled' state to channels and updating role handling in save_changes.

This commit is contained in:
pdxlocations committed 2026-09-04 23:50:46 -07:00
1 parent 36408a1f37
commit c55f6afef8
7 files changed
+80 -11

No files matched your search

+1
View File
@@ -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."
+26 -3
View File
@@ -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()
+4
View File
@@ -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
+4 -1
View File
@@ -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."),
+9 -6
View File
@@ -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}")
+19 -1
View File
@@ -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(),
+17
View File
@@ -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(