diff --git a/contact/localisations/en.ini b/contact/localisations/en.ini index 9558210..a0d6b7f 100644 --- a/contact/localisations/en.ini +++ b/contact/localisations/en.ini @@ -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", "" diff --git a/contact/localisations/ru.ini b/contact/localisations/ru.ini index a48a01d..2f19ad8 100644 --- a/contact/localisations/ru.ini +++ b/contact/localisations/ru.ini @@ -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, "Подождите", "" diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index 322ec9c..7dfce06 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -5,6 +5,7 @@ import logging import os import sys 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 @@ -34,7 +35,7 @@ MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals save_option = "Save Changes" max_help_lines = 0 help_win = None -sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] +sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset", "factory_reset_config"] # Compute the effective menu width for the current terminal @@ -43,7 +44,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__)) @@ -246,6 +247,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 @@ -538,7 +559,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(interface.localNode, 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(interface.localNode, full=False), + "Factory Reset Config Requested by menu", ) menu = rebuild_menu_at_current_path(interface, menu_state) menu_state.start_index.pop() diff --git a/contact/ui/menus.py b/contact/ui/menus.py index 7643305..e266e3e 100644 --- a/contact/ui/menus.py +++ b/contact/ui/menus.py @@ -133,6 +133,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, } ) diff --git a/tests/test_control_ui.py b/tests/test_control_ui.py index 3020308..f732260 100644 --- a/tests/test_control_ui.py +++ b/tests/test_control_ui.py @@ -1,4 +1,5 @@ from argparse import Namespace +from types import SimpleNamespace import unittest from unittest import mock @@ -63,3 +64,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"]) diff --git a/tests/test_menus.py b/tests/test_menus.py new file mode 100644 index 0000000..4ae95c9 --- /dev/null +++ b/tests/test_menus.py @@ -0,0 +1,28 @@ +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")