Compare commits

..
Author SHA1 Message Date
pdxlocations 5d767a14d1 Merge pull request #306 from pdxlocations:delete-channels
Enhance channel management by adding 'enabled' state to channels and updating role handling in save_changes.
2026-09-04 23:51:03 -07:00
pdxlocations c55f6afef8 Enhance channel management by adding 'enabled' state to channels and updating role handling in save_changes. 2026-09-04 23:50:46 -07:00
14 changed files with 109 additions and 146 deletions

No files matched your search

+2 -4
View File
@@ -121,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, stdscr)
interface = initialize_runtime_interface(args)
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
return interface
@@ -162,12 +162,10 @@ def initialize_globals(seed_demo: bool = False) -> None:
load_messages_from_db()
def initialize_runtime_interface(args: object, stdscr=None):
def initialize_runtime_interface(args: object):
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)
+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."
+1 -1
View File
@@ -36,7 +36,7 @@ def main(stdscr: curses.window) -> None:
parser = setup_parser()
args = parser.parse_args()
interface = initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input(
+17 -50
View File
@@ -246,14 +246,13 @@ def compute_widths(total_w: int, focus: int):
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
def paint_frame(win, selected: bool) -> None:
if win is not entry_win and ui_state.input_focused:
selected = False
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
win.box()
win.attrset(get_color("window_frame"))
if refresh:
win.refresh()
win.refresh()
def get_channel_row_color(index: int) -> int:
@@ -276,7 +275,7 @@ def get_node_row_color(index: int, highlight: bool = False) -> int:
return get_color(color, reverse=reverse)
def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh: bool = True) -> None:
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
if nodes_pad is None or not ui_state.node_list:
return
@@ -295,9 +294,8 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh
pass
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
if refresh:
refresh_pad(2)
draw_window_arrows(2)
refresh_pad(2)
draw_window_arrows(2)
def refresh_main_window(window_id: int, selected: bool) -> None:
@@ -308,7 +306,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
refresh_pad(0)
elif window_id == 1:
paint_frame(messages_win, selected=selected, refresh=False)
paint_frame(messages_win, selected=selected)
refresh_pad(1)
elif window_id == 2:
paint_frame(nodes_win, selected=selected)
@@ -702,7 +700,7 @@ def draw_message_input(draft: str) -> None:
used += text_width(content[start])
visible = slice_to_width(content[start:], available)
draw_text_field(entry_win, prompt + visible, get_color("input"))
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
paint_frame(entry_win, selected=ui_state.input_focused)
try:
hint = " Ctrl+K Help "
if width >= len(hint) + 4:
@@ -937,28 +935,6 @@ def handle_enter(input_text: str) -> str:
return input_text
def stage_node_details_background(stdscr, previous_window):
"""Restore the panes behind a moved dialog without displaying an intermediate frame."""
current_window = ui_state.current_window
try:
ui_state.current_window = previous_window
stdscr.touchwin()
stdscr.noutrefresh()
windows = (channel_win, messages_win, nodes_win)
for index, win in enumerate(windows):
if ui_state.single_pane_mode and index != previous_window:
continue
win.touchwin()
refresh_pad(index, commit=False)
if ui_state.display_log and (not ui_state.single_pane_mode or previous_window == 1):
packetlog_win.touchwin()
packetlog_win.noutrefresh()
entry_win.touchwin()
entry_win.noutrefresh()
finally:
ui_state.current_window = current_window
def handle_f5_key(stdscr: curses.window) -> None:
if not ui_state.node_list:
return
@@ -1056,7 +1032,6 @@ def handle_f5_key(stdscr: curses.window) -> None:
ui_state.current_window = 4
scroll_offset = 0
dialog_win = None
previous_layout = None
curses.curs_set(0)
refresh_node_selection(highlight=True)
@@ -1076,15 +1051,11 @@ def handle_f5_key(stdscr: curses.window) -> None:
max_scroll = max(0, len(message_lines) - viewport_h)
scroll_offset = max(0, min(scroll_offset, max_scroll))
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
if previous_layout is not None and layout != previous_layout:
stage_node_details_background(stdscr, previous_window)
previous_layout = layout
if dialog_win is None:
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
else:
dialog_win.erase()
dialog_win.refresh()
dialog_win.resize(dialog_height, dialog_width)
dialog_win.mvwin(y, x)
@@ -1129,7 +1100,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
except curses.error:
pass
dialog_win.noutrefresh()
dialog_win.refresh()
msg_win.noutrefresh()
curses.doupdate()
@@ -1149,12 +1120,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
old_selected_node = ui_state.selected_node
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
scroll_offset = 0
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
refresh_node_selection(old_selected_node, highlight=True)
elif char == curses.KEY_DOWN:
old_selected_node = ui_state.selected_node
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
scroll_offset = 0
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
refresh_node_selection(old_selected_node, highlight=True)
elif char == curses.KEY_PPAGE:
scroll_offset = max(0, scroll_offset - viewport_h)
elif char == curses.KEY_NPAGE:
@@ -1671,7 +1642,7 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
paint_frame(messages_win, selected=(ui_state.current_window == 1))
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
@@ -1686,6 +1657,7 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
refresh_message_highlight()
messages_win.refresh()
refresh_pad(1)
draw_packetlog_win()
draw_window_arrows(1)
@@ -2015,7 +1987,7 @@ def search(win: int) -> None:
entry_win.erase()
def refresh_pad(window: int, commit: bool = True) -> None:
def refresh_pad(window: int) -> None:
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
if ui_state.single_pane_mode and window != ui_state.current_window:
@@ -2032,7 +2004,7 @@ def refresh_pad(window: int, commit: bool = True) -> None:
if ui_state.display_log:
packetlog_win.box()
packetlog_win.noutrefresh()
packetlog_win.refresh()
elif window == 2:
pad = nodes_pad
@@ -2075,12 +2047,9 @@ def refresh_pad(window: int, commit: bool = True) -> None:
return
draw_frame_title(box, get_window_title(window))
box.noutrefresh()
box.refresh()
# The frame overlaps the pad's viewport. Recompose even unchanged pad
# rows before committing, so its text cannot flash blank between draws.
pad.touchwin()
pad.noutrefresh(
pad.refresh(
start_index,
0,
top,
@@ -2088,8 +2057,6 @@ def refresh_pad(window: int, commit: bool = True) -> None:
bottom,
right,
)
if commit:
curses.doupdate()
def add_notification(channel_number: int) -> None:
+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
+5 -10
View File
@@ -1,9 +1,8 @@
import curses
import textwrap
from contact.ui.colors import get_color
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
def draw_splash(stdscr: object) -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
@@ -14,20 +13,16 @@ def draw_splash(stdscr: object, status: str = "connecting...") -> 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))
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.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.move(start_y + 5, start_x2)
stdscr.attrset(get_color("window_frame"))
stdscr.box()
+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."),
+2 -9
View File
@@ -1,6 +1,4 @@
import logging
import contextlib
import io
import time
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
@@ -27,7 +25,7 @@ def interface_is_connected(interface) -> bool:
return getattr(interface, "localNode", None) is not None
def initialize_interface(args, status_callback=None):
def initialize_interface(args):
try:
if args.ble:
@@ -45,10 +43,7 @@ def initialize_interface(args, status_callback=None):
logging.error(f"Error connecting to {args.host}. {ex}")
else:
try:
# 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)
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:
@@ -61,8 +56,6 @@ def initialize_interface(args, status_callback=None):
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}")
+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}")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.6.9"
version = "1.6.7"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+1 -60
View File
@@ -1,5 +1,4 @@
import unittest
from contextlib import ExitStack
from types import SimpleNamespace
from unittest import mock
@@ -12,52 +11,6 @@ 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")
@@ -484,8 +437,6 @@ class ContactUiTests(unittest.TestCase):
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"]
@@ -502,16 +453,9 @@ class ContactUiTests(unittest.TestCase):
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.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]
@@ -564,9 +508,6 @@ class ContactUiTests(unittest.TestCase):
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:
+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(