diff --git a/.gitignore b/.gitignore index 912a885..2c568ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ +venv/ .venv/ __pycache__/ client.db .DS_Store client.log +settings.log diff --git a/input_handlers.py b/input_handlers.py new file mode 100644 index 0000000..2298399 --- /dev/null +++ b/input_handlers.py @@ -0,0 +1,212 @@ +import curses +import ipaddress + +def get_user_input(prompt): + # Calculate the dynamic height and width for the input window + height = 7 # Fixed height for input prompt + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + # Create a new window for user input + input_win = curses.newwin(height, width, start_y, start_x) + input_win.clear() + input_win.border() + + # Display the prompt + input_win.addstr(1, 2, prompt, curses.A_BOLD) + input_win.addstr(3, 2, "Enter value: ") + input_win.refresh() + + # Enable user input + curses.echo() + curses.curs_set(1) + + user_input = "" + while True: + key = input_win.getch(3, 15 + len(user_input)) # Adjust cursor position dynamically + if key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + curses.noecho() + curses.curs_set(0) + return None # Exit without returning a value + elif key == ord('\n'): # Enter key + break + elif key == curses.KEY_BACKSPACE or key == 127: # Backspace + user_input = user_input[:-1] + input_win.addstr(3, 15, " " * (len(user_input) + 1)) # Clear the line + input_win.addstr(3, 15, user_input) + else: + user_input += chr(key) + input_win.addstr(3, 15, user_input) + + curses.curs_set(0) + curses.noecho() + + # Clear the input window + input_win.clear() + input_win.refresh() + return user_input + +def get_bool_selection(message, current_value): + message = "Select True or False:" if None else message + cvalue = current_value + options = ["True", "False"] + selected_index = 0 if current_value == "True" else 1 + + height = 7 + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + bool_win = curses.newwin(height, width, start_y, start_x) + bool_win.keypad(True) + + while True: + bool_win.clear() + bool_win.border() + bool_win.addstr(1, 2, message, curses.A_BOLD) + + for idx, option in enumerate(options): + if idx == selected_index: + bool_win.addstr(idx + 3, 4, option, curses.A_REVERSE) + else: + bool_win.addstr(idx + 3, 4, option) + + bool_win.refresh() + key = bool_win.getch() + + if key == curses.KEY_UP: + selected_index = max(0, selected_index - 1) + elif key == curses.KEY_DOWN: + selected_index = min(len(options) - 1, selected_index + 1) + elif key == ord('\n'): # Enter key + return options[selected_index] + elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + return cvalue + +def get_repeated_input(current_value): + cvalue = current_value + height = 10 + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + repeated_win = curses.newwin(height, width, start_y, start_x) + repeated_win.keypad(True) # Enable keypad for special keys + + curses.echo() + curses.curs_set(1) + user_input = "" + + while True: + repeated_win.clear() + repeated_win.border() + repeated_win.addstr(1, 2, "Enter comma-separated values:", curses.A_BOLD) + repeated_win.addstr(3, 2, f"Current: {', '.join(current_value)}") + repeated_win.addstr(5, 2, f"New value: {user_input}") + repeated_win.refresh() + + key = repeated_win.getch() + + if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow + curses.noecho() + curses.curs_set(0) + return cvalue # Return the current value without changes + elif key == ord('\n'): # Enter key to save and return + curses.noecho() + curses.curs_set(0) + return user_input.split(",") # Split the input into a list + elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key + user_input = user_input[:-1] + else: + try: + user_input += chr(key) # Append valid character input + except ValueError: + pass # Ignore invalid character inputs + +def get_enum_input(options, current_value): + selected_index = options.index(current_value) if current_value in options else 0 + + height = min(len(options) + 4, curses.LINES - 2) + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + enum_win = curses.newwin(height, width, start_y, start_x) + enum_win.keypad(True) + + while True: + enum_win.clear() + enum_win.border() + enum_win.addstr(1, 2, "Select an option:", curses.A_BOLD) + + for idx, option in enumerate(options): + if idx == selected_index: + enum_win.addstr(idx + 2, 4, option, curses.A_REVERSE) + else: + enum_win.addstr(idx + 2, 4, option) + + enum_win.refresh() + key = enum_win.getch() + + if key == curses.KEY_UP: + selected_index = max(0, selected_index - 1) + elif key == curses.KEY_DOWN: + selected_index = min(len(options) - 1, selected_index + 1) + elif key == ord('\n'): # Enter key + return options[selected_index] + elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + return current_value + + +def get_fixed32_input(current_value): + cvalue = current_value + current_value = str(ipaddress.IPv4Address(current_value)) + height = 10 + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + fixed32_win = curses.newwin(height, width, start_y, start_x) + fixed32_win.keypad(True) + + curses.echo() + curses.curs_set(1) + user_input = "" + + while True: + fixed32_win.clear() + fixed32_win.border() + fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD) + fixed32_win.addstr(3, 2, f"Current: {current_value}") + fixed32_win.addstr(5, 2, f"New value: {user_input}") + fixed32_win.refresh() + + key = fixed32_win.getch() + + if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel + curses.noecho() + curses.curs_set(0) + return cvalue # Return the current value unchanged + elif key == ord('\n'): # Enter key to validate and save + # Validate IP address + octets = user_input.split(".") + if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets): + curses.noecho() + curses.curs_set(0) + fixed32_address = ipaddress.ip_address(user_input) + return int(fixed32_address) # Return the valid IP address + else: + fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5)) + fixed32_win.refresh() + curses.napms(1500) # Wait for 1.5 seconds before refreshing + user_input = "" # Clear invalid input + elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key + user_input = user_input[:-1] + else: + try: + char = chr(key) + if char.isdigit() or char == ".": + user_input += char # Append only valid characters (digits or dots) + except ValueError: + pass # Ignore invalid inputs \ No newline at end of file diff --git a/save_to_radio.py b/save_to_radio.py new file mode 100644 index 0000000..e7ae25d --- /dev/null +++ b/save_to_radio.py @@ -0,0 +1,134 @@ +from meshtastic.protobuf import channel_pb2 +from google.protobuf.message import Message +import logging +import base64 + +def settings_reboot(interface): + interface.localNode.reboot() + +def settings_reset_nodedb(interface): + interface.localNode.resetNodeDb() + +def settings_shutdown(interface): + interface.localNode.shutdown() + +def settings_factory_reset(interface): + interface.localNode.factoryReset() + +def settings_set_owner(interface, long_name=None, short_name=None, is_licensed=False): + if isinstance(is_licensed, str): + is_licensed = is_licensed.lower() == 'true' + interface.localNode.setOwner(long_name, short_name, is_licensed) + + +def save_changes(interface, menu_path, modified_settings): + """ + Save changes to the device based on modified settings. + :param interface: Meshtastic interface instance + :param menu_path: Current menu path + :param modified_settings: Dictionary of modified settings + """ + try: + if not modified_settings: + logging.info("No changes to save. modified_settings is empty.") + return + + node = interface.getNode('^local') + + if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings": + config_category = menu_path[2].lower() # for radio and module configs + + elif menu_path[1] == "User Settings": # for user configs + config_category = "User Settings" + long_name = modified_settings.get("longName", None) + short_name = modified_settings.get("shortName", None) + #TODO add is_licensed + node.setOwner(long_name, short_name, is_licensed=False) + logging.info(f"Updated {config_category} with Long Name: {long_name} and Short Name {short_name}") + return + + + elif menu_path[1] == "Channels": # for channel configs + config_category = "Channels" + + try: + channel = menu_path[-1] + channel_num = int(channel.split()[-1]) + except (IndexError, ValueError) as e: + channel_num = None + + channel = node.channels[channel_num] + for key, value in modified_settings.items(): + if key == 'psk': # Special case: decode Base64 for psk + channel.settings.psk = base64.b64decode(value) + elif key == 'position_precision': # Special case: module_settings + channel.settings.module_settings.position_precision = value + 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}") + logging.info(node.channels) + return + + else: + config_category = None + + + + for config_item, new_value in modified_settings.items(): + # Check if the category exists in localConfig + if hasattr(node.localConfig, config_category): + config_subcategory = getattr(node.localConfig, config_category) + # Check if the category exists in moduleConfig + elif hasattr(node.moduleConfig, config_category): + config_subcategory = getattr(node.moduleConfig, config_category) + else: + logging.warning(f"Config category '{config_category}' not found in config.") + continue + + # Check if the config_item exists in the subcategory + if hasattr(config_subcategory, config_item): + field = getattr(config_subcategory, config_item) + + try: + if isinstance(field, (int, float, str, bool)): # Direct field types + setattr(config_subcategory, config_item, new_value) + logging.info(f"Updated {config_category}.{config_item} to {new_value}") + elif isinstance(field, Message): # Handle protobuf sub-messages + if isinstance(new_value, dict): # If new_value is a dictionary + for sub_field, sub_value in new_value.items(): + if hasattr(field, sub_field): + setattr(field, sub_field, sub_value) + logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}") + else: + logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}") + else: + logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.") + else: + logging.warning(f"Unsupported field type for {config_category}.{config_item}.") + except AttributeError as e: + logging.error(f"Failed to update {config_category}.{config_item}: {e}") + else: + logging.warning(f"Config item '{config_item}' not found in config category '{config_category}'.") + + # Write the configuration changes to the node + try: + node.writeConfig(config_category) + logging.info(f"Changes written to config category: {config_category}") + except Exception as e: + logging.error(f"Failed to write configuration for category '{config_category}': {e}") + + + node.writeConfig(config_category) + + logging.info(f"Changes written to config category: {config_category}") + + except Exception as e: + logging.error(f"Error saving changes: {e}") diff --git a/settings.py b/settings.py index b9efb88..b2cab6c 100644 --- a/settings.py +++ b/settings.py @@ -1,801 +1,243 @@ import curses -import ipaddress +import meshtastic.serial_interface -import meshtastic.serial_interface, meshtastic.tcp_interface -from meshtastic.protobuf import config_pb2, module_config_pb2, mesh_pb2, channel_pb2 -import globals +from save_to_radio import settings_factory_reset, settings_reboot, settings_reset_nodedb, settings_shutdown +from ui.menus import generate_menu_from_protobuf +from input_handlers import get_bool_selection, get_repeated_input, get_user_input, get_enum_input, get_fixed32_input +from save_to_radio import save_changes -def display_enum_menu(stdscr, enum_values, menu_item): - menu_height = len(enum_values) + 2 - menu_width = max(len(option) for option in enum_values) + 4 - y_start = (curses.LINES - menu_height) // 2 - x_start = (curses.COLS - menu_width) // 2 +import logging - # Maximum number of rows to display - max_rows = 10 - # Calculate popup window dimensions and position - popup_height = min(len(enum_values), max_rows) + 2 - popup_width = max(len(option) for option in enum_values) + 6 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 +def display_menu(current_menu, menu_path, selected_index, show_save_option): + global menu_win + # Calculate the dynamic height based on the number of menu items + num_items = len(current_menu) + (1 if show_save_option else 0) # Add 1 for the "Save Changes" option if applicable + height = min(curses.LINES - 2, num_items + 5) # Ensure the menu fits within the terminal height + width = 60 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 - # Create the popup window - try: - popup_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) + # Create a new curses window with dynamic dimensions + menu_win = curses.newwin(height, width, start_y, start_x) + menu_win.clear() + menu_win.border() + menu_win.keypad(True) - # Enable keypad mode - popup_win.keypad(True) + # Display the current menu path as a header + header = " > ".join(word.title() for word in menu_path) + if len(header) > width - 4: + header = header[:width - 7] + "..." + menu_win.addstr(1, 2, header, curses.A_BOLD) - # Display enum values in the popup window - start_index = 0 # Starting index of displayed items - while True: - popup_win.clear() - popup_win.border() + # Display the menu options + for idx, option in enumerate(current_menu): + field_info = current_menu[option] + current_value = field_info[1] if isinstance(field_info, tuple) else "" + display_option = f"{option}"[:width // 2 - 2] # Truncate option name if too long + display_value = f"{current_value}"[:width // 2 - 4] # Truncate value if too long - # Calculate the starting index based on the menu item and window size - if menu_item >= start_index + max_rows: - start_index += 1 - elif menu_item < start_index: - start_index -= 1 - - # Display enum values within the window height - for i in range(min(len(enum_values) - start_index, max_rows)): - option_index = start_index + i - if option_index == menu_item: - popup_win.addstr(i + 1, 2, enum_values[option_index], curses.A_REVERSE) + try: + if idx == selected_index: + menu_win.addstr(idx + 3, 4, f"{display_option:<{width // 2 - 2}} {display_value}", curses.A_REVERSE) else: - popup_win.addstr(i + 1, 2, enum_values[option_index]) + menu_win.addstr(idx + 3, 4, f"{display_option:<{width // 2 - 2}} {display_value}") + except curses.error: + pass - popup_win.refresh() - - char = popup_win.getch() - if char == curses.KEY_DOWN: - if menu_item < len(enum_values) - 1: - menu_item += 1 - elif char == curses.KEY_UP: - if menu_item > 0: - menu_item -= 1 - elif char == ord('\n'): - selected_option = enum_values[menu_item] - popup_win.clear() - popup_win.refresh() - return selected_option, True - elif char == 27 or char == curses.KEY_LEFT: # Check if escape key is pressed - curses.curs_set(0) - popup_win.refresh() - return None, False - -def get_string_input(stdscr, setting_string): - popup_height = 5 - popup_width = 40 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 - - try: - input_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) - - input_win.border() - input_win.keypad(True) - input_win.refresh() - - input_win.addstr(1, 1, str(setting_string)) # Prepopulate input field with the setting value - input_win.refresh() - # Get user input - curses.curs_set(1) - input_text = "" - - while True: - # Display the current input text - input_win.addstr(1, 1, input_text) - input_win.border() - input_win.refresh() - - # Get a character from the user - key = stdscr.getch() - - if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key - curses.curs_set(0) - input_win.clear() - input_win.refresh() - return input_text, True - elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key - # Delete the last character from input_text - input_text = input_text[:-1] - elif 32 <= key <= 126: # Printable ASCII characters - # Append the character to input_text - input_text += chr(key) - elif key == 27: # Check if escape key is pressed - curses.curs_set(0) - input_win.refresh() - return None, False - - input_win.clear() - input_win.refresh() - - -def get_uint_input(stdscr, setting_string): - popup_height = 5 - popup_width = 40 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 - - try: - input_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) - - input_win.border() - input_win.keypad(True) - input_win.refresh() - - input_win.addstr(1, 1, str(setting_string)) # Prepopulate input field with the setting value - input_win.refresh() - curses.curs_set(1) - input_text = "" - - while True: - # Display the current input text - input_win.addstr(1, 1, input_text) - input_win.border() - input_win.refresh() - - # Get a character from the user - key = stdscr.getch() - - if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key - curses.curs_set(0) - input_win.clear() - input_win.refresh() - return int(input_text), True - elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key - # Delete the last character from input_text - input_text = input_text[:-1] - elif 48 <= key <= 57: # Numbers(ASCII range) - # Append the character to input_text - input_text += chr(key) - elif key == 27 or key == curses.KEY_LEFT: # Check if escape key is pressed - curses.curs_set(0) - input_win.refresh() - return None, False - - input_win.clear() - input_win.refresh() - - -def get_uint32_list_input(stdscr, setting_string): - setting_string = [str(num) for num in setting_string] - - popup_height = 8 # Increased height to accommodate three lines - popup_width = 40 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 - - try: - input_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) - - input_win.border() - input_win.keypad(True) - input_win.refresh() - - input_text = setting_string[:] # Copy the input strings - curses.curs_set(0) - - while True: - # Display the current input text for each line - for i, line in enumerate(input_text): - input_win.addstr(1 + i, 1, line) - - input_win.border() - input_win.refresh() - - # Get a character from the user - key = stdscr.getch() - - if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key - input_win.clear() - input_win.refresh() - return None, False # TODO allow setting this - # return input_text, True - # elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key - # # Delete the last character from the current line's input_text - # current_y, current_x = input_win.getyx() - # input_text[current_y - 1] = input_text[current_y - 1][:-1] - # elif (48 <= key <= 57) or key == 44: # Numbers and comma (ASCII range) - # # Append the character to the current line's input_text - # current_y, current_x = input_win.getyx() - # input_text[current_y - 1] += chr(key) - elif key == 27 or key == curses.KEY_LEFT: # Check if escape key is pressed - curses.curs_set(0) - input_win.refresh() - return None, False - - input_win.clear() - input_win.refresh() - -def get_float_input(stdscr, setting_string): - popup_height = 5 - popup_width = 40 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 - - try: - input_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) - - input_win.border() - input_win.keypad(True) - input_win.refresh() - - input_win.addstr(1, 1, str(setting_string)) # Prepopulate input field with the setting value - input_win.refresh() - curses.curs_set(1) - input_text = "" - - while True: - # Display the current input text - input_win.addstr(1, 1, input_text) - input_win.border() - input_win.refresh() - - # Get a character from the user - key = stdscr.getch() - - if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key - curses.curs_set(0) - input_win.clear() - input_win.refresh() - return float(input_text), True - elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key - # Delete the last character from input_text - input_text = input_text[:-1] - elif (48 <= key <= 57) or key == 46: # Numbers and decimal point (ASCII range) - # Append the character to input_text - input_text += chr(key) - elif key == 27 or key == curses.KEY_LEFT: # Check if escape key is pressed - curses.curs_set(0) - input_win.refresh() - return None, False - - input_win.clear() - input_win.refresh() - - -def ip_to_fixed32(ip): - # Parse the IP address - ip_obj = ipaddress.ip_address(ip) - # Convert IP address to 32-bit integer - return int(ip_obj) - -def fixed32_to_ip(fixed32): - # Convert 32-bit integer to IPv4Address object - ip_obj = ipaddress.IPv4Address(fixed32) - # Convert IPv4Address object to string representation - return str(ip_obj) - -def get_fixed32_input(stdscr, setting_string): - popup_height = 5 - popup_width = 40 - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 - - try: - input_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) - - input_win.border() - input_win.keypad(True) - input_win.refresh() - - input_win.addstr(1, 1, fixed32_to_ip(setting_string)) # Prepopulate input field with the setting value - input_win.refresh() - curses.curs_set(1) - input_text = "" - - while True: - # Display the current input text - input_win.addstr(1, 1, input_text) - input_win.border() - input_win.refresh() - - # Get a character from the user - key = stdscr.getch() - - if key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key - curses.curs_set(0) - input_win.clear() - input_win.refresh() - return ip_to_fixed32(input_text), True - elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key - # Delete the last character from input_text - input_text = input_text[:-1] - elif 48 <= key <= 57 or key == 46: # Numbers + period (ASCII range) - # Append the character to input_text - input_text += chr(key) - elif key == 27 or key == curses.KEY_LEFT: # Check if escape key is pressed - curses.curs_set(0) - input_win.refresh() - return None, False - - input_win.clear() - input_win.refresh() - - -def display_bool_menu(stdscr, setting_value): - bool_options = ["False", "True"] - return display_enum_menu(stdscr, bool_options, setting_value) - - -def generate_menu_from_protobuf(message_instance): - if not hasattr(message_instance, "DESCRIPTOR"): - return # This is not a protobuf message instance, exit - menu = {} - - field_names = message_instance.DESCRIPTOR.fields_by_name.keys() - for field_name in field_names: - field_descriptor = message_instance.DESCRIPTOR.fields_by_name[field_name] - if field_descriptor is not None: - nested_message_instance = getattr(message_instance, field_name) - menu[field_name] = generate_menu_from_protobuf(nested_message_instance) - return menu - - -def change_setting(stdscr, menu_path): - node = globals.interface.localNode - field_descriptor = None - setting_value = 0 - - stdscr.clear() - stdscr.border() - stdscr.refresh() - menu_header(stdscr, f"{menu_path[-1]}") - - # Determine the level of nesting based on the length of menu_path - - if menu_path[1] == "User Settings": - n = globals.interface.getMyNodeInfo() - - setting_string = n['user'].get(snake_to_camel(menu_path[2]), 0) - - if menu_path[2] == "is_licensed": - setting_value, do_change_setting = display_bool_menu(stdscr, setting_string) + # Show save option if applicable + if show_save_option: + save_option = "Save Changes" + save_position = height - 2 + if selected_index == len(current_menu): + menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, curses.A_REVERSE) else: - setting_value, do_change_setting = get_string_input(stdscr, setting_string) + menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option) - if not do_change_setting: - stdscr.clear() - stdscr.border() - menu_path.pop() - return + menu_win.refresh() - if menu_path[2] in ["long_name", "short_name"]: - if menu_path[2] == "short_name" and len(setting_value) > 4: - setting_value = setting_value[:4] - settings_set_owner(long_name=setting_value if menu_path[2] == "long_name" else None, - short_name=setting_value if menu_path[2] == "short_name" else None) - elif menu_path[2] == "is_licensed": - ln = n['user']['longName'] - settings_set_owner(long_name=ln, is_licensed=setting_value) +def settings_menu(sdscr, interface): - stdscr.clear() - stdscr.border() - menu_path.pop() - return - - if menu_path[1] == "Channels": - return - # n = interface.getChannelByChannelIndex() - # setting_string = getattr(getattr(n.channelSettings, str(menu_path[2])), menu_path[3]) - # field_descriptor = getattr(n.channelSettings, menu_path[2]).DESCRIPTOR.fields_by_name[menu_path[3]] - - - - if len(menu_path) == 4: - if menu_path[1] == "Radio Settings": - setting_string = getattr(getattr(node.localConfig, str(menu_path[2])), menu_path[3]) - field_descriptor = getattr(node.localConfig, menu_path[2]).DESCRIPTOR.fields_by_name[menu_path[3]] - - elif menu_path[1] == "Module Settings": - setting_string = getattr(getattr(node.moduleConfig, str(menu_path[2])), menu_path[3]) - field_descriptor = getattr(node.moduleConfig, menu_path[2]).DESCRIPTOR.fields_by_name[menu_path[3]] - - - elif len(menu_path) == 5: - if menu_path[1] == "Radio Settings": - setting_string = getattr(getattr(getattr(node.localConfig, str(menu_path[2])), menu_path[3]), menu_path[4]) - field_descriptor = getattr(getattr(node.localConfig, menu_path[2]), menu_path[3]).DESCRIPTOR.fields_by_name[menu_path[4]] - - elif menu_path[1] == "Module Settings": - setting_string = getattr(getattr(getattr(node.moduleConfig, str(menu_path[2])), menu_path[3]), menu_path[4]) - field_descriptor = getattr(getattr(node.moduleConfig, menu_path[2]), menu_path[3]).DESCRIPTOR.fields_by_name[menu_path[4]] - - - if field_descriptor.enum_type is not None: - enum_values = [enum_value.name for enum_value in field_descriptor.enum_type.values] - enum_option, do_change_setting = display_enum_menu(stdscr, enum_values, setting_string) - setting_value = enum_option - - elif field_descriptor.type == 8: # Field type 8 corresponds to BOOL - setting_value, do_change_setting = display_bool_menu(stdscr, setting_string) - - elif field_descriptor.type == 9: # Field type 9 corresponds to STRING - setting_value, do_change_setting = get_string_input(stdscr, setting_string) - - elif field_descriptor.type == 2: # Field type 2 corresponds to FLOAT - setting_value, do_change_setting = get_float_input(stdscr, setting_string) - - elif field_descriptor.type == 13: # Field type 13 corresponds to UINT32 - if field_descriptor.label == field_descriptor.LABEL_REPEATED: - setting_value, do_change_setting = get_uint32_list_input(stdscr, setting_string) - else: - setting_value, do_change_setting = get_uint_input(stdscr, setting_string) - - elif field_descriptor.type == 7: # Field type 7 corresponds to FIXED32 - setting_value, do_change_setting = get_fixed32_input(stdscr, setting_string) - - else: - menu_path.pop() - return - - if not do_change_setting: - stdscr.clear() - stdscr.border() - menu_path.pop() - return - - # formatted_text = f"{menu_path[2]}.{menu_path[3]} = {setting_value}" - # menu_header(stdscr,formatted_text,2) - - ourNode = globals.interface.localNode - - # Convert "true" to 1, "false" to 0, leave other values as they are - if setting_value == "True" or setting_value == "1": - setting_value_int = 1 - elif setting_value == "False" or setting_value == "0": - setting_value_int = 0 - else: - # If setting_value is not "true" or "false", keep it as it is - setting_value_int = setting_value - - # if isinstance(setting_value_int, list): - # value_string = ', '.join(str(item) for item in setting_value_int) - # setting_value_int = value_string - - try: - if len(menu_path) == 4: - if menu_path[1] == "Radio Settings": - setattr(getattr(ourNode.localConfig, menu_path[2]), menu_path[3], setting_value_int) - elif menu_path[1] == "Module Settings": - setattr(getattr(ourNode.moduleConfig, menu_path[2]), menu_path[3], setting_value_int) - - elif len(menu_path) == 5: - if menu_path[1] == "Radio Settings": - setattr(getattr(getattr(ourNode.localConfig, menu_path[2]), menu_path[3]), menu_path[4], setting_value_int) - elif menu_path[1] == "Module Settings": - setattr(getattr(getattr(ourNode.moduleConfig, menu_path[2]), menu_path[3]), menu_path[4], setting_value_int) - - except AttributeError as e: - print("Error setting attribute:", e) - - - ourNode.writeConfig(menu_path[2]) - menu_path.pop() - -def snake_to_camel(snake_str): - components = snake_str.split('_') - return components[0] + ''.join(x.title() for x in components[1:]) - - -def display_values(stdscr, key_list, menu_path): - node = globals.interface.localNode - user_settings = ["long_name", "short_name", "is_licensed"] - for i, key in enumerate(key_list): - - if len(menu_path) == 2: - if menu_path[1] == 'User Settings': - n = globals.interface.getMyNodeInfo() - try: - setting = n['user'][snake_to_camel(key_list[i])] - except: - setting = None - if key_list[i] in user_settings: - stdscr.addstr(i+3, 40, str(setting)) - - if len(menu_path) == 3: - if menu_path[1] == "Radio Settings": - setting = getattr(getattr(node.localConfig, menu_path[2]), key_list[i]) - if menu_path[1] == "Module Settings": - setting = getattr(getattr(node.moduleConfig, menu_path[2]), key_list[i]) - stdscr.addstr(i+3, 40, str(setting)[:14]) - - if len(menu_path) == 4: - if menu_path[1] == "Radio Settings": - setting = getattr(getattr(getattr(node.localConfig, menu_path[2]), menu_path[3]), key_list[i]) - if menu_path[1] == "Module Settings": - setting = getattr(getattr(getattr(node.moduleConfig, menu_path[2]), menu_path[3]), key_list[i]) - stdscr.addstr(i+3, 40, str(setting)[:14]) - - stdscr.refresh() - -def menu_header(window, text, start_y=1): - window.clear() - window.box() - window.refresh() - _, window_width = window.getmaxyx() - start_x = (window_width - len(text)) // 2 - formatted_text = text.replace('_', ' ').title() - window.addstr(start_y, start_x, formatted_text) - window.refresh() - -def nested_menu(stdscr, menu): - menu_item = 0 - current_menu = menu - prev_menu = [] - menu_index = 0 - next_key = None - - key_list = [] + menu = generate_menu_from_protobuf(interface) + current_menu = menu["Main Menu"] menu_path = ["Main Menu"] - - last_menu_level = False + selected_index = 0 + modified_settings = {} + while True: - - if current_menu is not None: - menu_header(stdscr, f"{menu_path[menu_index]}") + options = list(current_menu.keys()) - # Display current menu - for i, key in enumerate(current_menu.keys(), start=0): - if i == menu_item: - if key in ["Reboot", "Reset NodeDB", "Shutdown", "Factory Reset"]: - stdscr.addstr(i+3, 1, key, curses.color_pair(5)) - else: - stdscr.addstr(i+3, 1, key, curses.A_REVERSE) - else: - stdscr.addstr(i+3, 1, key) + show_save_option = ( + len(menu_path) > 2 and ("Radio Settings" in menu_path or "Module Settings" in menu_path) + ) or ( + len(menu_path) == 2 and "User Settings" in menu_path + ) or ( + len(menu_path) == 3 and "Channels" in menu_path + ) - # Display current values - display_values(stdscr, key_list, menu_path) + # Display the menu + display_menu(current_menu, menu_path, selected_index, show_save_option) - char = stdscr.getch() + # Capture user input + key = menu_win.getch() - selected_key = list(current_menu.keys())[menu_item] - selected_value = current_menu[selected_key] + if key == curses.KEY_UP: + selected_index = max(0, selected_index - 1) + + elif key == curses.KEY_DOWN: + max_index = len(options) + (1 if show_save_option else 0) - 1 + selected_index = min(max_index, selected_index + 1) - if char == curses.KEY_DOWN: - if last_menu_level == True: - last_menu_level = False - menu_item = min(len(current_menu) - 1, menu_item + 1) + elif key == curses.KEY_RIGHT or key == ord('\n'): + menu_win.clear() + menu_win.refresh() + if show_save_option and selected_index == len(options): + save_changes(interface, menu_path, modified_settings) + modified_settings.clear() + logging.info("Changes Saved") - elif char == curses.KEY_UP: - if last_menu_level == True: - last_menu_level = False - menu_item = max(0, menu_item - 1) - - elif char == curses.KEY_RIGHT: - # if selected_key == "Region": - # settings_region() - # break - if selected_key == "Channels": - channels_editor(stdscr) - elif selected_key not in ["Reboot", "Reset NodeDB", "Shutdown", "Factory Reset"]: - menu_path.append(selected_key) - - if isinstance(selected_value, dict): - # If the selected item is a submenu, navigate to it - prev_menu.append(current_menu) - menu_index += 1 - current_menu = selected_value - menu_item = 0 - last_menu_level = False - else: - last_menu_level = True - - - elif char == curses.KEY_LEFT: - if last_menu_level == True: - last_menu_level = False if len(menu_path) > 1: menu_path.pop() - current_menu = prev_menu[menu_index-1] - del prev_menu[menu_index-1] - menu_index -= 1 - menu_item = 0 + current_menu = menu["Main Menu"] + for step in menu_path[1:]: + current_menu = current_menu.get(step, {}) + selected_index = 0 - elif char == ord('\n'): - if selected_key == "Channels": - channels_editor(stdscr) - if selected_key == "Reboot": - settings_reboot() - elif selected_key == "Reset NodeDB": - settings_reset_nodedb() - elif selected_key == "Shutdown": - settings_shutdown() - elif selected_key == "Factory Reset": - settings_factory_reset() + continue - elif selected_value is not None: - stdscr.refresh() - stdscr.getch() - - elif char == 27: # escape to exit menu + selected_option = options[selected_index] + + if selected_option == "Exit": break + elif selected_option == "Reboot": + confirmation = get_bool_selection("Are you sure you want to Reboot?", 0) + if confirmation == "True": + settings_reboot(interface) + logging.info(f"Node Reboot Requested by menu") + continue - if char: - stdscr.clear() - stdscr.border() + elif selected_option == "Reset Node DB": + confirmation = get_bool_selection("Are you sure you want to Reset Node DB?", 0) + if confirmation == "True": + settings_reset_nodedb(interface) + logging.info(f"Node DB Reset Requested by menu") + continue + elif selected_option == "Shutdown": + confirmation = get_bool_selection("Are you sure you want to Shutdown?", 0) + if confirmation == "True": + settings_shutdown(interface) + logging.info(f"Node Shutdown Requested by menu") + continue + elif selected_option == "Factory Reset": + confirmation = get_bool_selection("Are you sure you want to Factory Reset?", 0) + if confirmation == "True": + settings_factory_reset(interface) + logging.info(f"Factory Reset Requested by menu") + continue + field_info = current_menu.get(selected_option) - next_key = list(current_menu.keys())[menu_item] - key_list = list(current_menu.keys()) + if isinstance(field_info, tuple): + field, current_value = field_info - else: - break # Exit loop if current_menu is None + if selected_option == 'longName' or selected_option == 'shortName': + new_value = get_user_input(f"Current value for {selected_option}: {current_value}") + + modified_settings[selected_option] = (new_value) + current_menu[selected_option] = (field, new_value) - if last_menu_level == True: - if not isinstance(current_menu.get(next_key), dict): - change_setting(stdscr, menu_path) + elif field.type == 8: # Handle boolean type + new_value = get_bool_selection(selected_option, str(current_value)) + try: + # Validate and convert input to a valid boolean + if isinstance(new_value, str): + # Handle string representations of booleans + new_value_lower = new_value.lower() + if new_value_lower in ("true", "yes", "1", "on"): + new_value = True + elif new_value_lower in ("false", "no", "0", "off"): + new_value = False + else: + raise ValueError("Invalid string for boolean") + else: + # Convert other types directly to bool + new_value = bool(new_value) + + except ValueError as e: + logging.info(f"Invalid input for boolean: {e}") -def settings(stdscr): - popup_height = 22 - popup_width = 60 - popup_win = None - y_start = (curses.LINES - popup_height) // 2 - x_start = (curses.COLS - popup_width) // 2 + elif field.label == field.LABEL_REPEATED: # Handle repeated field + new_value = get_repeated_input(current_value) - curses.curs_set(0) - try: - popup_win = curses.newwin(popup_height, popup_width, y_start, x_start) - except curses.error as e: - print("Error occurred while initializing curses window:", e) + elif field.enum_type: # Enum field + enum_options = [v.name for v in field.enum_type.values] + new_value = get_enum_input(enum_options, current_value) - popup_win.border() - popup_win.keypad(True) - - # Generate menu from protobuf for both radio and module settings + elif field.type == 7: # Field type 7 corresponds to FIXED32 + new_value = get_fixed32_input(current_value) + + elif field.type == 13: # Field type 13 corresponds to UINT32 + new_value = get_user_input(f"Current value for {selected_option}: {current_value}") + new_value = current_value if new_value is None else int(new_value) + + elif field.type == 2: # Field type 13 corresponds to INT64 + new_value = get_user_input(f"Current value for {selected_option}: {current_value}") + new_value = current_value if new_value is None else float(new_value) - user = mesh_pb2.User() - user_settings = ["long_name", "short_name", "is_licensed"] - user_config = generate_menu_from_protobuf(user) - user_config = {key: value for key, value in user_config.items() if key in user_settings} + else: # Handle other field types + new_value = get_user_input(f"Current value for {selected_option}: {current_value}") + new_value = current_value if new_value is None else new_value + + # Navigate to the correct nested dictionary based on the menu_path + current_nested = modified_settings + for key in menu_path[3:]: # Skip "Main Menu" + current_nested = current_nested.setdefault(key, {}) - channel = channel_pb2.ChannelSettings() - channel_config = generate_menu_from_protobuf(channel) - channel_config = [channel_config.copy() for i in range(8)] + # Add the new value to the appropriate level + current_nested[selected_option] = new_value - - radio = config_pb2.Config() - radio_config = generate_menu_from_protobuf(radio) - - module = module_config_pb2.ModuleConfig() - module_config = generate_menu_from_protobuf(module) - - # Add top-level menu items - top_level_menu = { - "User Settings": user_config, - "Channels": None, - "Radio Settings": radio_config, - "Module Settings": module_config, - "Reboot": None, - "Reset NodeDB": None, - "Shutdown": None, - "Factory Reset": None - } - - # Call nested_menu function to display and handle the nested menu - nested_menu(popup_win, top_level_menu) - - # Close the popup window - popup_win.clear() - popup_win.refresh() - -# def settings_region(): -# selected_option, do_set = set_region() -# if do_set: -# ourNode = interface.localNode -# setattr(ourNode.localConfig.lora, "region", selected_option) -# ourNode.writeConfig("lora") - -def settings_reboot(): - globals.interface.localNode.reboot() - -def settings_reset_nodedb(): - globals.interface.localNode.resetNodeDb() - -def settings_shutdown(): - globals.interface.localNode.shutdown() - -def settings_factory_reset(): - globals.interface.localNode.factoryReset() - -def settings_set_owner(long_name=None, short_name=None, is_licensed=False): - if is_licensed == 'True': - is_licensed = True - elif is_licensed == 'False': - is_licensed = False - globals.interface.localNode.setOwner(long_name, short_name, is_licensed) - - - -def channels_editor(stdscr): - # Define the list of channels - channels = [f"{i}" for i in range(8)] - - # Initialize menu item index and selected channel - menu_item = 0 - selected_channel = channels[menu_item] - - while True: - # Display the list of channels in the curses window - menu_header(stdscr, "Channels") - - # Fetch and print roles for each channel - for index, channel_index in enumerate(channels): - channel = globals.interface.localNode.getChannelByChannelIndex(index) - role = "DISABLED" if channel.role == 0 else "PRIMARY" if channel.role == 1 else "SECONDARY" - channel_settings = channel.settings - channel_name = channel_settings.name - if not channel_name and role != "DISABLED": - config = globals.interface.localNode.localConfig - channel_name_int = config.lora.modem_preset - channel_name = config_pb2.Config.LoRaConfig.ModemPreset.Name(channel_name_int) - - channel_name_formatted = f"{channel_name:<13}" # Adjust channel name to be 13 characters wide - role_formatted = f"{role:<9}" # Adjust role to be 9 characters wide (SECONDARY = 9 chars) - - if index == menu_item: - stdscr.addstr(index + 3, 1, f"{channel_index} ", curses.A_REVERSE) - stdscr.addstr(index + 3, 3, f"{channel_name_formatted}", curses.A_REVERSE) - stdscr.addstr(index + 3, 15, f"{role_formatted}", curses.A_REVERSE) + # modified_settings[selected_option] = (new_value) + current_menu[selected_option] = (field, new_value) else: - stdscr.addstr(index + 3, 1, f"{channel_index}") - stdscr.addstr(index + 3, 3, f"{channel_name_formatted}") - stdscr.addstr(index + 3, 15, f"{role_formatted}") + current_menu = current_menu[selected_option] + menu_path.append(selected_option) + selected_index = 0 - stdscr.refresh() + elif key == curses.KEY_LEFT: - key = stdscr.getch() + menu_win.clear() + menu_win.refresh() - if key == curses.KEY_DOWN: - menu_item = min(len(channels) - 1, menu_item + 1) - elif key == curses.KEY_UP: - menu_item = max(0, menu_item - 1) - elif key == ord('\n'): - # Handle selection action - selected_channel = channels[menu_item] - # Perform action here, if needed - elif key == 27 or key == curses.KEY_LEFT: # escape to exit menu + modified_settings.clear() + + # Navigate back to the previous menu + if len(menu_path) > 1: + menu_path.pop() + current_menu = menu["Main Menu"] + for step in menu_path[1:]: + current_menu = current_menu.get(step, {}) + selected_index = 0 + + elif key == 27: # Escape key break - selected_channel = channels[menu_item] - - return selected_channel - +def main(stdscr): + logging.basicConfig( # Run `tail -f client.log` in another terminal to view live + filename="settings.log", + level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL) + format="%(asctime)s - %(levelname)s - %(message)s" + ) + curses.curs_set(0) + stdscr.keypad(True) + interface = meshtastic.serial_interface.SerialInterface() + stdscr.clear() + stdscr.refresh() + settings_menu(stdscr, interface) if __name__ == "__main__": - - globals.interface = meshtastic.serial_interface.SerialInterface() - - # radio = config_pb2.Config() - # module = module_config_pb2.ModuleConfig() - # print(generate_menu_from_protobuf(radio, interface)) - # print(generate_menu_from_protobuf(module, interface)) - - def main(stdscr): - stdscr.keypad(True) - while True: - settings(stdscr) - curses.wrapper(main) \ No newline at end of file diff --git a/ui/curses_ui.py b/ui/curses_ui.py index aa1928f..05d2952 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -2,10 +2,11 @@ import curses import textwrap import globals from utilities.utils import get_name_from_number, get_channels -from settings import settings +from settings import settings_menu from message_handlers.tx_handler import send_message, send_traceroute import ui.dialog + def add_notification(channel_number): handle_notification(channel_number, add=True) @@ -367,10 +368,10 @@ def main_ui(stdscr): entry_win.move(y, x - 1) entry_win.refresh() - elif char == 96: - curses.curs_set(0) # Hide cursor - settings(stdscr) - curses.curs_set(1) # Show cursor again + elif char == 96: # ` Launch the settings interface + curses.curs_set(0) + settings_menu(stdscr, globals.interface) + curses.curs_set(1) elif char == 47: # Display packet log diff --git a/ui/menus.py b/ui/menus.py new file mode 100644 index 0000000..4f41832 --- /dev/null +++ b/ui/menus.py @@ -0,0 +1,93 @@ +from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2 +from save_to_radio import settings_reboot, settings_factory_reset, settings_reset_nodedb, settings_shutdown +import logging, traceback + +def extract_fields(message_instance, current_config=None): + if isinstance(current_config, dict): # Handle dictionaries + return {key: (None, current_config.get(key, "Not Set")) for key in current_config} + + if not hasattr(message_instance, "DESCRIPTOR"): + return {} + + menu = {} + fields = message_instance.DESCRIPTOR.fields + for field in fields: + if field.name in {"sessionkey", "channel_num", "id"}: # Skip certain fields + continue + + if field.message_type: # Nested message + nested_instance = getattr(message_instance, field.name) + nested_config = getattr(current_config, field.name, None) if current_config else None + menu[field.name] = extract_fields(nested_instance, nested_config) + elif field.enum_type: # Handle enum fields + current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set" + if isinstance(current_value, int): # If the value is a number, map it to its name + enum_value = field.enum_type.values_by_number.get(current_value) + if enum_value: # Check if the enum value exists + current_value_name = f"{enum_value.name}" + else: + current_value_name = f"Unknown ({current_value})" + menu[field.name] = (field, current_value_name) + else: + menu[field.name] = (field, current_value) # Non-integer values + else: # Handle other field types + current_value = getattr(current_config, field.name, "Not Set") if current_config else "Not Set" + menu[field.name] = (field, current_value) + + return menu + + +def generate_menu_from_protobuf(interface): +# Function to generate the menu structure from protobuf messages + menu_structure = {"Main Menu": {}} + + # Add Radio Settings + radio = config_pb2.Config() + current_radio_config = interface.localNode.localConfig if interface else None + menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config) + + # Add Module Settings + module = module_config_pb2.ModuleConfig() + current_module_config = interface.localNode.moduleConfig if interface else None + menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config) + + # Add User Settings + current_node_info = interface.getMyNodeInfo() if interface else None + + if current_node_info: + + current_user_config = current_node_info.get("user", None) + if current_user_config and isinstance(current_user_config, dict): + + menu_structure["Main Menu"]["User Settings"] = { + "longName": (None, current_user_config.get("longName", "Not Set")), + "shortName": (None, current_user_config.get("shortName", "Not Set")) + } + + else: + logging.info("User settings not found in Node Info") + menu_structure["Main Menu"]["User Settings"] = "No user settings available" + else: + logging.info("Node Info not available") + menu_structure["Main Menu"]["User Settings"] = "Node Info not available" + + # Add Channels + channel = channel_pb2.ChannelSettings() + menu_structure["Main Menu"]["Channels"] = {} + if interface: + for i in range(8): + current_channel = interface.localNode.getChannelByChannelIndex(i) + if current_channel: + channel_config = extract_fields(channel, current_channel.settings) + menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config + + # Add additional settings options + menu_structure["Main Menu"]["Reboot"] = settings_reboot + menu_structure["Main Menu"]["Reset Node DB"] = settings_reset_nodedb + menu_structure["Main Menu"]["Shutdown"] = settings_shutdown + menu_structure["Main Menu"]["Factory Reset"] = settings_factory_reset + + # Add Exit option + menu_structure["Main Menu"]["Exit"] = None + + return menu_structure \ No newline at end of file