From b717d464415cc25429e5be4849e0bf77485ea3ac Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Mon, 3 Feb 2025 20:36:00 -0600 Subject: [PATCH 01/73] Add lock/unlock icon for nodes with/without PSK --- ui/curses_ui.py | 51 +++++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 0133ee9..cdee208 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -107,29 +107,16 @@ def refresh_pad(window): box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2) def highlight_line(highlight, window, line): - pad = channel_pad - select_len = 0 - ch_color = get_color("channel_list") - nd_color = get_color("node_list") - - if(window == 2): - pad = nodes_pad - select_len = len(get_name_from_database(globals.node_list[line], "long")) - - pad.chgat(line, 1, select_len, nd_color | curses.A_REVERSE if highlight else nd_color) + pad = nodes_pad + color = get_color("node_list") + select_len = nodes_box.getmaxyx()[1] - 2 if(window == 0): - channel = globals.channel_list[line] - win_width = channel_box.getmaxyx()[1] + pad = channel_pad + color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list") + select_len = channel_box.getmaxyx()[1] - 2 - if(isinstance(channel, int)): - channel = get_name_from_database(channel, type="long") - select_len = min(len(channel), win_width - 4) - - if line == globals.selected_channel and highlight == False: - ch_color = get_color("channel_selected") - - pad.chgat(line, 1, select_len, ch_color | curses.A_REVERSE if highlight else ch_color) + pad.chgat(line, 1, select_len, color | curses.A_REVERSE if highlight else color) def add_notification(channel_number): if channel_number not in globals.notifications: @@ -200,15 +187,16 @@ def draw_channel_list(): notification = " " + config.notification_symbol if idx in globals.notifications else "" # Truncate the channel name if it's too long to fit in the window - truncated_channel = channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel + truncated_channel = ((channel[:win_width - 5] + '-' if len(channel) > win_width - 5 else channel) + notification).ljust(win_width - 3) + + color = get_color("channel_list") if idx == globals.selected_channel: if globals.current_window == 0: - channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list", reverse=True)) + color = get_color("channel_list", reverse=True) remove_notification(globals.selected_channel) else: - channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_selected")) - else: - channel_pad.addstr(idx, 1, truncated_channel + notification, get_color("channel_list")) + color = get_color("channel_selected") + channel_pad.addstr(idx, 1, truncated_channel, color) idx += 1 channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame")) @@ -263,13 +251,14 @@ def draw_messages_window(scroll_to_bottom = False): def draw_node_list(): nodes_pad.erase() - nodes_pad.resize(len(globals.node_list), nodes_box.getmaxyx()[1]) + box_width = nodes_box.getmaxyx()[1] + nodes_pad.resize(len(globals.node_list) + 1, box_width) - for i, node in enumerate(globals.node_list): - if globals.selected_node == i and globals.current_window == 2: - nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list", reverse=True)) - else: - nodes_pad.addstr(i, 1, get_name_from_database(node, "long"), get_color("node_list")) + for i, node_num in enumerate(globals.node_list): + node = globals.interface.nodesByNum[node_num] + secure = 'user' in node and 'publicKey' in node['user'] and node['user']['publicKey'] + node_str = f"{'🔒' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2] + nodes_pad.addstr(i, 1, node_str, get_color("node_list", reverse=globals.selected_node == i and globals.current_window == 2)) nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame")) nodes_box.box() From 1675b0a116c84e603290517c571cec2d951066ec Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Tue, 4 Feb 2025 17:34:41 -0600 Subject: [PATCH 02/73] Add channel and node search feature Press Ctrl + / while the nodes or channels window is highlighted to start search Type text to search as you type, first matching item will be selected, starting at current selected index Press Tab to find next match starting from the current index - search wraps around if necessary Press Esc or Enter to exit search mode --- ui/curses_ui.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 0133ee9..34ceb08 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -374,6 +374,50 @@ def draw_packetlog_win(): packetlog_win.box() packetlog_win.refresh() +def search(win): + start_idx = globals.selected_node + l = globals.node_list + select_func = select_node + + if win == 0: + start_idx = globals.selected_channel + l = globals.channel_list + select_func = select_channel + + search_text = "" + entry_win.erase() + + while True: + draw_centered_text_field(entry_win, f"Search: {search_text}", 0, get_color("input")) + char = entry_win.get_wch() + + if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)): + break + elif char == "\t": + start_idx = globals.selected_node + 1 if win == 2 else globals.selected_channel + 1 + elif char in (curses.KEY_BACKSPACE, chr(127)): + if search_text: + search_text = search_text[:-1] + y, x = entry_win.getyx() + entry_win.move(y, x - 1) + entry_win.addch(' ') # + entry_win.move(y, x - 1) + entry_win.erase() + entry_win.refresh() + elif isinstance(char, str): + search_text += char + + search_text_caseless = search_text.casefold() + + for i, n in enumerate(l[start_idx:] + l[:start_idx]): + if isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'long').casefold() \ + or isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'short').casefold() \ + or search_text_caseless in str(n).casefold(): + select_func((i + start_idx) % len(l)) + break + + entry_win.erase() + def handle_resize(stdscr, firstrun): global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win @@ -671,6 +715,10 @@ def main_ui(stdscr): draw_channel_list() draw_messages_window() + elif char == chr(31): + if(globals.current_window == 2 or globals.current_window == 0): + search(globals.current_window) + else: # Append typed character to input text if(isinstance(char, str)): From 8382da07a366ae355649ee8ee99d7a8cb0f6b52d Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Tue, 4 Feb 2025 19:16:00 -0600 Subject: [PATCH 03/73] Fix indexing if list changes while searching --- ui/curses_ui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 34ceb08..9df2e41 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -376,12 +376,10 @@ def draw_packetlog_win(): def search(win): start_idx = globals.selected_node - l = globals.node_list select_func = select_node if win == 0: start_idx = globals.selected_channel - l = globals.channel_list select_func = select_channel search_text = "" @@ -409,6 +407,7 @@ def search(win): search_text_caseless = search_text.casefold() + l = globals.node_list if win == 2 else globals.channel_list for i, n in enumerate(l[start_idx:] + l[:start_idx]): if isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'long').casefold() \ or isinstance(n, int) and search_text_caseless in get_name_from_database(n, 'short').casefold() \ From cc7124b6f5a5bc436b09558fe434e06f47bf3d2e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 5 Feb 2025 08:10:43 -0800 Subject: [PATCH 04/73] add commands --- README.md | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b846682..cc9f0b6 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,38 @@ 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. + Screenshot_2024-03-29_at_4 00 29_PM -

-Settings can be accessed within the client or can be run standalone +The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py` Screenshot 2024-04-15 at 3 39 12 PM +## Message Persistence + +All messages will saved in a SQLite DB and restored upon relaunch of the app. You may delete `client.db` if you wish to erase all stored messages and node data. If multiple nodes are used, each will independently store data in the database, but the data will not be shared or viewable between nodes. + +## Client Configuration + +By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more! + +## Commands + +- `↑→↓←` = Navigate around the UI. +- `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM +- `` ` `` = Open the Settings dialogue +- `CTRL` + `p` = Hide/show a log of raw received packets. +- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node +- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user. +- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed. + +### Search +- Press `CTRL` + `/` while the nodes or channels window is highlighted to start search +- Type text to search as you type, first matching item will be selected, starting at current selected index +- Press Tab to find next match starting from the current index - search wraps around if necessary +- Press Esc or Enter to exit search mode + ## Arguments You can pass the following arguments to the client: @@ -22,7 +46,7 @@ You can pass the following arguments to the client: Optional arguments to specify a device to connect to and how. - `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`. -- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP. +- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed. - `--ble`, `-b`: The BLE device MAC address or name to connect to. If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost. @@ -33,3 +57,8 @@ If no connection arguments are specified, the client will attempt a serial conne python main.py --port /dev/ttyUSB0 python main.py --host 192.168.1.1 python main.py --ble BlAddressOfDevice +``` +To quickly connect to localhost, use: +```sh +python main.py -t +``` From 9073da802d0ca8daeac35ab39ef0257af852a87b Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Wed, 5 Feb 2025 08:16:15 -0800 Subject: [PATCH 05/73] Update Settings Image --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cc9f0b6..51c35f9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This Python curses client for Meshtastic is a terminal-based client designed to

The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py` -Screenshot 2024-04-15 at 3 39 12 PM +Contact - Settings Dialogue ## Message Persistence From 83393e2a25288b71013fb2e3feeecaf97c7de64a Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Wed, 5 Feb 2025 12:25:20 -0600 Subject: [PATCH 06/73] Update README.md main UI screenshot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 51c35f9..0badf56 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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. -Screenshot_2024-03-29_at_4 00 29_PM +Contact - Main UI Screenshot

The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py` From 4306971871376b09577fdad6336971af39ed164e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 5 Feb 2025 16:05:29 -0800 Subject: [PATCH 07/73] switch locked emoji --- ui/curses_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 009c86b..d1f9c3f 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -257,7 +257,7 @@ def draw_node_list(): for i, node_num in enumerate(globals.node_list): node = globals.interface.nodesByNum[node_num] secure = 'user' in node and 'publicKey' in node['user'] and node['user']['publicKey'] - node_str = f"{'🔒' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2] + node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2] nodes_pad.addstr(i, 1, node_str, get_color("node_list", reverse=globals.selected_node == i and globals.current_window == 2)) nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame")) From e7850b9204259cff24efd1ba0357978991df5233 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 5 Feb 2025 16:31:12 -0800 Subject: [PATCH 08/73] add role to node details --- ui/curses_ui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index d1f9c3f..48036b8 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -27,7 +27,9 @@ def draw_node_details(): f"({node['user']['shortName']})" if 'user' in node and 'shortName' in node['user'] else "", f" | {node['user']['hwModel']}" - if 'user' in node and 'hwModel' in node['user'] else ""] + if 'user' in node and 'hwModel' in node['user'] else "", + f" | {node['user']['role']}" + if 'user' in node and 'role' in node['user'] else ""] if globals.node_list[globals.selected_node] == globals.myNodeNum: node_details_list.extend([f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)" From 651d381c787bdf5e7fae477dec424234c7e34200 Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Wed, 5 Feb 2025 19:15:18 -0600 Subject: [PATCH 09/73] Fix receiving traceroute from archived chat They were invisible --- message_handlers/tx_handler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/message_handlers/tx_handler.py b/message_handlers/tx_handler.py index 419cac0..c9b545d 100644 --- a/message_handlers/tx_handler.py +++ b/message_handlers/tx_handler.py @@ -3,7 +3,7 @@ import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 -from db_handler import save_message_to_db, update_ack_nak, get_name_from_database +from db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db import default_config as config import globals @@ -94,6 +94,9 @@ def on_response_traceroute(packet): globals.channel_list.append(packet['from']) refresh_channels = True + if(is_chat_archived(packet['from'])): + update_node_info_in_db(packet['from'], chat_archived=False) + channel_number = globals.channel_list.index(packet['from']) if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: From 16c8e3032adeccf22a83f893bdc873cc4cad9b48 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 6 Feb 2025 23:06:19 -0800 Subject: [PATCH 10/73] fix startup error test --- ui/curses_ui.py | 448 ++++++++++++++++++++++++------------------------ 1 file changed, 225 insertions(+), 223 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 48036b8..c5e3b60 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -9,6 +9,230 @@ import default_config as config import ui.dialog import globals + +def main_ui(stdscr): + global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win + messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None + + stdscr.keypad(True) + get_channels() + + input_text = "" + + handle_resize(stdscr, True) + + while True: + draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) + + # Get user input from entry window + char = entry_win.get_wch() + + # draw_debug(f"Keypress: {char}") + + if char == curses.KEY_UP: + if globals.current_window == 0: + scroll_channels(-1) + elif globals.current_window == 1: + scroll_messages(-1) + elif globals.current_window == 2: + scroll_nodes(-1) + + elif char == curses.KEY_DOWN: + if globals.current_window == 0: + scroll_channels(1) + elif globals.current_window == 1: + scroll_messages(1) + elif globals.current_window == 2: + scroll_nodes(1) + + elif char == curses.KEY_HOME: + if globals.current_window == 0: + select_channel(0) + elif globals.current_window == 1: + globals.selected_message = 0 + refresh_pad(1) + elif globals.current_window == 2: + select_node(0) + + elif char == curses.KEY_END: + if globals.current_window == 0: + select_channel(len(globals.channel_list) - 1) + elif globals.current_window == 1: + msg_line_count = messages_pad.getmaxyx()[0] + globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0) + refresh_pad(1) + elif globals.current_window == 2: + select_node(len(globals.node_list) - 1) + + elif char == curses.KEY_PPAGE: + if globals.current_window == 0: + select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us + elif globals.current_window == 1: + globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0) + refresh_pad(1) + elif globals.current_window == 2: + select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us + + elif char == curses.KEY_NPAGE: + if globals.current_window == 0: + select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us + elif globals.current_window == 1: + msg_line_count = messages_pad.getmaxyx()[0] + globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines()) + refresh_pad(1) + elif globals.current_window == 2: + select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us + + elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT: + delta = -1 if char == curses.KEY_LEFT else 1 + + old_window = globals.current_window + globals.current_window = (globals.current_window + delta) % 3 + + if old_window == 0: + channel_box.attrset(get_color("window_frame")) + channel_box.box() + channel_box.refresh() + highlight_line(False, 0, globals.selected_channel) + refresh_pad(0) + if old_window == 1: + messages_box.attrset(get_color("window_frame")) + messages_box.box() + messages_box.refresh() + refresh_pad(1) + elif old_window == 2: + draw_function_win() + nodes_box.attrset(get_color("window_frame")) + nodes_box.box() + nodes_box.refresh() + highlight_line(False, 2, globals.selected_node) + refresh_pad(2) + + if globals.current_window == 0: + channel_box.attrset(get_color("window_frame_selected")) + channel_box.box() + channel_box.attrset(get_color("window_frame")) + channel_box.refresh() + highlight_line(True, 0, globals.selected_channel) + refresh_pad(0) + elif globals.current_window == 1: + messages_box.attrset(get_color("window_frame_selected")) + messages_box.box() + messages_box.attrset(get_color("window_frame")) + messages_box.refresh() + refresh_pad(1) + elif globals.current_window == 2: + draw_function_win() + nodes_box.attrset(get_color("window_frame_selected")) + nodes_box.box() + nodes_box.attrset(get_color("window_frame")) + nodes_box.refresh() + highlight_line(True, 2, globals.selected_node) + refresh_pad(2) + + # Check for Esc + elif char == chr(27): + break + + # Check for Ctrl + t + elif char == chr(20): + send_traceroute() + curses.curs_set(0) # Hide cursor + ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") + curses.curs_set(1) # Show cursor again + handle_resize(stdscr, False) + + elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)): + if globals.current_window == 2: + node_list = globals.node_list + if node_list[globals.selected_node] not in globals.channel_list: + globals.channel_list.append(node_list[globals.selected_node]) + if(node_list[globals.selected_node] not in globals.all_messages): + globals.all_messages[node_list[globals.selected_node]] = [] + + + globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node]) + + if(is_chat_archived(globals.channel_list[globals.selected_channel])): + update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False) + + globals.selected_node = 0 + globals.current_window = 0 + + draw_node_list() + draw_channel_list() + draw_messages_window(True) + + elif len(input_text) > 0: + # Enter key pressed, send user input as message + send_message(input_text, channel=globals.selected_channel) + draw_messages_window(True) + + # Clear entry window and reset input text + input_text = "" + entry_win.erase() + + elif char in (curses.KEY_BACKSPACE, chr(127)): + if input_text: + input_text = input_text[:-1] + y, x = entry_win.getyx() + entry_win.move(y, x - 1) + entry_win.addch(' ') # + entry_win.move(y, x - 1) + entry_win.refresh() + + elif char == "`": # ` Launch the settings interface + curses.curs_set(0) + settings_menu(stdscr, globals.interface) + curses.curs_set(1) + refresh_node_list() + handle_resize(stdscr, False) + + elif char == chr(16): + # Display packet log + if globals.display_log is False: + globals.display_log = True + draw_messages_window(True) + else: + globals.display_log = False + packetlog_win.erase() + draw_messages_window(True) + + elif char == curses.KEY_RESIZE: + input_text = "" + handle_resize(stdscr, False) + + # ^D + elif char == chr(4): + if(globals.current_window == 0): + if(isinstance(globals.channel_list[globals.selected_channel], int)): + update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=True) + + # Shift notifications up to account for deleted item + for i in range(len(globals.notifications)): + if globals.notifications[i] > globals.selected_channel: + globals.notifications[i] -= 1 + + del globals.channel_list[globals.selected_channel] + globals.selected_channel = min(globals.selected_channel, len(globals.channel_list) - 1) + select_channel(globals.selected_channel) + draw_channel_list() + draw_messages_window() + + elif char == chr(31): + if(globals.current_window == 2 or globals.current_window == 0): + search(globals.current_window) + + else: + # Append typed character to input text + if(isinstance(char, str)): + input_text += char + else: + input_text += chr(char) + + + + def draw_node_details(): node = None try: @@ -409,7 +633,7 @@ def search(win): entry_win.erase() def handle_resize(stdscr, firstrun): - global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win + # global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win # Calculate window max dimensions height, width = stdscr.getmaxyx() @@ -495,225 +719,3 @@ def handle_resize(stdscr, firstrun): # Resize events can come faster than we can re-draw, which can cause a curses error. # In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later. pass - -def main_ui(stdscr): - global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win - messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None - - stdscr.keypad(True) - get_channels() - - input_text = "" - - handle_resize(stdscr, True) - - while True: - draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) - - # Get user input from entry window - char = entry_win.get_wch() - - # draw_debug(f"Keypress: {char}") - - if char == curses.KEY_UP: - if globals.current_window == 0: - scroll_channels(-1) - elif globals.current_window == 1: - scroll_messages(-1) - elif globals.current_window == 2: - scroll_nodes(-1) - - elif char == curses.KEY_DOWN: - if globals.current_window == 0: - scroll_channels(1) - elif globals.current_window == 1: - scroll_messages(1) - elif globals.current_window == 2: - scroll_nodes(1) - - elif char == curses.KEY_HOME: - if globals.current_window == 0: - select_channel(0) - elif globals.current_window == 1: - globals.selected_message = 0 - refresh_pad(1) - elif globals.current_window == 2: - select_node(0) - - elif char == curses.KEY_END: - if globals.current_window == 0: - select_channel(len(globals.channel_list) - 1) - elif globals.current_window == 1: - msg_line_count = messages_pad.getmaxyx()[0] - globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0) - refresh_pad(1) - elif globals.current_window == 2: - select_node(len(globals.node_list) - 1) - - elif char == curses.KEY_PPAGE: - if globals.current_window == 0: - select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us - elif globals.current_window == 1: - globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0) - refresh_pad(1) - elif globals.current_window == 2: - select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us - - elif char == curses.KEY_NPAGE: - if globals.current_window == 0: - select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us - elif globals.current_window == 1: - msg_line_count = messages_pad.getmaxyx()[0] - globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines()) - refresh_pad(1) - elif globals.current_window == 2: - select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us - - elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT: - delta = -1 if char == curses.KEY_LEFT else 1 - - old_window = globals.current_window - globals.current_window = (globals.current_window + delta) % 3 - - if old_window == 0: - channel_box.attrset(get_color("window_frame")) - channel_box.box() - channel_box.refresh() - highlight_line(False, 0, globals.selected_channel) - refresh_pad(0) - if old_window == 1: - messages_box.attrset(get_color("window_frame")) - messages_box.box() - messages_box.refresh() - refresh_pad(1) - elif old_window == 2: - draw_function_win() - nodes_box.attrset(get_color("window_frame")) - nodes_box.box() - nodes_box.refresh() - highlight_line(False, 2, globals.selected_node) - refresh_pad(2) - - if globals.current_window == 0: - channel_box.attrset(get_color("window_frame_selected")) - channel_box.box() - channel_box.attrset(get_color("window_frame")) - channel_box.refresh() - highlight_line(True, 0, globals.selected_channel) - refresh_pad(0) - elif globals.current_window == 1: - messages_box.attrset(get_color("window_frame_selected")) - messages_box.box() - messages_box.attrset(get_color("window_frame")) - messages_box.refresh() - refresh_pad(1) - elif globals.current_window == 2: - draw_function_win() - nodes_box.attrset(get_color("window_frame_selected")) - nodes_box.box() - nodes_box.attrset(get_color("window_frame")) - nodes_box.refresh() - highlight_line(True, 2, globals.selected_node) - refresh_pad(2) - - # Check for Esc - elif char == chr(27): - break - - # Check for Ctrl + t - elif char == chr(20): - send_traceroute() - curses.curs_set(0) # Hide cursor - ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") - curses.curs_set(1) # Show cursor again - handle_resize(stdscr, False) - - elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)): - if globals.current_window == 2: - node_list = globals.node_list - if node_list[globals.selected_node] not in globals.channel_list: - globals.channel_list.append(node_list[globals.selected_node]) - if(node_list[globals.selected_node] not in globals.all_messages): - globals.all_messages[node_list[globals.selected_node]] = [] - - - globals.selected_channel = globals.channel_list.index(node_list[globals.selected_node]) - - if(is_chat_archived(globals.channel_list[globals.selected_channel])): - update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=False) - - globals.selected_node = 0 - globals.current_window = 0 - - draw_node_list() - draw_channel_list() - draw_messages_window(True) - - elif len(input_text) > 0: - # Enter key pressed, send user input as message - send_message(input_text, channel=globals.selected_channel) - draw_messages_window(True) - - # Clear entry window and reset input text - input_text = "" - entry_win.erase() - - elif char in (curses.KEY_BACKSPACE, chr(127)): - if input_text: - input_text = input_text[:-1] - y, x = entry_win.getyx() - entry_win.move(y, x - 1) - entry_win.addch(' ') # - entry_win.move(y, x - 1) - entry_win.refresh() - - elif char == "`": # ` Launch the settings interface - curses.curs_set(0) - settings_menu(stdscr, globals.interface) - curses.curs_set(1) - refresh_node_list() - handle_resize(stdscr, False) - - elif char == chr(16): - # Display packet log - if globals.display_log is False: - globals.display_log = True - draw_messages_window(True) - else: - globals.display_log = False - packetlog_win.erase() - draw_messages_window(True) - - elif char == curses.KEY_RESIZE: - input_text = "" - handle_resize(stdscr, False) - - # ^D - elif char == chr(4): - if(globals.current_window == 0): - if(isinstance(globals.channel_list[globals.selected_channel], int)): - update_node_info_in_db(globals.channel_list[globals.selected_channel], chat_archived=True) - - # Shift notifications up to account for deleted item - for i in range(len(globals.notifications)): - if globals.notifications[i] > globals.selected_channel: - globals.notifications[i] -= 1 - - del globals.channel_list[globals.selected_channel] - globals.selected_channel = min(globals.selected_channel, len(globals.channel_list) - 1) - select_channel(globals.selected_channel) - draw_channel_list() - draw_messages_window() - - elif char == chr(31): - if(globals.current_window == 2 or globals.current_window == 0): - search(globals.current_window) - - else: - # Append typed character to input text - if(isinstance(char, str)): - input_text += char - else: - input_text += chr(char) - - From 0d6f23419121b5436553a6489addbd46c6d3cb14 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 6 Feb 2025 23:10:01 -0800 Subject: [PATCH 11/73] more testing --- ui/curses_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index c5e3b60..dc4c969 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -633,7 +633,7 @@ def search(win): entry_win.erase() def handle_resize(stdscr, firstrun): - # global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win + global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win # Calculate window max dimensions height, width = stdscr.getmaxyx() From bbfe361173f8afe1bd385a78a691b36d613f24f9 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 18:03:10 -0800 Subject: [PATCH 12/73] morer testing --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index ab0efd3..1f84711 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from pubsub import pub import os import logging import traceback +import time from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface @@ -53,6 +54,7 @@ def main(stdscr): pub.subscribe(on_receive, 'meshtastic.receive') init_nodedb() load_messages_from_db() + time.sleep(1) logging.info("Starting main UI") main_ui(stdscr) except Exception as e: From 30d14a6a9e8b8948f0f46af998d2b5f735adc76b Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 18:08:03 -0800 Subject: [PATCH 13/73] big test --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 1f84711..3955a98 100644 --- a/main.py +++ b/main.py @@ -54,7 +54,7 @@ def main(stdscr): pub.subscribe(on_receive, 'meshtastic.receive') init_nodedb() load_messages_from_db() - time.sleep(1) + time.sleep(5) logging.info("Starting main UI") main_ui(stdscr) except Exception as e: From 9c380c18fd36322fbc0332c7778763bb85ce0432 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 20:19:26 -0800 Subject: [PATCH 14/73] maybe this --- main.py | 1 - ui/curses_ui.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 3955a98..34686f8 100644 --- a/main.py +++ b/main.py @@ -54,7 +54,6 @@ def main(stdscr): pub.subscribe(on_receive, 'meshtastic.receive') init_nodedb() load_messages_from_db() - time.sleep(5) logging.info("Starting main UI") main_ui(stdscr) except Exception as e: diff --git a/ui/curses_ui.py b/ui/curses_ui.py index dc4c969..321108b 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -10,9 +10,10 @@ import ui.dialog import globals +messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None + def main_ui(stdscr): global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win - messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None stdscr.keypad(True) get_channels() From fe3980bc5ad7abc00ce4d822ee4b9536f2e1684e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 20:23:47 -0800 Subject: [PATCH 15/73] ok I'll stop --- ui/curses_ui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 321108b..dc4c969 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -10,10 +10,9 @@ import ui.dialog import globals -messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None - def main_ui(stdscr): global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win + messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None stdscr.keypad(True) get_channels() From 3668d47119746922cb4e0280d8df8e35de501bcf Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 20:49:38 -0800 Subject: [PATCH 16/73] define windows in resize --- ui/curses_ui.py | 528 ++++++++++++++++++++++++------------------------ 1 file changed, 262 insertions(+), 266 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index dc4c969..7c35872 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -10,17 +10,104 @@ import ui.dialog import globals + +def handle_resize(stdscr, firstrun): + global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win + + # Calculate window max dimensions + height, width = stdscr.getmaxyx() + + # Define window dimensions and positions + channel_width = 3 * (width // 16) + nodes_width = 5 * (width // 16) + messages_width = width - channel_width - nodes_width + + if firstrun: + entry_win = curses.newwin(3, width, 0, 0) + channel_win = curses.newwin(height - 6, channel_width, 3, 0) + messages_win = curses.newwin(height - 6, messages_width, 3, channel_width) + nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width) + function_win = curses.newwin(3, width, height - 3, 0) + packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width) + + # Will be resized to what we need when drawn + messages_pad = curses.newpad(1, 1) + nodes_pad = curses.newpad(1,1) + channel_pad = curses.newpad(1,1) + + entry_win.bkgd(get_color("background")) + channel_win.bkgd(get_color("background")) + messages_win.bkgd(get_color("background")) + nodes_win.bkgd(get_color("background")) + function_win.bkgd(get_color("background")) + packetlog_win.bkgd(get_color("background")) + + messages_pad.bkgd(get_color("background")) + nodes_pad.bkgd(get_color("background")) + channel_pad.bkgd(get_color("background")) + + + channel_win.attrset(get_color("window_frame")) + entry_win.attrset(get_color("window_frame")) + nodes_win.attrset(get_color("window_frame")) + messages_win.attrset(get_color("window_frame")) + function_win.attrset(get_color("window_frame")) + + else: + entry_win.erase() + channel_win.erase() + messages_win.erase() + nodes_win.erase() + function_win.erase() + packetlog_win.erase() + entry_win.resize(3, width) + channel_win.resize(height - 6, channel_width) + messages_win.resize(height - 6, messages_width) + messages_win.mvwin(3, channel_width) + nodes_win.resize(height - 6, nodes_width) + nodes_win.mvwin(3, channel_width + messages_width) + function_win.resize(3, width) + function_win.mvwin(height - 3, 0) + packetlog_win.resize(int(height / 3), messages_width) + packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) + + + channel_win.box() + entry_win.box() + nodes_win.box() + messages_win.box() + + function_win.box() + + # Refresh all windows + entry_win.refresh() + channel_win.refresh() + function_win.refresh() + nodes_win.refresh() + messages_win.refresh() + + entry_win.keypad(True) + curses.curs_set(1) + + try: + draw_function_win() + draw_channel_list() + draw_messages_window(True) + draw_node_list() + except: + # Resize events can come faster than we can re-draw, which can cause a curses error. + # In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later. + pass + + def main_ui(stdscr): - global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win - messages_pad = messages_box = nodes_pad = nodes_box = channel_pad = channel_box = function_win = packetlog_win = entry_win = None stdscr.keypad(True) get_channels() - - input_text = "" - + handle_resize(stdscr, True) + input_text = "" while True: draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) @@ -66,22 +153,22 @@ def main_ui(stdscr): elif char == curses.KEY_PPAGE: if globals.current_window == 0: - select_channel(globals.selected_channel - (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us + select_channel(globals.selected_channel - (channel_win.getmaxyx()[0] - 2)) # select_channel will bounds check for us elif globals.current_window == 1: globals.selected_message = max(globals.selected_message - get_msg_window_lines(), 0) refresh_pad(1) elif globals.current_window == 2: - select_node(globals.selected_node - (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us + select_node(globals.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us elif char == curses.KEY_NPAGE: if globals.current_window == 0: - select_channel(globals.selected_channel + (channel_box.getmaxyx()[0] - 2)) # select_channel will bounds check for us + select_channel(globals.selected_channel + (channel_win.getmaxyx()[0] - 2)) # select_channel will bounds check for us elif globals.current_window == 1: msg_line_count = messages_pad.getmaxyx()[0] globals.selected_message = min(globals.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines()) refresh_pad(1) elif globals.current_window == 2: - select_node(globals.selected_node + (nodes_box.getmaxyx()[0] - 2)) # select_node will bounds check for us + select_node(globals.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT: delta = -1 if char == curses.KEY_LEFT else 1 @@ -90,43 +177,43 @@ def main_ui(stdscr): globals.current_window = (globals.current_window + delta) % 3 if old_window == 0: - channel_box.attrset(get_color("window_frame")) - channel_box.box() - channel_box.refresh() + channel_win.attrset(get_color("window_frame")) + channel_win.box() + channel_win.refresh() highlight_line(False, 0, globals.selected_channel) refresh_pad(0) if old_window == 1: - messages_box.attrset(get_color("window_frame")) - messages_box.box() - messages_box.refresh() + messages_win.attrset(get_color("window_frame")) + messages_win.box() + messages_win.refresh() refresh_pad(1) elif old_window == 2: draw_function_win() - nodes_box.attrset(get_color("window_frame")) - nodes_box.box() - nodes_box.refresh() + nodes_win.attrset(get_color("window_frame")) + nodes_win.box() + nodes_win.refresh() highlight_line(False, 2, globals.selected_node) refresh_pad(2) if globals.current_window == 0: - channel_box.attrset(get_color("window_frame_selected")) - channel_box.box() - channel_box.attrset(get_color("window_frame")) - channel_box.refresh() + channel_win.attrset(get_color("window_frame_selected")) + channel_win.box() + channel_win.attrset(get_color("window_frame")) + channel_win.refresh() highlight_line(True, 0, globals.selected_channel) refresh_pad(0) elif globals.current_window == 1: - messages_box.attrset(get_color("window_frame_selected")) - messages_box.box() - messages_box.attrset(get_color("window_frame")) - messages_box.refresh() + messages_win.attrset(get_color("window_frame_selected")) + messages_win.box() + messages_win.attrset(get_color("window_frame")) + messages_win.refresh() refresh_pad(1) elif globals.current_window == 2: draw_function_win() - nodes_box.attrset(get_color("window_frame_selected")) - nodes_box.box() - nodes_box.attrset(get_color("window_frame")) - nodes_box.refresh() + nodes_win.attrset(get_color("window_frame_selected")) + nodes_win.box() + nodes_win.attrset(get_color("window_frame")) + nodes_win.refresh() highlight_line(True, 2, globals.selected_node) refresh_pad(2) @@ -230,143 +317,6 @@ def main_ui(stdscr): else: input_text += chr(char) - - - -def draw_node_details(): - node = None - try: - node = globals.interface.nodesByNum[globals.node_list[globals.selected_node]] - except KeyError: - return - - function_win.erase() - function_win.box() - - nodestr = "" - width = function_win.getmaxyx()[1] - - node_details_list = [f"{node['user']['longName']} " - if 'user' in node and 'longName' in node['user'] else "", - f"({node['user']['shortName']})" - if 'user' in node and 'shortName' in node['user'] else "", - f" | {node['user']['hwModel']}" - if 'user' in node and 'hwModel' in node['user'] else "", - f" | {node['user']['role']}" - if 'user' in node and 'role' in node['user'] else ""] - - if globals.node_list[globals.selected_node] == globals.myNodeNum: - node_details_list.extend([f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)" - if 'deviceMetrics' in node - and 'batteryLevel' in node['deviceMetrics'] - and 'voltage' in node['deviceMetrics'] else "", - f" | Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}" if 'deviceMetrics' in node - and 'uptimeSeconds' in node['deviceMetrics'] else "", - f" | ChUtil: {node['deviceMetrics']['channelUtilization']:.2f}%" if 'deviceMetrics' in node - and 'channelUtilization' in node['deviceMetrics'] else "", - f" | AirUtilTX: {node['deviceMetrics']['airUtilTx']:.2f}%" if 'deviceMetrics' in node - and 'airUtilTx' in node['deviceMetrics'] else "", - ]) - else: - node_details_list.extend([f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "", - f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "", - f" | SNR: {node['snr']}dB" - if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0) - else "", - ]) - - for s in node_details_list: - if len(nodestr) + len(s) < width - 2: - nodestr = nodestr + s - - draw_centered_text_field(function_win, nodestr, 0, get_color("commands")) - -def draw_help(): - cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat"] - function_str = "" - for s in cmds: - if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2): - function_str += s - - draw_centered_text_field(function_win, function_str, 0, get_color("commands")) - -def draw_function_win(): - if(globals.current_window == 2): - draw_node_details() - else: - draw_help() - -def get_msg_window_lines(): - packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0 - return messages_box.getmaxyx()[0] - 2 - packetlog_height - -def refresh_pad(window): - win_height = channel_box.getmaxyx()[0] - - selected_item = globals.selected_channel - pad = channel_pad - box = channel_box - lines = box.getmaxyx()[0] - 2 - start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders - - if(window == 1): - pad = messages_pad - box = messages_box - lines = get_msg_window_lines() - selected_item = globals.selected_message - start_index = globals.selected_message - - if globals.display_log: - packetlog_win.box() - packetlog_win.refresh() - - if(window == 2): - pad = nodes_pad - box = nodes_box - lines = box.getmaxyx()[0] - 2 - selected_item = globals.selected_node - start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders - - - pad.refresh(start_index, 0, - box.getbegyx()[0] + 1, box.getbegyx()[1] + 1, - box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2) - -def highlight_line(highlight, window, line): - pad = nodes_pad - color = get_color("node_list") - select_len = nodes_box.getmaxyx()[1] - 2 - - if(window == 0): - pad = channel_pad - color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list") - select_len = channel_box.getmaxyx()[1] - 2 - - pad.chgat(line, 1, select_len, color | curses.A_REVERSE if highlight else color) - -def add_notification(channel_number): - if channel_number not in globals.notifications: - globals.notifications.append(channel_number) - -def remove_notification(channel_number): - if channel_number in globals.notifications: - globals.notifications.remove(channel_number) - -def draw_text_field(win, text, color): - win.border() - win.addstr(1, 1, text, color) - -def draw_centered_text_field(win, text, y_offset, color): - height, width = win.getmaxyx() - x = (width - len(text)) // 2 - y = (height // 2) + y_offset - win.addstr(y, x, text, color) - win.refresh() - -def draw_debug(value): - function_win.addstr(1, 1, f"debug: {value} ") - function_win.refresh() - def draw_splash(stdscr): setup_colors() curses.curs_set(0) @@ -396,10 +346,10 @@ def draw_splash(stdscr): def draw_channel_list(): channel_pad.erase() - win_height, win_width = channel_box.getmaxyx() + win_height, win_width = channel_win.getmaxyx() start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders - channel_pad.resize(len(globals.all_messages), channel_box.getmaxyx()[1]) + channel_pad.resize(len(globals.all_messages), channel_win.getmaxyx()[1]) idx = 0 for channel in globals.channel_list: @@ -425,10 +375,10 @@ def draw_channel_list(): channel_pad.addstr(idx, 1, truncated_channel, color) idx += 1 - channel_box.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame")) - channel_box.box() - channel_box.attrset((get_color("window_frame"))) - channel_box.refresh() + channel_win.attrset(get_color("window_frame_selected") if globals.current_window == 0 else get_color("window_frame")) + channel_win.box() + channel_win.attrset((get_color("window_frame"))) + channel_win.refresh() refresh_pad(0) @@ -446,9 +396,9 @@ def draw_messages_window(scroll_to_bottom = False): row = 0 for (prefix, message) in messages: full_message = f"{prefix}{message}" - wrapped_lines = textwrap.wrap(full_message, messages_box.getmaxyx()[1] - 2) + wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2) msg_line_count += len(wrapped_lines) - messages_pad.resize(msg_line_count, messages_box.getmaxyx()[1]) + messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1]) for line in wrapped_lines: if prefix.startswith("--"): @@ -461,10 +411,10 @@ def draw_messages_window(scroll_to_bottom = False): messages_pad.addstr(row, 1, line, color) row += 1 - messages_box.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame")) - messages_box.box() - messages_box.attrset(get_color("window_frame")) - messages_box.refresh() + messages_win.attrset(get_color("window_frame_selected") if globals.current_window == 1 else get_color("window_frame")) + messages_win.box() + messages_win.attrset(get_color("window_frame")) + messages_win.refresh() if(scroll_to_bottom): globals.selected_message = max(msg_line_count - get_msg_window_lines(), 0) @@ -477,7 +427,7 @@ def draw_messages_window(scroll_to_bottom = False): def draw_node_list(): nodes_pad.erase() - box_width = nodes_box.getmaxyx()[1] + box_width = nodes_win.getmaxyx()[1] nodes_pad.resize(len(globals.node_list) + 1, box_width) for i, node_num in enumerate(globals.node_list): @@ -486,10 +436,10 @@ def draw_node_list(): node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2] nodes_pad.addstr(i, 1, node_str, get_color("node_list", reverse=globals.selected_node == i and globals.current_window == 2)) - nodes_box.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame")) - nodes_box.box() - nodes_box.attrset(get_color("window_frame")) - nodes_box.refresh() + nodes_win.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame")) + nodes_win.box() + nodes_win.attrset(get_color("window_frame")) + nodes_win.refresh() refresh_pad(2) @@ -632,90 +582,136 @@ def search(win): entry_win.erase() -def handle_resize(stdscr, firstrun): - global messages_pad, messages_box, nodes_pad, nodes_box, channel_pad, channel_box, function_win, packetlog_win, entry_win - - # Calculate window max dimensions - height, width = stdscr.getmaxyx() - - # Define window dimensions and positions - channel_width = 3 * (width // 16) - nodes_width = 5 * (width // 16) - messages_width = width - channel_width - nodes_width - - if firstrun: - entry_win = curses.newwin(3, width, 0, 0) - channel_box = curses.newwin(height - 6, channel_width, 3, 0) - messages_box = curses.newwin(height - 6, messages_width, 3, channel_width) - nodes_box = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width) - function_win = curses.newwin(3, width, height - 3, 0) - packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width) - - # Will be resized to what we need when drawn - messages_pad = curses.newpad(1, 1) - nodes_pad = curses.newpad(1,1) - channel_pad = curses.newpad(1,1) - - entry_win.bkgd(get_color("background")) - channel_box.bkgd(get_color("background")) - messages_box.bkgd(get_color("background")) - nodes_box.bkgd(get_color("background")) - - messages_pad.bkgd(get_color("background")) - nodes_pad.bkgd(get_color("background")) - channel_pad.bkgd(get_color("background")) - - function_win.bkgd(get_color("background")) - packetlog_win.bkgd(get_color("background")) - - channel_box.attrset(get_color("window_frame")) - entry_win.attrset(get_color("window_frame")) - nodes_box.attrset(get_color("window_frame")) - messages_box.attrset(get_color("window_frame")) - function_win.attrset(get_color("window_frame")) - - else: - entry_win.erase() - channel_box.erase() - messages_box.erase() - nodes_box.erase() - function_win.erase() - packetlog_win.erase() - entry_win.resize(3, width) - channel_box.resize(height - 6, channel_width) - messages_box.resize(height - 6, messages_width) - messages_box.mvwin(3, channel_width) - nodes_box.resize(height - 6, nodes_width) - nodes_box.mvwin(3, channel_width + messages_width) - function_win.resize(3, width) - function_win.mvwin(height - 3, 0) - packetlog_win.resize(int(height / 3), messages_width) - packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) - - - channel_box.box() - entry_win.box() - nodes_box.box() - messages_box.box() +def draw_node_details(): + node = None + try: + node = globals.interface.nodesByNum[globals.node_list[globals.selected_node]] + except KeyError: + return + function_win.erase() function_win.box() - # Refresh all windows - entry_win.refresh() - channel_box.refresh() - function_win.refresh() - nodes_box.refresh() - messages_box.refresh() + nodestr = "" + width = function_win.getmaxyx()[1] - entry_win.keypad(True) - curses.curs_set(1) + node_details_list = [f"{node['user']['longName']} " + if 'user' in node and 'longName' in node['user'] else "", + f"({node['user']['shortName']})" + if 'user' in node and 'shortName' in node['user'] else "", + f" | {node['user']['hwModel']}" + if 'user' in node and 'hwModel' in node['user'] else "", + f" | {node['user']['role']}" + if 'user' in node and 'role' in node['user'] else ""] - try: - draw_function_win() - draw_channel_list() - draw_messages_window(True) - draw_node_list() - except: - # Resize events can come faster than we can re-draw, which can cause a curses error. - # In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later. - pass + if globals.node_list[globals.selected_node] == globals.myNodeNum: + node_details_list.extend([f" | Bat: {node['deviceMetrics']['batteryLevel']}% ({node['deviceMetrics']['voltage']}v)" + if 'deviceMetrics' in node + and 'batteryLevel' in node['deviceMetrics'] + and 'voltage' in node['deviceMetrics'] else "", + f" | Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}" if 'deviceMetrics' in node + and 'uptimeSeconds' in node['deviceMetrics'] else "", + f" | ChUtil: {node['deviceMetrics']['channelUtilization']:.2f}%" if 'deviceMetrics' in node + and 'channelUtilization' in node['deviceMetrics'] else "", + f" | AirUtilTX: {node['deviceMetrics']['airUtilTx']:.2f}%" if 'deviceMetrics' in node + and 'airUtilTx' in node['deviceMetrics'] else "", + ]) + else: + node_details_list.extend([f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "", + f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "", + f" | SNR: {node['snr']}dB" + if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0) + else "", + ]) + + for s in node_details_list: + if len(nodestr) + len(s) < width - 2: + nodestr = nodestr + s + + draw_centered_text_field(function_win, nodestr, 0, get_color("commands")) + +def draw_help(): + cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat"] + function_str = "" + for s in cmds: + if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2): + function_str += s + + draw_centered_text_field(function_win, function_str, 0, get_color("commands")) + +def draw_function_win(): + if(globals.current_window == 2): + draw_node_details() + else: + draw_help() + +def get_msg_window_lines(): + packetlog_height = packetlog_win.getmaxyx()[0] - 1 if globals.display_log else 0 + return messages_win.getmaxyx()[0] - 2 - packetlog_height + +def refresh_pad(window): + win_height = channel_win.getmaxyx()[0] + + selected_item = globals.selected_channel + pad = channel_pad + box = channel_win + lines = box.getmaxyx()[0] - 2 + start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders + + if(window == 1): + pad = messages_pad + box = messages_win + lines = get_msg_window_lines() + selected_item = globals.selected_message + start_index = globals.selected_message + + if globals.display_log: + packetlog_win.box() + packetlog_win.refresh() + + if(window == 2): + pad = nodes_pad + box = nodes_win + lines = box.getmaxyx()[0] - 2 + selected_item = globals.selected_node + start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders + + + pad.refresh(start_index, 0, + box.getbegyx()[0] + 1, box.getbegyx()[1] + 1, + box.getbegyx()[0] + lines, box.getbegyx()[1] + box.getmaxyx()[1] - 2) + +def highlight_line(highlight, window, line): + pad = nodes_pad + color = get_color("node_list") + select_len = nodes_win.getmaxyx()[1] - 2 + + if(window == 0): + pad = channel_pad + color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list") + select_len = channel_win.getmaxyx()[1] - 2 + + pad.chgat(line, 1, select_len, color | curses.A_REVERSE if highlight else color) + +def add_notification(channel_number): + if channel_number not in globals.notifications: + globals.notifications.append(channel_number) + +def remove_notification(channel_number): + if channel_number in globals.notifications: + globals.notifications.remove(channel_number) + +def draw_text_field(win, text, color): + win.border() + win.addstr(1, 1, text, color) + +def draw_centered_text_field(win, text, y_offset, color): + height, width = win.getmaxyx() + x = (width - len(text)) // 2 + y = (height // 2) + y_offset + win.addstr(y, x, text, color) + win.refresh() + +def draw_debug(value): + function_win.addstr(1, 1, f"debug: {value} ") + function_win.refresh() \ No newline at end of file From dae71984bc8c5fd1e42de9f8c5ecb0220de614e4 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 21:01:06 -0800 Subject: [PATCH 17/73] make sure nodes pad is created --- ui/curses_ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 7c35872..4831e1f 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -31,7 +31,7 @@ def handle_resize(stdscr, firstrun): packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width) # Will be resized to what we need when drawn - messages_pad = curses.newpad(1, 1) + messages_pad = curses.newpad(1,1) nodes_pad = curses.newpad(1,1) channel_pad = curses.newpad(1,1) @@ -71,6 +71,9 @@ def handle_resize(stdscr, firstrun): packetlog_win.resize(int(height / 3), messages_width) packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) + if 'nodes_pad' not in globals(): + nodes_pad = curses.newpad(1, 1) + channel_win.box() entry_win.box() From dd11932a53391a4d0f8b533b99cf12799c5ad6ac Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 21:44:17 -0800 Subject: [PATCH 18/73] make sure nodes_pad exists legitely --- ui/curses_ui.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 4831e1f..9e4c312 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -71,8 +71,11 @@ def handle_resize(stdscr, firstrun): packetlog_win.resize(int(height / 3), messages_width) packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) - if 'nodes_pad' not in globals(): - nodes_pad = curses.newpad(1, 1) + # Reinitialize `nodes_pad` in case it was lost during a resize + try: + nodes_pad.getmaxyx() # Check if it's still valid + except: + nodes_pad = curses.newpad(1, 1) # Recreate if needed channel_win.box() From a080af3e84f495b480db2a74546b8d3c2cdbaa15 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 21:50:14 -0800 Subject: [PATCH 19/73] add logging --- ui/curses_ui.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 9e4c312..9f9f38f 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -1,5 +1,7 @@ import curses import textwrap +import logging +import traceback from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list from settings import settings_menu from message_handlers.tx_handler import send_message, send_traceroute @@ -432,9 +434,13 @@ def draw_messages_window(scroll_to_bottom = False): draw_packetlog_win() def draw_node_list(): - nodes_pad.erase() - box_width = nodes_win.getmaxyx()[1] - nodes_pad.resize(len(globals.node_list) + 1, box_width) + try: + nodes_pad.erase() + box_width = nodes_win.getmaxyx()[1] + nodes_pad.resize(len(globals.node_list) + 1, box_width) + except Exception as e: + logging.error(f"Error Drawing Nodes List: {e}") + logging.error("Traceback: %s", traceback.format_exc()) for i, node_num in enumerate(globals.node_list): node = globals.interface.nodesByNum[node_num] From 5690329b06999665f127a87fab2470d9445933d4 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Feb 2025 22:11:53 -0800 Subject: [PATCH 20/73] notes --- ui/curses_ui.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 9f9f38f..1464fba 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -73,7 +73,8 @@ def handle_resize(stdscr, firstrun): packetlog_win.resize(int(height / 3), messages_width) packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) - # Reinitialize `nodes_pad` in case it was lost during a resize + # Reinitialize `nodes_pad` in case it was lost during a resize. + # We shouldn't need this but nodes_pad keeps throwing and error in draw_node_list so... try: nodes_pad.getmaxyx() # Check if it's still valid except: @@ -664,12 +665,6 @@ def get_msg_window_lines(): def refresh_pad(window): win_height = channel_win.getmaxyx()[0] - selected_item = globals.selected_channel - pad = channel_pad - box = channel_win - lines = box.getmaxyx()[0] - 2 - start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders - if(window == 1): pad = messages_pad box = messages_win @@ -681,13 +676,19 @@ def refresh_pad(window): packetlog_win.box() packetlog_win.refresh() - if(window == 2): + elif(window == 2): pad = nodes_pad box = nodes_win lines = box.getmaxyx()[0] - 2 selected_item = globals.selected_node start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders + else: + pad = channel_pad + box = channel_win + lines = box.getmaxyx()[0] - 2 + selected_item = globals.selected_channel + start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders pad.refresh(start_index, 0, box.getbegyx()[0] + 1, box.getbegyx()[1] + 1, From 57042d2050b3e68811d6de874074df06d309cec9 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Sat, 8 Feb 2025 09:14:25 -0800 Subject: [PATCH 21/73] Maybe fix startup error (again) (#124) * more excuses * none isn't better than nothing * more checks * typo * refactor * less is more --- ui/curses_ui.py | 84 +++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 1464fba..1ee86d6 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -11,8 +11,6 @@ import default_config as config import ui.dialog import globals - - def handle_resize(stdscr, firstrun): global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win @@ -37,31 +35,46 @@ def handle_resize(stdscr, firstrun): nodes_pad = curses.newpad(1,1) channel_pad = curses.newpad(1,1) - entry_win.bkgd(get_color("background")) - channel_win.bkgd(get_color("background")) - messages_win.bkgd(get_color("background")) - nodes_win.bkgd(get_color("background")) - function_win.bkgd(get_color("background")) - packetlog_win.bkgd(get_color("background")) + # Set background colors for windows + for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]: + win.bkgd(get_color("background")) - messages_pad.bkgd(get_color("background")) - nodes_pad.bkgd(get_color("background")) - channel_pad.bkgd(get_color("background")) + # Set background colors for pads + for pad in [messages_pad, nodes_pad, channel_pad]: + pad.bkgd(get_color("background")) + # Set colors for window frames + for win in [channel_win, entry_win, nodes_win, messages_win, function_win]: + win.attrset(get_color("window_frame")) - channel_win.attrset(get_color("window_frame")) - entry_win.attrset(get_color("window_frame")) - nodes_win.attrset(get_color("window_frame")) - messages_win.attrset(get_color("window_frame")) - function_win.attrset(get_color("window_frame")) else: - entry_win.erase() - channel_win.erase() - messages_win.erase() - nodes_win.erase() - function_win.erase() - packetlog_win.erase() + # Ensure all windows are initialized before using them + if entry_win is None: + entry_win = curses.newwin(3, width, 0, 0) + if channel_win is None: + channel_win = curses.newwin(height - 6, channel_width, 3, 0) + if messages_win is None: + messages_win = curses.newwin(height - 6, messages_width, 3, channel_width) + if nodes_win is None: + nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width) + if function_win is None: + function_win = curses.newwin(3, width, height - 3, 0) + if packetlog_win is None: + packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width) + + # Ensure pads are initialized + if messages_pad is None: + messages_pad = curses.newpad(1,1) + if nodes_pad is None: + nodes_pad = curses.newpad(1,1) + if channel_pad is None: + channel_pad = curses.newpad(1,1) + + + for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]: + win.erase() + entry_win.resize(3, width) channel_win.resize(height - 6, channel_width) messages_win.resize(height - 6, messages_width) @@ -73,27 +86,12 @@ def handle_resize(stdscr, firstrun): packetlog_win.resize(int(height / 3), messages_width) packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) - # Reinitialize `nodes_pad` in case it was lost during a resize. - # We shouldn't need this but nodes_pad keeps throwing and error in draw_node_list so... - try: - nodes_pad.getmaxyx() # Check if it's still valid - except: - nodes_pad = curses.newpad(1, 1) # Recreate if needed + # Draw window borders + for win in [channel_win, entry_win, nodes_win, messages_win, function_win]: + win.box() + win.refresh() - channel_win.box() - entry_win.box() - nodes_win.box() - messages_win.box() - - function_win.box() - - # Refresh all windows - entry_win.refresh() - channel_win.refresh() - function_win.refresh() - nodes_win.refresh() - messages_win.refresh() entry_win.keypad(True) curses.curs_set(1) @@ -435,6 +433,10 @@ def draw_messages_window(scroll_to_bottom = False): draw_packetlog_win() def draw_node_list(): + # global nodes_pad + # if nodes_pad is None: + # nodes_pad = curses.newpad(1, 1) + try: nodes_pad.erase() box_width = nodes_win.getmaxyx()[1] From 804f82cbe6dab1629af76e8b36ab66d7ddb9d534 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 8 Feb 2025 10:03:51 -0800 Subject: [PATCH 22/73] where have all the nodes_pad gone? --- main.py | 1 - ui/curses_ui.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 34686f8..ab0efd3 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,6 @@ from pubsub import pub import os import logging import traceback -import time from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 1ee86d6..115644a 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -433,9 +433,9 @@ def draw_messages_window(scroll_to_bottom = False): draw_packetlog_win() def draw_node_list(): - # global nodes_pad - # if nodes_pad is None: - # nodes_pad = curses.newpad(1, 1) + global nodes_pad + if nodes_pad is None: + nodes_pad = curses.newpad(1, 1) try: nodes_pad.erase() From 3c3bf0ad37f6a4535989404e04608cd53aeb8d6a Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 8 Feb 2025 13:23:30 -0800 Subject: [PATCH 23/73] rename enum to list --- input_handlers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/input_handlers.py b/input_handlers.py index e7cb0a1..e38759b 100644 --- a/input_handlers.py +++ b/input_handlers.py @@ -207,19 +207,19 @@ def get_list_input(prompt, current_option, list_options): return current_option -def move_highlight(old_idx, new_idx, options, enum_win, enum_pad): +def move_highlight(old_idx, new_idx, options, list_win, list_pad): if old_idx == new_idx: return # no-op - enum_pad.chgat(old_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default")) - enum_pad.chgat(new_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default", reverse = True)) + list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default")) + list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse = True)) - enum_win.refresh() + list_win.refresh() - start_index = max(0, new_idx - (enum_win.getmaxyx()[0] - 4)) + start_index = max(0, new_idx - (list_win.getmaxyx()[0] - 4)) - enum_win.refresh() - enum_pad.refresh(start_index, 0, - enum_win.getbegyx()[0] + 3, enum_win.getbegyx()[1] + 4, - enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4) + list_win.refresh() + list_pad.refresh(start_index, 0, + list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, + list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + 4 + list_win.getmaxyx()[1] - 4) \ No newline at end of file From e69c51f9c3934195c3d4f0d9a3a7af942ce58097 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Sat, 8 Feb 2025 15:57:54 -0800 Subject: [PATCH 24/73] Another attempt to fix startup errors (#125) * more excuses * none isn't better than nothing * more checks * typo * refactor * less is more * grasping at straws * more global * back up * db snapshot --- db_handler.py | 3 ++- ui/curses_ui.py | 31 ++++++++----------------------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/db_handler.py b/db_handler.py index a9aab2d..2169d51 100644 --- a/db_handler.py +++ b/db_handler.py @@ -149,9 +149,10 @@ def init_nodedb(): return # No nodes to initialize ensure_node_table_exists() # Ensure the table exists before insertion + nodes_snapshot = list(globals.interface.nodes.values()) # Insert or update all nodes - for node in globals.interface.nodes.values(): + for node in nodes_snapshot: update_node_info_in_db( user_id=node['num'], long_name=node['user'].get('longName', ''), diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 115644a..b8768a2 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -49,40 +49,22 @@ def handle_resize(stdscr, firstrun): else: - # Ensure all windows are initialized before using them - if entry_win is None: - entry_win = curses.newwin(3, width, 0, 0) - if channel_win is None: - channel_win = curses.newwin(height - 6, channel_width, 3, 0) - if messages_win is None: - messages_win = curses.newwin(height - 6, messages_width, 3, channel_width) - if nodes_win is None: - nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width) - if function_win is None: - function_win = curses.newwin(3, width, height - 3, 0) - if packetlog_win is None: - packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width) - - # Ensure pads are initialized - if messages_pad is None: - messages_pad = curses.newpad(1,1) - if nodes_pad is None: - nodes_pad = curses.newpad(1,1) - if channel_pad is None: - channel_pad = curses.newpad(1,1) - - for win in [entry_win, channel_win, messages_win, nodes_win, function_win, packetlog_win]: win.erase() entry_win.resize(3, width) + channel_win.resize(height - 6, channel_width) + messages_win.resize(height - 6, messages_width) messages_win.mvwin(3, channel_width) + nodes_win.resize(height - 6, nodes_width) nodes_win.mvwin(3, channel_width + messages_width) + function_win.resize(3, width) function_win.mvwin(height - 3, 0) + packetlog_win.resize(int(height / 3), messages_width) packetlog_win.mvwin(height - int(height / 3) - 3, channel_width) @@ -434,6 +416,7 @@ def draw_messages_window(scroll_to_bottom = False): def draw_node_list(): global nodes_pad + if nodes_pad is None: nodes_pad = curses.newpad(1, 1) @@ -665,6 +648,8 @@ def get_msg_window_lines(): return messages_win.getmaxyx()[0] - 2 - packetlog_height def refresh_pad(window): + # global messages_pad, nodes_pad, channel_pad + win_height = channel_win.getmaxyx()[0] if(window == 1): From 34cd21b323a519a900d418561ac403e8e3631d37 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Sun, 9 Feb 2025 06:46:42 -0800 Subject: [PATCH 25/73] Fix Startup Error with Thread Lock (#126) * more excuses * none isn't better than nothing * more checks * typo * refactor * less is more * grasping at straws * more global * back up * db snapshot * try a threading lock * fix conflict * lock it down * sir locks a lot * sir locks a lilttle less --- db_handler.py | 1 + globals.py | 3 +- main.py | 22 +++-- message_handlers/rx_handler.py | 145 +++++++++++++++++---------------- ui/curses_ui.py | 4 +- 5 files changed, 91 insertions(+), 84 deletions(-) diff --git a/db_handler.py b/db_handler.py index 2169d51..dbf73b5 100644 --- a/db_handler.py +++ b/db_handler.py @@ -144,6 +144,7 @@ def load_messages_from_db(): def init_nodedb(): """Initialize the node database and update it with nodes from the interface.""" + try: if not globals.interface.nodes: return # No nodes to initialize diff --git a/globals.py b/globals.py index a174cf8..78b9475 100644 --- a/globals.py +++ b/globals.py @@ -9,4 +9,5 @@ myNodeNum = 0 selected_channel = 0 selected_message = 0 selected_node = 0 -current_window = 0 \ No newline at end of file +current_window = 0 +lock = None \ No newline at end of file diff --git a/main.py b/main.py index ab0efd3..9b88fd1 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from pubsub import pub import os import logging import traceback +import threading from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface @@ -38,6 +39,8 @@ logging.basicConfig( format="%(asctime)s - %(levelname)s - %(message)s" ) +globals.lock = threading.Lock() + def main(stdscr): try: draw_splash(stdscr) @@ -45,15 +48,16 @@ def main(stdscr): args = parser.parse_args() logging.info("Initializing interface %s", args) - globals.interface = initialize_interface(args) - logging.info("Interface initialized") - globals.myNodeNum = get_nodeNum() - globals.channel_list = get_channels() - globals.node_list = get_node_list() - pub.subscribe(on_receive, 'meshtastic.receive') - init_nodedb() - load_messages_from_db() - logging.info("Starting main UI") + with globals.lock: + globals.interface = initialize_interface(args) + logging.info("Interface initialized") + globals.myNodeNum = get_nodeNum() + globals.channel_list = get_channels() + globals.node_list = get_node_list() + pub.subscribe(on_receive, 'meshtastic.receive') + init_nodedb() + load_messages_from_db() + logging.info("Starting main UI") main_ui(stdscr) except Exception as e: logging.error("An error occurred: %s", e) diff --git a/message_handlers/rx_handler.py b/message_handlers/rx_handler.py index 0261e3d..3ba0276 100644 --- a/message_handlers/rx_handler.py +++ b/message_handlers/rx_handler.py @@ -12,93 +12,94 @@ from datetime import datetime def on_receive(packet, interface): - # Update packet log - globals.packet_buffer.append(packet) - if len(globals.packet_buffer) > 20: - # Trim buffer to 20 packets - globals.packet_buffer = globals.packet_buffer[-20:] - - if globals.display_log: - draw_packetlog_win() - try: - if 'decoded' not in packet: - return + with globals.lock: + # Update packet log + globals.packet_buffer.append(packet) + if len(globals.packet_buffer) > 20: + # Trim buffer to 20 packets + globals.packet_buffer = globals.packet_buffer[-20:] + + if globals.display_log: + draw_packetlog_win() + try: + if 'decoded' not in packet: + return - # Assume any incoming packet could update the last seen time for a node - changed = refresh_node_list() - if(changed): - draw_node_list() + # Assume any incoming packet could update the last seen time for a node + changed = refresh_node_list() + if(changed): + draw_node_list() - if packet['decoded']['portnum'] == 'NODEINFO_APP': - if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]: - maybe_store_nodeinfo_in_db(packet) + if packet['decoded']['portnum'] == 'NODEINFO_APP': + if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]: + maybe_store_nodeinfo_in_db(packet) - elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': - message_bytes = packet['decoded']['payload'] - message_string = message_bytes.decode('utf-8') + elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP': + message_bytes = packet['decoded']['payload'] + message_string = message_bytes.decode('utf-8') - refresh_channels = False - refresh_messages = False + refresh_channels = False + refresh_messages = False - if packet.get('channel'): - channel_number = packet['channel'] - else: - channel_number = 0 - - if packet['to'] == globals.myNodeNum: - if packet['from'] in globals.channel_list: - pass + if packet.get('channel'): + channel_number = packet['channel'] else: - globals.channel_list.append(packet['from']) - if(packet['from'] not in globals.all_messages): - globals.all_messages[packet['from']] = [] - update_node_info_in_db(packet['from'], chat_archived=False) + channel_number = 0 + + if packet['to'] == globals.myNodeNum: + if packet['from'] in globals.channel_list: + pass + else: + globals.channel_list.append(packet['from']) + if(packet['from'] not in globals.all_messages): + globals.all_messages[packet['from']] = [] + update_node_info_in_db(packet['from'], chat_archived=False) + refresh_channels = True + + channel_number = globals.channel_list.index(packet['from']) + + if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]: + add_notification(channel_number) refresh_channels = True + else: + refresh_messages = True - channel_number = globals.channel_list.index(packet['from']) + # Add received message to the messages list + message_from_id = packet['from'] + message_from_string = get_name_from_database(message_from_id, type='short') + ":" - if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]: - add_notification(channel_number) - refresh_channels = True - else: - refresh_messages = True + if globals.channel_list[channel_number] not in globals.all_messages: + globals.all_messages[globals.channel_list[channel_number]] = [] - # Add received message to the messages list - message_from_id = packet['from'] - message_from_string = get_name_from_database(message_from_id, type='short') + ":" + # Timestamp handling + current_timestamp = time.time() + current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00') - if globals.channel_list[channel_number] not in globals.all_messages: - globals.all_messages[globals.channel_list[channel_number]] = [] - - # Timestamp handling - current_timestamp = time.time() - current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00') - - # Retrieve the last timestamp if available - channel_messages = globals.all_messages[globals.channel_list[channel_number]] - if channel_messages: - # Check the last entry for a timestamp - for entry in reversed(channel_messages): - if entry[0].startswith("--"): - last_hour = entry[0].strip("- ").strip() - break + # Retrieve the last timestamp if available + channel_messages = globals.all_messages[globals.channel_list[channel_number]] + if channel_messages: + # Check the last entry for a timestamp + for entry in reversed(channel_messages): + if entry[0].startswith("--"): + last_hour = entry[0].strip("- ").strip() + break + else: + last_hour = None else: last_hour = None - else: - last_hour = None - # Add a new timestamp if it's a new hour - if last_hour != current_hour: - globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", "")) + # Add a new timestamp if it's a new hour + if last_hour != current_hour: + globals.all_messages[globals.channel_list[channel_number]].append((f"-- {current_hour} --", "")) - globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string)) + globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string)) - if refresh_channels: - draw_channel_list() - if refresh_messages: - draw_messages_window(True) + if refresh_channels: + draw_channel_list() + if refresh_messages: + draw_messages_window(True) - save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string) + save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string) - except KeyError as e: - logging.error(f"Error processing packet: {e}") + except KeyError as e: + logging.error(f"Error processing packet: {e}") diff --git a/ui/curses_ui.py b/ui/curses_ui.py index b8768a2..5154154 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -417,8 +417,8 @@ def draw_messages_window(scroll_to_bottom = False): def draw_node_list(): global nodes_pad - if nodes_pad is None: - nodes_pad = curses.newpad(1, 1) + # if nodes_pad is None: + nodes_pad = curses.newpad(1, 1) try: nodes_pad.erase() From 71aeae4f926bc04aa89ada38752e68ec46e73c76 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sun, 9 Feb 2025 20:49:49 -0800 Subject: [PATCH 26/73] add note in draw_node_list --- ui/curses_ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 5154154..11e7e68 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -417,9 +417,12 @@ def draw_messages_window(scroll_to_bottom = False): def draw_node_list(): global nodes_pad + # This didn't work, for some reason an error is thown on startup, so we just create the pad every time # if nodes_pad is None: + # nodes_pad = curses.newpad(1, 1) nodes_pad = curses.newpad(1, 1) + try: nodes_pad.erase() box_width = nodes_win.getmaxyx()[1] From 989c3cf44ecb43adfc64d4c2531fd41e9cc40c72 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Sun, 9 Feb 2025 22:09:15 -0800 Subject: [PATCH 27/73] add region check at startup (#127) --- main.py | 11 +++++++++++ settings.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/main.py b/main.py index 9b88fd1..75216c0 100644 --- a/main.py +++ b/main.py @@ -17,7 +17,9 @@ from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface from message_handlers.rx_handler import on_receive from ui.curses_ui import main_ui, draw_splash +from input_handlers import get_list_input from utilities.utils import get_channels, get_node_list, get_nodeNum +from settings import set_region from db_handler import init_nodedb, load_messages_from_db import default_config as config import globals @@ -50,6 +52,7 @@ def main(stdscr): logging.info("Initializing interface %s", args) with globals.lock: globals.interface = initialize_interface(args) + logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() globals.channel_list = get_channels() @@ -58,6 +61,14 @@ def main(stdscr): init_nodedb() load_messages_from_db() logging.info("Starting main UI") + + if globals.interface.localNode.localConfig.lora.region == 0: + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + if confirmation == "Yes": + set_region() + globals.interface.close() + globals.interface = initialize_interface(args) + main_ui(stdscr) except Exception as e: logging.error("An error occurred: %s", e) diff --git a/settings.py b/settings.py index 11527dc..946a772 100644 --- a/settings.py +++ b/settings.py @@ -9,6 +9,7 @@ from ui.menus import generate_menu_from_protobuf from ui.colors import setup_colors, get_color from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface +from ui.dialog import dialog from user_config import json_editor import globals @@ -348,6 +349,14 @@ def settings_menu(stdscr, interface): menu_win.refresh() break +def set_region(): + node = globals.interface.getNode('^local') + device_config = node.localConfig + regions = [region.name for region in device_config.lora.DESCRIPTOR.fields_by_name["region"].enum_type.values] + new_region = get_list_input('Select your region:', 'UNSET', regions) + node.localConfig.lora.region = new_region + node.writeConfig("lora") + def main(stdscr): logging.basicConfig( # Run `tail -f client.log` in another terminal to view live From 47ab0a5b9a48de7426ea4663a175c3b41a847db4 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sun, 9 Feb 2025 22:09:59 -0800 Subject: [PATCH 28/73] bump version --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 75216c0..2264e5d 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ ''' Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Powered by Meshtastic.org -V 1.2.0 +V 1.2.1 ''' import curses From fb60773ae65e1302f6a51ff40ba152798963cbc8 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 12 Feb 2025 15:18:58 -0800 Subject: [PATCH 29/73] don't close interface after region set --- main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/main.py b/main.py index 2264e5d..3329bfe 100644 --- a/main.py +++ b/main.py @@ -66,8 +66,6 @@ def main(stdscr): confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region() - globals.interface.close() - globals.interface = initialize_interface(args) main_ui(stdscr) except Exception as e: From d7f0bee54cf4e3bbd28944f38c2309d71bc40760 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 12 Feb 2025 15:22:02 -0800 Subject: [PATCH 30/73] try setting region earlier --- main.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 3329bfe..d719375 100644 --- a/main.py +++ b/main.py @@ -52,7 +52,10 @@ def main(stdscr): logging.info("Initializing interface %s", args) with globals.lock: globals.interface = initialize_interface(args) - + if globals.interface.localNode.localConfig.lora.region == 0: + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + if confirmation == "Yes": + set_region() logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() globals.channel_list = get_channels() @@ -61,11 +64,6 @@ def main(stdscr): init_nodedb() load_messages_from_db() logging.info("Starting main UI") - - if globals.interface.localNode.localConfig.lora.region == 0: - confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) - if confirmation == "Yes": - set_region() main_ui(stdscr) except Exception as e: From c83ccea4ef57c8e6cdaf3d32da9a6b89f7f6d477 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 12 Feb 2025 15:27:33 -0800 Subject: [PATCH 31/73] use protubuf number when setting region --- settings.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/settings.py b/settings.py index 946a772..f183a31 100644 --- a/settings.py +++ b/settings.py @@ -352,9 +352,20 @@ def settings_menu(stdscr, interface): def set_region(): node = globals.interface.getNode('^local') device_config = node.localConfig - regions = [region.name for region in device_config.lora.DESCRIPTOR.fields_by_name["region"].enum_type.values] - new_region = get_list_input('Select your region:', 'UNSET', regions) - node.localConfig.lora.region = new_region + lora_descriptor = device_config.lora.DESCRIPTOR + + # Get the enum mapping of region names to their numerical values + region_enum = lora_descriptor.fields_by_name["region"].enum_type + region_name_to_number = {v.name: v.number for v in region_enum.values} + + regions = list(region_name_to_number.keys()) + + new_region_name = get_list_input('Select your region:', 'UNSET', regions) + + # Convert region name to corresponding enum number + new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found + + node.localConfig.lora.region = new_region_number node.writeConfig("lora") From 5dd06624e34a644380b5de8e9bf6302b5558620c Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 12 Feb 2025 16:08:18 -0800 Subject: [PATCH 32/73] do close interface after region set --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index d719375..c39c3af 100644 --- a/main.py +++ b/main.py @@ -56,6 +56,8 @@ def main(stdscr): confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region() + globals.interface.close() + globals.interface = initialize_interface(args) logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() globals.channel_list = get_channels() From 8c837e68a0b9323bd71c1517f914fa622ff1acac Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:30:42 -0800 Subject: [PATCH 33/73] keep cursor in the input window (#130) --- ui/curses_ui.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 11e7e68..7388b9d 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -74,7 +74,6 @@ def handle_resize(stdscr, firstrun): win.box() win.refresh() - entry_win.keypad(True) curses.curs_set(1) @@ -90,13 +89,12 @@ def handle_resize(stdscr, firstrun): def main_ui(stdscr): - + global input_text + input_text = "" stdscr.keypad(True) get_channels() - handle_resize(stdscr, True) - input_text = "" while True: draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) @@ -444,6 +442,11 @@ def draw_node_list(): refresh_pad(2) + # Restore cursor to input field + entry_win.move(1, len("Input: ") + len(input_text)+1) + entry_win.refresh() + curses.curs_set(1) + def select_channel(idx): old_selected_channel = globals.selected_channel globals.selected_channel = max(0, min(idx, len(globals.channel_list) - 1)) @@ -540,6 +543,11 @@ def draw_packetlog_win(): packetlog_win.box() packetlog_win.refresh() + # Restore cursor to input field + entry_win.move(1, len("Input: ") + len(input_text)+1) + entry_win.refresh() + curses.curs_set(1) + def search(win): start_idx = globals.selected_node select_func = select_node From 3db44f4ae34cbe821d0313431b4b3e16469496df Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 20 Feb 2025 21:57:09 -0800 Subject: [PATCH 34/73] remove double writeconfig --- save_to_radio.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/save_to_radio.py b/save_to_radio.py index 0b6c1b0..28f45be 100644 --- a/save_to_radio.py +++ b/save_to_radio.py @@ -122,10 +122,5 @@ def save_changes(interface, menu_path, modified_settings): 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}") From f644b92356ec63bb392e1b9d286b975a633d1332 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 20 Feb 2025 22:31:19 -0800 Subject: [PATCH 35/73] use global interface in save --- globals.py | 4 ++-- save_to_radio.py | 8 ++++---- settings.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/globals.py b/globals.py index 78b9475..3b598e3 100644 --- a/globals.py +++ b/globals.py @@ -1,4 +1,5 @@ interface = None +lock = None display_log = False all_messages = {} channel_list = [] @@ -9,5 +10,4 @@ myNodeNum = 0 selected_channel = 0 selected_message = 0 selected_node = 0 -current_window = 0 -lock = None \ No newline at end of file +current_window = 0 \ No newline at end of file diff --git a/save_to_radio.py b/save_to_radio.py index 28f45be..3fc7464 100644 --- a/save_to_radio.py +++ b/save_to_radio.py @@ -5,7 +5,7 @@ import base64 from db_handler import update_node_info_in_db import globals -def save_changes(interface, menu_path, modified_settings): +def save_changes(menu_path, modified_settings): """ Save changes to the device based on modified settings. :param interface: Meshtastic interface instance @@ -17,7 +17,7 @@ def save_changes(interface, menu_path, modified_settings): logging.info("No changes to save. modified_settings is empty.") return - node = interface.getNode('^local') + node = globals.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 @@ -27,7 +27,7 @@ def save_changes(interface, menu_path, modified_settings): lon = float(modified_settings.get('longitude', 0.0)) alt = int(modified_settings.get('altitude', 0)) - interface.localNode.setFixedPosition(lat, lon, alt) + globals.interface.localNode.setFixedPosition(lat, lon, alt) logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") return @@ -123,4 +123,4 @@ def save_changes(interface, menu_path, modified_settings): logging.error(f"Failed to write configuration for category '{config_category}': {e}") except Exception as e: - logging.error(f"Error saving changes: {e}") + logging.error(f"Error saving changes: {e}") \ No newline at end of file diff --git a/settings.py b/settings.py index f183a31..461e572 100644 --- a/settings.py +++ b/settings.py @@ -152,7 +152,7 @@ def settings_menu(stdscr, interface): menu_win.erase() menu_win.refresh() if show_save_option and selected_index == len(options): - save_changes(interface, menu_path, modified_settings) + save_changes(menu_path, modified_settings) modified_settings.clear() logging.info("Changes Saved") From b08612596227e19ad72977bea4474f64fb5acce3 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 21 Feb 2025 18:24:19 -0800 Subject: [PATCH 36/73] new skip fields check --- ui/menus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/menus.py b/ui/menus.py index 13cde37..6596a4e 100644 --- a/ui/menus.py +++ b/ui/menus.py @@ -14,7 +14,8 @@ def extract_fields(message_instance, current_config=None): menu = {} fields = message_instance.DESCRIPTOR.fields for field in fields: - if field.name in {"sessionkey", "channel_num", "id", "ignore_incoming"}: # Skip certain fields + skip_fields = {"sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming"} + if any(skip_field in field.full_name for skip_field in skip_fields): continue if field.message_type: # Nested message From aa8a66ef2229ff8d978ae6cd4cd9126844fe16b6 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 22 Feb 2025 18:40:20 -0800 Subject: [PATCH 37/73] fix config overwrite option --- settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.py b/settings.py index 461e572..7ddd1d0 100644 --- a/settings.py +++ b/settings.py @@ -189,7 +189,7 @@ def settings_menu(stdscr, interface): if os.path.exists(yaml_file_path): overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) - if overwrite == "Yes": + if overwrite == "No": logging.info("Export cancelled: User chose not to overwrite.") continue # Return to menu os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) From fac5c690ae39eddc33f456a75e1cbabbe6065d49 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 25 Feb 2025 21:30:44 -0800 Subject: [PATCH 38/73] fix region set at boot --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index c39c3af..d8afb0a 100644 --- a/main.py +++ b/main.py @@ -56,7 +56,7 @@ def main(stdscr): confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region() - globals.interface.close() + globals.interface = None globals.interface = initialize_interface(args) logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() From 5f88d0e6fce195317bbacbfef93d34755dd03209 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 25 Feb 2025 21:48:11 -0800 Subject: [PATCH 39/73] add some interface checks --- main.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index d8afb0a..67c8831 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ import os import logging import traceback import threading +import sys from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface @@ -52,12 +53,23 @@ def main(stdscr): logging.info("Initializing interface %s", args) with globals.lock: globals.interface = initialize_interface(args) + + # Check if the interface was successfully initialized + if not globals.interface: + logging.critical("Failed to initialize interface. Exiting...") + sys.exit(1) # Exit the program with an error code + if globals.interface.localNode.localConfig.lora.region == 0: - confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region() - globals.interface = None + globals.interface.close() globals.interface = initialize_interface(args) + + # Ensure the new interface is valid after reinitialization + if not globals.interface: + logging.critical("Re-initialization of interface failed. Exiting...") + sys.exit(1) logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() globals.channel_list = get_channels() From c43d0144175039446e65cfa5801958e2cdcda19c Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 26 Feb 2025 07:55:30 -0800 Subject: [PATCH 40/73] revert iface checks --- main.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/main.py b/main.py index 67c8831..c39c3af 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,6 @@ import os import logging import traceback import threading -import sys from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface @@ -53,23 +52,12 @@ def main(stdscr): logging.info("Initializing interface %s", args) with globals.lock: globals.interface = initialize_interface(args) - - # Check if the interface was successfully initialized - if not globals.interface: - logging.critical("Failed to initialize interface. Exiting...") - sys.exit(1) # Exit the program with an error code - if globals.interface.localNode.localConfig.lora.region == 0: - confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": set_region() globals.interface.close() globals.interface = initialize_interface(args) - - # Ensure the new interface is valid after reinitialization - if not globals.interface: - logging.critical("Re-initialization of interface failed. Exiting...") - sys.exit(1) logging.info("Interface initialized") globals.myNodeNum = get_nodeNum() globals.channel_list = get_channels() From 50c07827f18ea5d682ab1b0d348c1ff61af64806 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Wed, 26 Feb 2025 15:36:01 -0800 Subject: [PATCH 41/73] Suppress errors from the console (#135) * suppress console messages * send errors to log * stderr and stdout --- main.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index c39c3af..b5dfde6 100644 --- a/main.py +++ b/main.py @@ -9,9 +9,11 @@ V 1.2.1 import curses from pubsub import pub import os +import sys import logging import traceback import threading +import contextlib from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface @@ -24,8 +26,6 @@ from db_handler import init_nodedb, load_messages_from_db import default_config as config import globals -import os - # Set ncurses compatibility settings os.environ["NCURSES_NO_UTF8_ACS"] = "1" os.environ["LANG"] = "C.UTF-8" @@ -74,8 +74,15 @@ def main(stdscr): raise if __name__ == "__main__": - try: - curses.wrapper(main) - except Exception as e: - logging.error("Fatal error in curses wrapper: %s", e) - logging.error("Traceback: %s", traceback.format_exc()) \ No newline at end of file + log_file = config.log_file_path + log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes + + sys.stdout = log_f + sys.stderr = log_f + + with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f): + try: + curses.wrapper(main) + except Exception as e: + logging.error("Fatal error in curses wrapper: %s", e) + logging.error("Traceback: %s", traceback.format_exc()) \ No newline at end of file From 8190bdaafa2c1952247a6ec356e099d3052a5117 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 26 Feb 2025 16:08:57 -0800 Subject: [PATCH 42/73] log force exit --- main.py | 62 ++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/main.py b/main.py index b5dfde6..1420245 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ V 1.2.1 import curses from pubsub import pub import os +import io import sys import logging import traceback @@ -44,45 +45,56 @@ logging.basicConfig( globals.lock = threading.Lock() def main(stdscr): + output_capture = io.StringIO() try: - draw_splash(stdscr) - parser = setup_parser() - args = parser.parse_args() + with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): + draw_splash(stdscr) + parser = setup_parser() + args = parser.parse_args() + + logging.info("Initializing interface %s", args) + with globals.lock: + globals.interface = initialize_interface(args) + + if globals.interface.localNode.localConfig.lora.region == 0: + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + if confirmation == "Yes": + set_region() + globals.interface.close() + globals.interface = initialize_interface(args) + + logging.info("Interface initialized") + globals.myNodeNum = get_nodeNum() + globals.channel_list = get_channels() + globals.node_list = get_node_list() + pub.subscribe(on_receive, 'meshtastic.receive') + init_nodedb() + load_messages_from_db() + logging.info("Starting main UI") + + main_ui(stdscr) - logging.info("Initializing interface %s", args) - with globals.lock: - globals.interface = initialize_interface(args) - if globals.interface.localNode.localConfig.lora.region == 0: - confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) - if confirmation == "Yes": - set_region() - globals.interface.close() - globals.interface = initialize_interface(args) - logging.info("Interface initialized") - globals.myNodeNum = get_nodeNum() - globals.channel_list = get_channels() - globals.node_list = get_node_list() - pub.subscribe(on_receive, 'meshtastic.receive') - init_nodedb() - load_messages_from_db() - logging.info("Starting main UI") - - main_ui(stdscr) except Exception as e: + console_output = output_capture.getvalue() logging.error("An error occurred: %s", e) logging.error("Traceback: %s", traceback.format_exc()) - raise + logging.error("Console output before crash:\n%s", console_output) + raise # Re-raise only unexpected errors if __name__ == "__main__": log_file = config.log_file_path log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes - + sys.stdout = log_f sys.stderr = log_f with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f): try: curses.wrapper(main) + except KeyboardInterrupt: + logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging + sys.exit(0) # Ensure a clean exit except Exception as e: logging.error("Fatal error in curses wrapper: %s", e) - logging.error("Traceback: %s", traceback.format_exc()) \ No newline at end of file + logging.error("Traceback: %s", traceback.format_exc()) + sys.exit(1) # Exit with an error code \ No newline at end of file From eb70e591ae6103f542d22c007fd3a2c76ff1ec03 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 5 Mar 2025 12:06:12 -0800 Subject: [PATCH 43/73] break out splash --- main.py | 6 +++++- ui/curses_ui.py | 25 ------------------------- ui/splash.py | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 ui/splash.py diff --git a/main.py b/main.py index 1420245..3dd53a7 100644 --- a/main.py +++ b/main.py @@ -19,7 +19,9 @@ import contextlib from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface from message_handlers.rx_handler import on_receive -from ui.curses_ui import main_ui, draw_splash +from ui.curses_ui import main_ui +from ui.colors import setup_colors +from ui.splash import draw_splash from input_handlers import get_list_input from utilities.utils import get_channels, get_node_list, get_nodeNum from settings import set_region @@ -48,6 +50,8 @@ def main(stdscr): output_capture = io.StringIO() try: with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): + + setup_colors() draw_splash(stdscr) parser = setup_parser() args = parser.parse_args() diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 7388b9d..d54117e 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -304,31 +304,6 @@ def main_ui(stdscr): else: input_text += chr(char) -def draw_splash(stdscr): - setup_colors() - curses.curs_set(0) - - stdscr.clear() - stdscr.bkgd(get_color("background")) - - height, width = stdscr.getmaxyx() - 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)) - stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text")) - - stdscr.attrset(get_color("window_frame")) - stdscr.box() - stdscr.refresh() - curses.napms(500) def draw_channel_list(): diff --git a/ui/splash.py b/ui/splash.py new file mode 100644 index 0000000..08fe2e5 --- /dev/null +++ b/ui/splash.py @@ -0,0 +1,27 @@ +import curses +from ui.colors import get_color + +def draw_splash(stdscr): + curses.curs_set(0) + + stdscr.clear() + stdscr.bkgd(get_color("background")) + + height, width = stdscr.getmaxyx() + 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)) + stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text")) + + stdscr.attrset(get_color("window_frame")) + stdscr.box() + stdscr.refresh() + curses.napms(500) \ No newline at end of file From 6477d8aeeab4609bcaf82a84f1c0b119821a4bc4 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Fri, 7 Mar 2025 16:54:04 -0800 Subject: [PATCH 44/73] Merge changes from control repo (#136) * init * working changes * working changes * changes * working changes --- input_handlers.py | 225 ------- localisations/en.ini | 279 ++++++++ main.py | 20 +- message_handlers/rx_handler.py | 4 +- message_handlers/tx_handler.py | 4 +- settings.py | 435 ++----------- ui/colors.py | 2 +- ui/control_ui.py | 603 ++++++++++++++++++ ui/curses_ui.py | 4 +- default_config.py => ui/default_config.py | 32 +- ui/menus.py | 50 +- user_config.py => ui/user_config.py | 4 +- utilities/control_utils.py | 67 ++ db_handler.py => utilities/db_handler.py | 2 +- utilities/input_handlers.py | 408 ++++++++++++ .../save_to_radio.py | 49 +- utilities/utils.py | 2 +- 17 files changed, 1524 insertions(+), 666 deletions(-) delete mode 100644 input_handlers.py create mode 100644 localisations/en.ini create mode 100644 ui/control_ui.py rename default_config.py => ui/default_config.py (87%) rename user_config.py => ui/user_config.py (98%) create mode 100644 utilities/control_utils.py rename db_handler.py => utilities/db_handler.py (99%) create mode 100644 utilities/input_handlers.py rename save_to_radio.py => utilities/save_to_radio.py (74%) diff --git a/input_handlers.py b/input_handlers.py deleted file mode 100644 index e38759b..0000000 --- a/input_handlers.py +++ /dev/null @@ -1,225 +0,0 @@ -import curses -import ipaddress -from ui.colors import get_color - -def get_text_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.bkgd(get_color("background")) - input_win.attrset(get_color("window_frame")) - input_win.border() - - # Display the prompt - input_win.addstr(1, 2, prompt, get_color("settings_default", bold=True)) - input_win.addstr(3, 2, "Enter value: ", get_color("settings_default")) - input_win.refresh() - - # Check if "shortName" is in the prompt, and set max length accordingly - max_length = 4 if "shortName" in prompt else None - - curses.curs_set(1) - - user_input = "" - input_position = (3, 15) # Tuple for row and column - row, col = input_position # Unpack tuple - while True: - key = input_win.get_wch(row, col + len(user_input)) # Adjust cursor position dynamically - if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow - curses.curs_set(0) - return None # Exit without returning a value - elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): - break - elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace - user_input = user_input[:-1] - input_win.addstr(row, col, " " * (len(user_input) + 1), get_color("settings_default")) # Clear the line - input_win.addstr(row, col, user_input, get_color("settings_default")) - elif max_length is None or len(user_input) < max_length: # Enforce max length if applicable - # Append typed character to input text - if(isinstance(key, str)): - user_input += key - else: - user_input += chr(key) - input_win.addstr(3, 15, user_input, get_color("settings_default")) - - curses.curs_set(0) - - # Clear the input window - input_win.erase() - input_win.refresh() - return user_input - - -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.bkgd(get_color("background")) - repeated_win.attrset(get_color("window_frame")) - repeated_win.keypad(True) # Enable keypad for special keys - - curses.echo() - curses.curs_set(1) - user_input = "" - - while True: - repeated_win.erase() - repeated_win.border() - repeated_win.addstr(1, 2, "Enter comma-separated values:", get_color("settings_default", bold=True)) - repeated_win.addstr(3, 2, f"Current: {', '.join(map(str, current_value))}", get_color("settings_default")) - repeated_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default")) - 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_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.bkgd(get_color("background")) - fixed32_win.attrset(get_color("window_frame")) - fixed32_win.keypad(True) - - curses.echo() - curses.curs_set(1) - user_input = "" - - while True: - fixed32_win.erase() - 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 - - -def get_list_input(prompt, current_option, list_options): - """ - Displays a scrollable list of list_options for the user to choose from. - """ - selected_index = list_options.index(current_option) if current_option in list_options else 0 - - height = min(len(list_options) + 5, curses.LINES - 2) - width = 60 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 - - list_win = curses.newwin(height, width, start_y, start_x) - list_win.bkgd(get_color("background")) - list_win.attrset(get_color("window_frame")) - list_win.keypad(True) - - list_pad = curses.newpad(len(list_options) + 1, width - 8) - list_pad.bkgd(get_color("background")) - - # Render header - list_win.erase() - list_win.border() - list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True)) - - # Render options on the pad - for idx, color in enumerate(list_options): - if idx == selected_index: - list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True)) - else: - list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default")) - - # Initial refresh - list_win.refresh() - list_pad.refresh(0, 0, - list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, - list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) - - while True: - key = list_win.getch() - - if key == curses.KEY_UP: - old_selected_index = selected_index - selected_index = max(0, selected_index - 1) - move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) - elif key == curses.KEY_DOWN: - old_selected_index = selected_index - selected_index = min(len(list_options) - 1, selected_index + 1) - move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) - elif key == ord('\n'): # Enter key - return list_options[selected_index] - elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow - return current_option - - -def move_highlight(old_idx, new_idx, options, list_win, list_pad): - if old_idx == new_idx: - return # no-op - - list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default")) - list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse = True)) - - list_win.refresh() - - start_index = max(0, new_idx - (list_win.getmaxyx()[0] - 4)) - - list_win.refresh() - list_pad.refresh(start_index, 0, - list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, - list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + 4 + list_win.getmaxyx()[1] - 4) - \ No newline at end of file diff --git a/localisations/en.ini b/localisations/en.ini new file mode 100644 index 0000000..ebd01ef --- /dev/null +++ b/localisations/en.ini @@ -0,0 +1,279 @@ +#field_name, "Human readable field name with first word capitalized", "Help text with [warning]warnings[/warning], [note]notes[/note], [underline]underlines[/underline], \033[31mANSI color codes\033[0m and \nline breaks." +[User Settings] +user, "User" +longName, "Node long name", "If you are a licensed HAM operator and have enabled HAM mode, this must be set to your HAM operator call sign." +shortName, "Node short name", "Must be up to 4 bytes. Usually this is 4 characters, if using latin characters and no emojis." +isLicensed, "Enable licensed amateur (HAM) mode", "IMPORTANT: Read Meshtastic help documentation before enabling." + +[Channels.channel] +title, "Channels" +channel_num, "Channel number", "The index number of this channel." +psk, "PSK", "The channel's encryption key." +name, "Name", "The channel's name." +id, "", "" +uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node." +downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel." +module_settings, "Module settings", "Position precision and Client Mute." +position_precision, "Position precision", "The precision level of location data sent on this channel." +is_client_muted, "", "" + +[config.device] +title, "Device" +role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information." +serial_enabled, "Enable serial console", "" +button_gpio, "Button GPIO", "GPIO pin for user button." +buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer." +rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast." +node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Femtofox will still send nodeinfo in response to new nodes on the mesh." +double_tap_as_button_press, "Double tap as button press", "This option will enable a double tap, when a supported accelerometer is attached to the device, to be treated as a button press." +is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" +disable_triple_click, "Disable triple button press", "" +tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs." +led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED." + +[config.position] +title, "Position" +position_broadcast_secs, "Position broadcast interval", "If smart broadcast is off we should send our position this often." +position_broadcast_smart_enabled, "Smart position broadcast enabled", "Smart broadcast will send out your position at an increased frequency only if your location has changed enough for a position update to be useful." +fixed_position, "Fixed position", "If set, this use a fixed position. The device will generate GPS updates but use whatever the last lat/lon/alt it saved for the node. Position can be set by an internal GPS or with smartphone GPS." +latitude, "Latitude", "" +longitude, "Longitude", "" +altitude, "Altitude", "" +gps_enabled, "GPS enabled", "" +gps_update_interval, "GPS update interval", "How often we should try to get GPS position (in seconds), or zero for the default of once every 2 minutes, or a very large value (maxint) to update only once at boot." +gps_attempt_time, "GPS attempt time", "" +position_flags, "Position flags", "See Meshtastic docs for more information." +rx_gpio, "GPS RX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the RX pin of a GPS module." +tx_gpio, "GPS TX GPIO pin", "If your device does not have a fixed GPS chip, you can define the GPIO pins for the TX pin of a GPS module." +broadcast_smart_minimum_distance, "GPS smart position min distance", "The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if smart broadcast is enabled." +broadcast_smart_minimum_interval_secs, "GPS smart position min interval", "The minimum number of seconds (since the last send) before we can send a position to the mesh if smart broadcast is enabled." +gps_en_gpio, "GPS enable GPIO", "" +gps_mode, "GPS mode", "Configures whether the GPS functionality is enabled, disabled, or not present on the node." + +[config.power] +title, "Power" +is_power_saving, "Enable power saving mode", "Automatically shut down a device after this many seconds if power is lost." +on_battery_shutdown_after_secs, "Battery shutdown interval", "" +adc_multiplier_override, "ADC multiplier override", "Ratio of voltage divider for battery pin. Overrides the ADC_MULTIPLIER defined in the firmware device variant file for battery voltage calculation. See Meshtastic docs for more info." +wait_bluetooth_secs, "Bluetooth", "How long to wait before turning off BLE when no bluetooth device is connected." +sds_secs, "Super deep sleep interval", "While in Light Sleep if mesh_sds_timeout_secs is exceeded we will lower into super deep sleep for this value or a button press. 0 for default of one year" +ls_secs, "Light sleep interval", "ESP32 only. In light sleep the CPU is suspended, LoRa radio is on, BLE is off and GPS is on." +min_wake_secs, "Minimum wake interval", "While in light sleep when we receive packets on the LoRa radio we will wake and handle them and stay awake in no Bluetooth mode for this interval in seconds." +device_battery_ina_address, "Device battery INA2xx address", "If an INA-2XX device is auto-detected on one of the I2C buses at the specified address, it will be used as the authoritative source for reading device battery level voltage. Setting is ignored for devices with PMUs (e.g. T-beams)" +powermon_enables, "Power monitor enables", "If non-zero, we want powermon log outputs. With the particular (bitfield) sources enabled." + +[config.network] +title, "Network" +wifi_enabled, "Wi-Fi enabled", "Enables or Disables Wi-Fi." +wifi_ssid, "Wi-Fi SSID", "This is your Wi-Fi Network's SSID." +wifi_psk, "Wi-Fi PSK", "This is your Wi-Fi Network's password." +ntp_server, "NTP server", "The network time server used if IP networking is available." +eth_enabled, "Ethernet enabled", "Enables or Disables Ethernet on some hardware models." +address_mode, "IPv4 networking mode", "Set to DHCP by default. Change to STATIC to use a static IP address. Applies to both Ethernet and Wi-Fi." +ipv4_config, "IPv4 configuration", "Advanced network settings" +ip, "IPv4 static address", "" +gateway, "IPv4 gateway", "" +subnet, "IPv4 subnet", "" +dns, "IPv4 DNS server", "" +rsyslog_server, "RSyslog server", "" +enabled_protocols, "Enabled protocols", "" + +[config.display] +title, "Display" +screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received." +gps_format, "GPS format", "The format used to display GPS coordinates on the device screen." +auto_screen_carousel_secs, "Auto carousel interval", "Automatically toggles to the next page on the screen like a carousel, based on the specified interval in seconds." +compass_north_top, "Always point north", "If set, compass heading on screen outside of the circle will always point north. This feature is off by default and the top of display represents your heading direction, the North indicator will move around the circle." +flip_screen, "Flip screen", "Whether to flip the screen vertically." +units, "Preferred display units", "Switch between METRIC (default) and IMPERIAL units." +oled, "OLED definition", "The type of OLED Controller is auto-detected by default, but can be defined with this setting if the auto-detection fails. For the SH1107, we assume a square display with 128x128 Pixels like the GME128128-1." +displaymode, "Display mode", "DEFAULT, TWOCOLOR, INVERTED or COLOR. TWOCOLOR: intended for OLED displays with first line a different color. INVERTED: will invert bicolor area, resulting in white background headline on monochrome displays." +heading_bold, "Heading bold", "The heading can be hard to read when 'INVERTED' or 'TWOCOLOR' display mode is used. This setting will make the heading bold, so it is easier to read." +wake_on_tap_or_motion, "Wake on tap or motion", "This option enables the ability to wake the device screen when motion, such as a tap on the device, is detected via an attached accelerometer, or a capacitive touch button." +compass_orientation, "Compass orientation", "Whether to rotate the compass." +use_12h_clock, "Use 12 hour clock" + +[config.device_ui] +title, "Device UI" +version, "Version", "" +screen_brightness, "Screen brightness", "" +screen_timeout, "Screen timeout", "" +screen_lock, "Screen lock", "" +settings_lock, "Settings lock", "" +pin_code, "PIN code", "" +theme, "Theme", "" +alert_enabled, "Alert enabled", "" +banner_enabled, "Banner enabled", "" +ring_tone_id, "Ring tone ID", "" + +[config.lora] +title, "LoRa" +use_preset, "Use modem preset", "Presets are pre-defined modem settings (Bandwidth, Spread Factor, and Coding Rate) which influence both message speed and range. The vast majority of users use a preset." +modem_preset, "Preset", "The default preset will provide a strong mixture of speed and range, for most users." +bandwidth, "Bandwidth", "Width of the frequency 'band' used around the calculated center frequency. Only used if modem preset is disabled." +spread_factor, "Spread factor", "Indicates the number of chirps per symbol. Only used if modem preset is disabled." +coding_rate, "Coding rate", "The proportion of each LoRa transmission that contains actual data - the rest is used for error correction." +frequency_offset, "Frequency offset", "This parameter is for advanced users with advanced test equipment." +region, "Region", "Sets the region for your node. As long as this is not set, the node will display a message and not transmit any packets." +hop_limit, "Hop limit", "The maximum number of intermediate nodes between Femtofox and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7." +tx_enabled, "Enable TX", "Enables/disables the radio chip. Useful for hot-swapping antennas." +tx_power, "TX power in dBm", "[warning]Setting a 33db radio above 8db will permanently damage it. ERP above 27db violates EU law. ERP above 36db violates US (unlicensed) law.[/warning] If 0, will use the max continuous power legal in region. Must be 0-30 (0=automatic)." +channel_num, "Frequency slot", "Determines the exact frequency the radio transmits and receives. If unset or set to 0, determined automatically by the primary channel name." +override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]" +sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity." +override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications." +pa_fan_disabled, "", "" +ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device." +config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers." + +[config.bluetooth] +title, "Bluetooth" +enabled, "Enabled", "Enables bluetooth. Duh!" +mode, "Pairing mode", "RANDOM_PIN generates a random PIN during runtime. FIXED_PIN uses the fixed PIN that should then be additionally specified. Finally, NO_PIN disables PIN authentication." +fixed_pin, "Fixed PIN", "If your pairing mode is set to FIXED_PIN, the default value is 123456. For all other pairing modes, this number is ignored. A custom integer (6 digits) can be set via the Bluetooth config options." + +[config.security] +title, "Security" +public_key, "Public key", "The public key of the device, shared with other nodes on the mesh to allow them to compute a shared secret key for secure communication. Generated automatically to match private key.\n[warning]Don't change this if you don't know what you're doing.[/warning]" +private_key, "Private key", "The private key of the device, used to create a shared key with a remote device for secure communication.\n[warning]This key should be kept confidential.[/warning]\n[note]Setting an invalid key will lead to unexpected behaviors.[/note]" +is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" +serial_enabled, "Enable serial console", "" +debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active." +admin_channel_enabled, "Enable legacy admin channel", "If the node you Femtofox needs to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes." +admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3." + +[module.mqtt] +title, "MQTT" +enabled, "Enabled", "Enables the MQTT module." +address, "Server address", "The server to use for MQTT. If not set, the default public server will be used." +username, "Username", "MQTT Server username to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default public server, this will only be honored if set, otherwise the device will use the default username." +password, "Password", "MQTT password to use (most useful for a custom MQTT server). If using a custom server, this will be honored even if empty. If using the default server, this will only be honored if set, otherwise the device will use the default password." +encryption_enabled, "Encryption enabled", "Whether to send encrypted or unencrypted packets to the MQTT server. Unencrypted packets may be useful for external systems that want to consume meshtastic packets. Note: All messages are sent to the MQTT broker unencrypted if this option is not enabled, even when your uplink channels have encryption keys set." +json_enabled, "JSON enabled", "Enable the sending / consumption of JSON packets on MQTT. These packets are not encrypted, but offer an easy way to integrate with systems that can read JSON. JSON is not supported on the nRF52 platform." +tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connection using TLS." +root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs." +proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings." +map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet." +map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)." +map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m." + +[module.serial] +title, "Serial" +enabled, "Enabled", "Enables the module." +echo, "Echo", "If set, any packets you send will be echoed back to your device." +rxd, "Receive GPIO pin", "Set the GPIO pin to the RXD pin you have set up." +txd, "Transmit GPIO pin", "Set the GPIO pin to the TXD pin you have set up." +baud, "Baud rate", "The serial baud rate." +timeout, "Timeout", "The amount of time to wait before we consider your packet as 'done'." +mode, "Mode", "See Meshtastic docs for more information." +override_console_serial_port, "Override console serial port", "If set to true, this will allow Serial Module to control (set baud rate) and use the primary USB serial bus for output. This is only useful for NMEA and CalTopo modes and may behave strangely or not work at all in other modes. Setting TX/RX pins in the Serial Module config will cause this setting to be ignored." + +[module.external_notification] +title, "External Notification" +enabled, "Enabled", "Enables the module." +output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause." +output, "", "" +output_vibra, "", "" +output_buzzer, "", "" +active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low." +alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message." +alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message." +alert_message_buzzer, "Alert buzzer on message", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." +alert_bell, "Alert when receiving a bell (general)", "Specifies if an alert should be triggered when receiving an incoming bell with an alert bell character." +alert_bell_vibra, "Alert vibration on bell", "Specifies if a vibration alert should be triggered when receiving an incoming message with an alert bell character." +alert_bell_buzzer, "Alert buzzer on bell", "Specifies if an alert should be triggered when receiving an incoming message with an alert bell character." +use_pwm, "Use PWM for buzzer", "" +nag_timeout, "Repeat (nag timeout)", "Specifies if the alert should be repeated. If set to a value greater than zero, the alert will be repeated until the user button is pressed or 'value' number of seconds have past." +use_i2s_as_buzzer, "Use i2s as buzzer", "" + +[module.store_forward] +title, "Store & Forward" +enabled, "Enabled", "Enables the module." +heartbeat, "Heartbeat", "The Store & Forward Server sends a periodic message onto the network. This allows connected devices to know that a server is in range and listening to received messages. A client like Android, iOS, or Web can (if supported) indicate to the user whether a Store & Forward Server is available." +records, "Records", "Set this to the maximum number of records the server will save. Best to leave this at the default (0) where the module will use 2/3 of your device's available PSRAM. This is about 11,000 records." +history_return_max, "History return max", "Sets the maximum number of messages to return to a client device when it requests the history." +history_return_window, "History return window", "Limits the time period (in minutes) a client device can request." +is_server, "Is server", "Set to true to configure your node with PSRAM as a Store & Forward Server for storing and forwarding messages. This is an alternative to setting the node as a ROUTER and only available since 2.4." + +[module.range_test] +title, "Range Test" +enabled, "Enabled", "Enables the module." +sender, "Sender interval", "How long to wait between sending sequential test packets in seconds. 0 is default which disables sending messages." +save, "Save CSV file", "If enabled, all received messages are saved to the device's flash memory in a file named rangetest.csv. Leave disabled when using the Android or Apple apps. Saves directly to the device's flash memory (without the need for a smartphone). [warning]Only available on ESP32-based devices.[/warning]" + +[module.telemetry] +title, "Telemetry" +device_update_interval, "Device metrics update interval", "How often we should send Device Metrics over the mesh in seconds." +environment_update_interval, "Environment metrics update interval", "How often we should send environment (sensor) Metrics over the mesh in seconds." +environment_measurement_enabled, "Environment telemetry enabled", "Enable the Environment Telemetry (Sensors)." +environment_screen_enabled, "Environment screen enabled", "Show the environment telemetry data on the device display." +environment_display_fahrenheit, "Display fahrenheit", "The sensor is always read in Celsius, but the user can opt to display in Fahrenheit (on the device display only) using this setting." +air_quality_enabled, "Air quality enabled", "This option is used to enable/disable the sending of air quality metrics from an attached supported sensor over the mesh network." +air_quality_interval, "Air quality interval", "This option is used to configure the interval in seconds that should be used to send air quality metrics from an attached supported sensor over the mesh network in seconds." +power_measurement_enabled, "Power metrics enabled", "This option is used to enable/disable the sending of power telemetry as gathered by an attached supported voltage/current sensor. Note that this does not need to be enabled to monitor the voltage of the battery." +power_update_interval, "Power metrics interval", "This option is used to configure the interval in seconds that should be used to send power metrics from an attached supported sensor over the mesh network in seconds." +power_screen_enabled, "Power screen enabled", "Show the power telemetry data on the device display." +health_measurement_enabled, "Health telemetry interval", "This option is used to configure the interval in seconds that should be used to send health data from an attached supported sensor over the mesh network in seconds." +health_update_interval, "Health telemetry enabled", "This option is used to enable/disable the sending of health data from an attached supported sensor over the mesh network." +health_screen_enabled, "Health screen enabled", "Show the health telemetry data on the device display." + +[module.canned_message] +title, "Canned Message" +rotary1_enabled, "Rotary encoder enabled", "Enable the default rotary encoder." +inputbroker_pin_a, "Input broker pin A", "GPIO Pin Value (1-39) For encoder port A." +inputbroker_pin_b, "Input broker pin B", "GPIO Pin Value (1-39) For encoder port B." +inputbroker_pin_press, "Input broker pin press", "GPIO Pin Value (1-39) For encoder Press port." +inputbroker_event_cw, "Input broker event clockwise", "Generate the rotary clockwise event." +inputbroker_event_ccw, "Input broker event counter clockwise", "Generate the rotary counter clockwise event." +inputbroker_event_press, "Input broker event press", "Generate input event on Press of this kind." +updown1_enabled, "Up down encoder enabled", "Enable the up / down encoder." +enabled, "Enabled", "Enables the module." +allow_input_source, "Input source", "Input event sources accepted by the canned message module." +send_bell, "Send bell", "Sends a bell character with each message." + +[module.audio] +title, "Audio" +codec2_enabled, "Enabled", "Enables the module." +ptt_pin, "PTT GPIO", "The GPIO to use for the Push-To-Talk button. The default is GPIO 39 on the ESP32." +bitrate, "Audio bitrate/codec mode", "The bitrate to use for audio." +i2s_ws, "I2S word select", "The GPIO to use for the WS signal in the I2S interface." +i2s_sd, "I2S data IN", "The GPIO to use for the SD signal in the I2S interface." +i2s_din, "I2S data OUT", "The GPIO to use for the DIN signal in the I2S interface." +i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface." + +[module.remote_hardware] +title, "Remote Hardware" +enabled, "Enabled", "Enables the module." +allow_undefined_pin_access, "Allow undefined pin access", "" + +[module.neighbor_info] +title, "Neighbor Info" +enabled, "Enabled", "Enables the module." +update_interval, "Update interval", "How often in seconds the neighbor info is sent to the mesh. This cannot be set lower than 4 hours (14400 seconds). The default is 6 hours (21600 seconds)." +transmit_over_lora, "Transmit over LoRa", "Available from firmware 2.5.13 and higher. By default, neighbor info will only be sent to MQTT and a connected app. If enabled, the neighbor info will be sent on the primary channel over LoRa. Only available when the primary channel is not the public channel with default key and name." + +[module.ambient_lighting] +title, "Ambient Lighting" +led_state, "LED state", "Sets the LED to on or Off." +current, "Current", "Sets the current for the LED output. Default is 10." +red, "Red", "Sets the red LED level. Values are 0-255." +green, "Green", "Sets the green LED level. Values are 0-255." +blue, "Blue", "Sets the blue LED level. Values are 0-255." + +[module.detection_sensor] +title, "Detection Sensor" +enabled, "Enabled", "Enables the module." +minimum_broadcast_secs, "Minimum broadcast interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected." +state_broadcast_secs, "State broadcast interval", "The interval in seconds of how often we should send a message to the mesh with the current state regardless of changes, When set to 0, only state changes will be broadcasted, Works as a sort of status heartbeat for peace of mind." +send_bell, "Send bell", "Send ASCII bell with alert message. Useful for triggering ext. notification on bell name." +name, "Friendly name", "Used to format the message sent to mesh. Example: A name 'Motion' would result in a message 'Motion detected'. Maximum length of 20 characters." +monitor_pin, "Monitor pin", "The GPIO pin to monitor for state changes." +detection_trigger_type, "Detection triggered high", "Whether or not the GPIO pin state detection is triggered on HIGH (1), otherwise LOW (0)." +use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. Only applicable if the board uses pull-up resistors on the pin." + +[module.paxcounter] +title, "Paxcounter" +enabled, "Enabled", "Enables the module." +paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected." +Wi-Fi_threshold, "", "" +ble_threshold, "", "" \ No newline at end of file diff --git a/main.py b/main.py index 3dd53a7..9c39961 100644 --- a/main.py +++ b/main.py @@ -6,27 +6,27 @@ Powered by Meshtastic.org V 1.2.1 ''' +import contextlib import curses -from pubsub import pub import os -import io +from pubsub import pub import sys +import io import logging import traceback import threading -import contextlib -from utilities.arg_parser import setup_parser -from utilities.interfaces import initialize_interface +from utilities.db_handler import init_nodedb, load_messages_from_db from message_handlers.rx_handler import on_receive +from settings import set_region from ui.curses_ui import main_ui from ui.colors import setup_colors from ui.splash import draw_splash -from input_handlers import get_list_input +import ui.default_config as config +from utilities.arg_parser import setup_parser +from utilities.interfaces import initialize_interface +from utilities.input_handlers import get_list_input from utilities.utils import get_channels, get_node_list, get_nodeNum -from settings import set_region -from db_handler import init_nodedb, load_messages_from_db -import default_config as config import globals # Set ncurses compatibility settings @@ -63,7 +63,7 @@ def main(stdscr): if globals.interface.localNode.localConfig.lora.region == 0: confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) if confirmation == "Yes": - set_region() + set_region(globals.interface) globals.interface.close() globals.interface = initialize_interface(args) diff --git a/message_handlers/rx_handler.py b/message_handlers/rx_handler.py index 3ba0276..c95f0c5 100644 --- a/message_handlers/rx_handler.py +++ b/message_handlers/rx_handler.py @@ -3,8 +3,8 @@ import time from utilities.utils import refresh_node_list from datetime import datetime from ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification -from db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db -import default_config as config +from utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db +import ui.default_config as config import globals diff --git a/message_handlers/tx_handler.py b/message_handlers/tx_handler.py index c9b545d..0b72fc9 100644 --- a/message_handlers/tx_handler.py +++ b/message_handlers/tx_handler.py @@ -3,8 +3,8 @@ import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 -from db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db -import default_config as config +from utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import ui.default_config as config import globals ack_naks = {} diff --git a/settings.py b/settings.py index 7ddd1d0..5be03b4 100644 --- a/settings.py +++ b/settings.py @@ -1,389 +1,70 @@ +import contextlib import curses +import io import logging -import os +import sys +import traceback -from save_to_radio import save_changes -from utilities.config_io import config_export, config_import -from input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input -from ui.menus import generate_menu_from_protobuf -from ui.colors import setup_colors, get_color +import ui.default_config as config +from utilities.input_handlers import get_list_input +from ui.colors import setup_colors +from ui.splash import draw_splash +from ui.control_ui import set_region, settings_menu from utilities.arg_parser import setup_parser from utilities.interfaces import initialize_interface -from ui.dialog import dialog -from user_config import json_editor -import globals -width = 60 -save_option = "Save Changes" -sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] - -def display_menu(current_menu, menu_path, selected_index, show_save_option): - - # 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 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 - - # Create a new curses window with dynamic dimensions - menu_win = curses.newwin(height, width, start_y, start_x) - menu_win.erase() - menu_win.bkgd(get_color("background")) - menu_win.attrset(get_color("window_frame")) - menu_win.border() - menu_win.keypad(True) - - menu_pad = curses.newpad(len(current_menu) + 1, width - 8) - menu_pad.bkgd(get_color("background")) - - # 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, get_color("settings_breadcrumbs", bold=True)) - - # 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 - - try: - # Use red color for "Reboot" or "Shutdown" - color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse = (idx == selected_index)) - menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) - except curses.error: - pass - - # Show save option if applicable - if show_save_option: - save_position = height - 2 - menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse = (selected_index == len(current_menu)))) - - menu_win.refresh() - menu_pad.refresh(0, 0, - menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8) - - return menu_win, menu_pad - - -def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad): - - if(old_idx == new_idx): # no-op - return - - max_index = len(options) + (1 if show_save_option else 0) - 1 - - if show_save_option and old_idx == max_index: # special case un-highlight "Save" option - menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")) - else: - menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) - - if show_save_option and new_idx == max_index: # special case highlight "Save" option - menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse = True)) - else: - menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse = True)) - - menu_win.refresh() - - start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) # Leave room for borders - menu_pad.refresh(start_index, 0, - menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8) - -def settings_menu(stdscr, interface): - curses.update_lines_cols() - - menu = generate_menu_from_protobuf(interface) - current_menu = menu["Main Menu"] - menu_path = ["Main Menu"] - menu_index = [] - selected_index = 0 - modified_settings = {} - - need_redraw = True - show_save_option = False - - while True: - if(need_redraw): - options = list(current_menu.keys()) - - 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 the menu - menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option) - - need_redraw = False - - # Capture user input - key = menu_win.getch() - - max_index = len(options) + (1 if show_save_option else 0) - 1 - - if key == curses.KEY_UP: - old_selected_index = selected_index - selected_index = max_index if selected_index == 0 else selected_index - 1 - move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad) - - elif key == curses.KEY_DOWN: - old_selected_index = selected_index - selected_index = 0 if selected_index == max_index else selected_index + 1 - move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad) - - elif key == curses.KEY_RESIZE: - need_redraw = True - curses.update_lines_cols() - - elif key == ord("\t") and show_save_option: - old_selected_index = selected_index - selected_index = max_index - move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad) - - elif key == curses.KEY_RIGHT or key == ord('\n'): - need_redraw = True - menu_win.erase() - menu_win.refresh() - if show_save_option and selected_index == len(options): - save_changes(menu_path, modified_settings) - modified_settings.clear() - logging.info("Changes Saved") - - 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 - - continue - - selected_option = options[selected_index] - - if selected_option == "Exit": - break - - - elif selected_option == "Export Config": - filename = get_text_input("Enter a filename for the config file") - - if not filename: - logging.warning("Export aborted: No filename provided.") - continue # Go back to the menu - - if not filename.lower().endswith(".yaml"): - filename += ".yaml" - - try: - config_text = config_export(globals.interface) - app_directory = os.path.dirname(os.path.abspath(__file__)) - config_folder = "node-configs" - yaml_file_path = os.path.join(app_directory, config_folder, filename) - - if os.path.exists(yaml_file_path): - overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) - if overwrite == "No": - logging.info("Export cancelled: User chose not to overwrite.") - continue # Return to menu - os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) - with open(yaml_file_path, "w", encoding="utf-8") as file: - file.write(config_text) - logging.info(f"Config file saved to {yaml_file_path}") - break - except PermissionError: - logging.error(f"Permission denied: Unable to write to {yaml_file_path}") - except OSError as e: - logging.error(f"OS error while saving config: {e}") - except Exception as e: - logging.error(f"Unexpected error: {e}") - continue - - elif selected_option == "Load Config": - app_directory = os.path.dirname(os.path.abspath(__file__)) - config_folder = "node-configs" - folder_path = os.path.join(app_directory, config_folder) - file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))] - filename = get_list_input("Choose a config file", None, file_list) - if filename: - file_path = os.path.join(app_directory, config_folder, filename) - overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) - if overwrite == "Yes": - config_import(globals.interface, file_path) - break - continue - - elif selected_option == "Reboot": - confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"]) - if confirmation == "Yes": - interface.localNode.reboot() - logging.info(f"Node Reboot Requested by menu") - break - continue - elif selected_option == "Reset Node DB": - confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"]) - if confirmation == "Yes": - interface.localNode.resetNodeDb() - logging.info(f"Node DB Reset Requested by menu") - break - continue - elif selected_option == "Shutdown": - confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"]) - if confirmation == "Yes": - interface.localNode.shutdown() - logging.info(f"Node Shutdown Requested by menu") - break - continue - elif selected_option == "Factory Reset": - confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"]) - if confirmation == "Yes": - interface.localNode.factoryReset() - logging.info(f"Factory Reset Requested by menu") - break - continue - elif selected_option == "App Settings": - menu_win.clear() - menu_win.refresh() - json_editor(stdscr) # Open the App Settings menu - continue - # need_redraw = True - - field_info = current_menu.get(selected_option) - if isinstance(field_info, tuple): - field, current_value = field_info - - if selected_option in ['longName', 'shortName', 'isLicensed']: - if selected_option in ['longName', 'shortName']: - new_value = get_text_input(f"Current value for {selected_option}: {current_value}") - new_value = current_value if new_value is None else new_value - current_menu[selected_option] = (field, new_value) - - elif selected_option == 'isLicensed': - new_value = get_list_input(f"Current value for {selected_option}: {current_value}", str(current_value), ["True", "False"]) - new_value = new_value == "True" - current_menu[selected_option] = (field, new_value) - - for option, (field, value) in current_menu.items(): - modified_settings[option] = value - - elif selected_option in ['latitude', 'longitude', 'altitude']: - new_value = get_text_input(f"Current value for {selected_option}: {current_value}") - new_value = current_value if new_value is None else new_value - current_menu[selected_option] = (field, new_value) - - for option in ['latitude', 'longitude', 'altitude']: - if option in current_menu: - modified_settings[option] = current_menu[option][1] - - elif field.type == 8: # Handle boolean type - new_value = get_list_input(selected_option, str(current_value), ["True", "False"]) - new_value = new_value == "True" or new_value is True - - elif field.label == field.LABEL_REPEATED: # Handle repeated field - new_value = get_repeated_input(current_value) - new_value = current_value if new_value is None else [int(item) for item in new_value] - - elif field.enum_type: # Enum field - enum_options = {v.name: v.number for v in field.enum_type.values} - new_value_name = get_list_input(selected_option, current_value, list(enum_options.keys())) - new_value = enum_options.get(new_value_name, current_value) - - 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_text_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_text_input(f"Current value for {selected_option}: {current_value}") - new_value = current_value if new_value is None else float(new_value) - - else: # Handle other field types - new_value = get_text_input(f"Current value for {selected_option}: {current_value}") - new_value = current_value if new_value is None else new_value - - for key in menu_path[3:]: # Skip "Main Menu" - modified_settings = modified_settings.setdefault(key, {}) - - # Add the new value to the appropriate level - modified_settings[selected_option] = new_value - - # Convert enum string to int - if field and field.enum_type: - enum_value_descriptor = field.enum_type.values_by_number.get(new_value) - new_value = enum_value_descriptor.name if enum_value_descriptor else new_value - - current_menu[selected_option] = (field, new_value) - else: - current_menu = current_menu[selected_option] - menu_path.append(selected_option) - menu_index.append(selected_index) - selected_index = 0 - - elif key == curses.KEY_LEFT: - need_redraw = True - - menu_win.erase() - menu_win.refresh() - - if len(menu_path) < 2: - 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 = menu_index.pop() - - elif key == 27: # Escape key - menu_win.erase() - menu_win.refresh() - break - -def set_region(): - node = globals.interface.getNode('^local') - device_config = node.localConfig - lora_descriptor = device_config.lora.DESCRIPTOR - - # Get the enum mapping of region names to their numerical values - region_enum = lora_descriptor.fields_by_name["region"].enum_type - region_name_to_number = {v.name: v.number for v in region_enum.values} - - regions = list(region_name_to_number.keys()) - - new_region_name = get_list_input('Select your region:', 'UNSET', regions) - - # Convert region name to corresponding enum number - new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found - - node.localConfig.lora.region = new_region_number - node.writeConfig("lora") - 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" - ) - setup_colors() - curses.curs_set(0) - stdscr.keypad(True) + output_capture = io.StringIO() + try: + with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): + setup_colors() + draw_splash(stdscr) + curses.curs_set(0) + stdscr.keypad(True) - parser = setup_parser() - args = parser.parse_args() - globals.interface = initialize_interface(args) + parser = setup_parser() + args = parser.parse_args() + interface = initialize_interface(args) - settings_menu(stdscr, globals.interface) + if interface.localNode.localConfig.lora.region == 0: + confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"]) + if confirmation == "Yes": + set_region(interface) + interface.close() + interface = initialize_interface(args) + stdscr.clear() + stdscr.refresh() + settings_menu(stdscr, interface) + + except Exception as e: + console_output = output_capture.getvalue() + logging.error("An error occurred: %s", e) + logging.error("Traceback: %s", traceback.format_exc()) + logging.error("Console output before crash:\n%s", console_output) + raise + + +logging.basicConfig( # Run `tail -f client.log` in another terminal to view live + filename=config.log_file_path, + level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL) + format="%(asctime)s - %(levelname)s - %(message)s" +) if __name__ == "__main__": - curses.wrapper(main) + log_file = config.log_file_path + log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes + + sys.stdout = log_f + sys.stderr = log_f + + with contextlib.redirect_stderr(log_f), contextlib.redirect_stdout(log_f): + try: + curses.wrapper(main) + except KeyboardInterrupt: + logging.info("User exited with Ctrl+C or Ctrl+X") # Clean exit logging + sys.exit(0) # Ensure a clean exit + except Exception as e: + logging.error("Fatal error in curses wrapper: %s", e) + logging.error("Traceback: %s", traceback.format_exc()) + sys.exit(1) # Exit with an error code \ No newline at end of file diff --git a/ui/colors.py b/ui/colors.py index 8a47028..8016542 100644 --- a/ui/colors.py +++ b/ui/colors.py @@ -1,5 +1,5 @@ import curses -import default_config as config +import ui.default_config as config COLOR_MAP = { "black": curses.COLOR_BLACK, diff --git a/ui/control_ui.py b/ui/control_ui.py new file mode 100644 index 0000000..c5a9db4 --- /dev/null +++ b/ui/control_ui.py @@ -0,0 +1,603 @@ +import base64 +import curses +import logging +import os +import re +import sys + +from utilities.save_to_radio import save_changes +from utilities.config_io import config_export, config_import +from utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input +from ui.menus import generate_menu_from_protobuf +from ui.colors import get_color +from ui.dialog import dialog +from utilities.control_utils import parse_ini_file, transform_menu_path +from ui.user_config import json_editor + +# Constants +width = 80 +save_option = "Save Changes" +max_help_lines = 0 +help_win = None +sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] + +# Get the parent directory of the script +script_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) + +# Paths +locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory +translation_file = os.path.join(locals_dir, "localisations", "en.ini") +config_folder = os.path.join(parent_dir, "node-configs") + +# Load translations +field_mapping, help_text = parse_ini_file(translation_file) + + +def display_menu(current_menu, menu_path, selected_index, show_save_option, help_text): + min_help_window_height = 6 + num_items = len(current_menu) + (1 if show_save_option else 0) + + # Determine the available height for the menu + max_menu_height = curses.LINES + menu_height = min(max_menu_height - min_help_window_height, num_items + 5) + start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) + start_x = (curses.COLS - width) // 2 + + # Calculate remaining space for help window + global max_help_lines + remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding + max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help + + menu_win = curses.newwin(menu_height, width, start_y, start_x) + menu_win.erase() + menu_win.bkgd(get_color("background")) + menu_win.attrset(get_color("window_frame")) + menu_win.border() + menu_win.keypad(True) + + menu_pad = curses.newpad(len(current_menu) + 1, width - 8) + menu_pad.bkgd(get_color("background")) + + header = " > ".join(word.title() for word in menu_path) + if len(header) > width - 4: + header = header[:width - 7] + "..." + menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) + + transformed_path = transform_menu_path(menu_path) + + for idx, option in enumerate(current_menu): + field_info = 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_option = f"{display_name}"[:width // 2 - 2] + display_value = f"{current_value}"[:width // 2 - 4] + + try: + color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == selected_index)) + menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) + except curses.error: + pass + + if show_save_option: + save_position = menu_height - 2 + menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(selected_index == len(current_menu)))) + + # Draw help window with dynamically updated max_help_lines + draw_help_window(start_y, start_x, menu_height, max_help_lines, current_menu, selected_index, transformed_path) + + menu_win.refresh() + menu_pad.refresh( + 0, 0, + menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, + menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), + menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8 + ) + + return menu_win, menu_pad + +def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, current_menu, selected_index, transformed_path): + global help_win + + if 'help_win' not in globals(): + help_win = None # Initialize if it does not exist + + selected_option = list(current_menu.keys())[selected_index] if current_menu else None + help_y = menu_start_y + menu_height + + help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x) + +def update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, help_x): + """Handles rendering the help window consistently.""" + wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines) + + help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border + help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border) + + # Ensure help window does not exceed screen size + if help_y + help_height > curses.LINES: + help_y = curses.LINES - help_height + + # Create or update the help window + if help_win is None: + help_win = curses.newwin(help_height, width, help_y, help_x) + else: + help_win.erase() + help_win.refresh() + help_win.resize(help_height, width) + help_win.mvwin(help_y, help_x) + + help_win.bkgd(get_color("background")) + help_win.attrset(get_color("window_frame")) + help_win.border() + + for idx, line_segments in enumerate(wrapped_help): + x_pos = 2 # Start after border + for text, color, bold, underline in line_segments: + try: + attr = get_color(color, bold=bold, underline=underline) + help_win.addstr(1 + idx, x_pos, text, attr) + x_pos += len(text) + except curses.error: + pass # Prevent crashes + + help_win.refresh() + return help_win + + + +def get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_lines): + """Fetches and formats help text for display, ensuring it fits within the allowed lines.""" + + full_help_key = '.'.join(transformed_path + [selected_option]) if selected_option else None + help_content = help_text.get(full_help_key, "No help available.") + + wrap_width = max(width - 6, 10) # Ensure a valid wrapping width + + # Color replacements + color_mappings = { + r'\[warning\](.*?)\[/warning\]': ('settings_warning', True, False), # Red for warnings + r'\[note\](.*?)\[/note\]': ('settings_note', True, False), # Green for notes + r'\[underline\](.*?)\[/underline\]': ('settings_default', False, True), # Underline + + r'\\033\[31m(.*?)\\033\[0m': ('settings_warning', True, False), # Red text + r'\\033\[32m(.*?)\\033\[0m': ('settings_note', True, False), # Green text + r'\\033\[4m(.*?)\\033\[0m': ('settings_default', False, True) # Underline + } + + def extract_ansi_segments(text): + """Extracts and replaces ANSI color codes, ensuring spaces are preserved.""" + matches = [] + last_pos = 0 + pattern_matches = [] + + # Find all matches and store their positions + for pattern, (color, bold, underline) in color_mappings.items(): + for match in re.finditer(pattern, text): + pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline)) + + # Sort matches by start position to process sequentially + pattern_matches.sort(key=lambda x: x[0]) + + for start, end, content, color, bold, underline in pattern_matches: + # Preserve non-matching text including spaces + if last_pos < start: + segment = text[last_pos:start] + matches.append((segment, "settings_default", False, False)) + + # Append the colored segment + matches.append((content, color, bold, underline)) + last_pos = end + + # Preserve any trailing text + if last_pos < len(text): + matches.append((text[last_pos:], "settings_default", False, False)) + + return matches + + def wrap_ansi_text(segments, wrap_width): + """Wraps text while preserving ANSI formatting and spaces.""" + wrapped_lines = [] + line_buffer = [] + line_length = 0 + + for text, color, bold, underline in segments: + words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately + + for word in words: + word_length = len(word) + + if line_length + word_length > wrap_width and word.strip(): + # If the word (ignoring spaces) exceeds width, wrap the line + wrapped_lines.append(line_buffer) + line_buffer = [] + line_length = 0 + + line_buffer.append((word, color, bold, underline)) + line_length += word_length + + if line_buffer: + wrapped_lines.append(line_buffer) + + return wrapped_lines + + raw_lines = help_content.split("\\n") # Preserve new lines + wrapped_help = [] + + for raw_line in raw_lines: + color_segments = extract_ansi_segments(raw_line) + wrapped_segments = wrap_ansi_text(color_segments, wrap_width) + wrapped_help.extend(wrapped_segments) + pass + + # Trim and add ellipsis if needed + if len(wrapped_help) > max_lines: + wrapped_help = wrapped_help[:max_lines] + wrapped_help[-1].append(("...", "settings_default", False, False)) + + return wrapped_help + + +def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines): + + if old_idx == new_idx: # No-op + return + + max_index = len(options) + (1 if show_save_option else 0) - 1 + + if show_save_option and old_idx == max_index: # Special case un-highlight "Save" option + menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")) + else: + menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) + + if show_save_option and new_idx == max_index: # Special case highlight "Save" option + menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) + else: + menu_pad.chgat(new_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True)) + + menu_win.refresh() + + start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) + menu_pad.refresh(start_index, 0, + menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, + menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), + menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8) + + # Transform menu path + transformed_path = transform_menu_path(menu_path) + selected_option = options[new_idx] if new_idx < len(options) else None + help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] + + # Call helper function to update the help window + help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) + + +def settings_menu(stdscr, interface): + curses.update_lines_cols() + + menu = generate_menu_from_protobuf(interface) + current_menu = menu["Main Menu"] + menu_path = ["Main Menu"] + menu_index = [] + selected_index = 0 + modified_settings = {} + + need_redraw = True + show_save_option = False + + while True: + if(need_redraw): + options = list(current_menu.keys()) + + 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 the menu + menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option, help_text) + + need_redraw = False + + # Capture user input + key = menu_win.getch() + + max_index = len(options) + (1 if show_save_option else 0) - 1 + # max_help_lines = 4 + + if key == curses.KEY_UP: + old_selected_index = selected_index + selected_index = max_index if selected_index == 0 else selected_index - 1 + move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path,max_help_lines) + + elif key == curses.KEY_DOWN: + old_selected_index = selected_index + selected_index = 0 if selected_index == max_index else selected_index + 1 + move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines) + + elif key == curses.KEY_RESIZE: + need_redraw = True + curses.update_lines_cols() + + menu_win.erase() + help_win.erase() + + menu_win.refresh() + help_win.refresh() + + elif key == ord("\t") and show_save_option: + old_selected_index = selected_index + selected_index = max_index + move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines) + + elif key == curses.KEY_RIGHT or key == ord('\n'): + need_redraw = True + menu_win.erase() + help_win.erase() + + # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, current_menu, selected_index, transform_menu_path(menu_path)) + + menu_win.refresh() + help_win.refresh() + + # Get the parent directory of the script + app_directory = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) + config_folder = "node-configs" + + if show_save_option and selected_index == len(options): + save_changes(interface, menu_path, modified_settings) + modified_settings.clear() + logging.info("Changes Saved") + + 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 + + continue + + selected_option = options[selected_index] + + if selected_option == "Exit": + break + + elif selected_option == "Export Config File": + filename = get_text_input("Enter a filename for the config file") + if not filename: + logging.info("Export aborted: No filename provided.") + continue # Go back to the menu + if not filename.lower().endswith(".yaml"): + filename += ".yaml" + + try: + config_text = config_export(interface) + yaml_file_path = os.path.join(app_directory, config_folder, filename) + + if os.path.exists(yaml_file_path): + overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) + if overwrite == "No": + logging.info("Export cancelled: User chose not to overwrite.") + continue # Return to menu + + os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) + with open(yaml_file_path, "w", encoding="utf-8") as file: + file.write(config_text) + logging.info(f"Config file saved to {yaml_file_path}") + dialog(stdscr, "Config File Saved:", yaml_file_path) + + continue + except PermissionError: + logging.error(f"Permission denied: Unable to write to {yaml_file_path}") + except OSError as e: + logging.error(f"OS error while saving config: {e}") + except Exception as e: + logging.error(f"Unexpected error: {e}") + continue + + elif selected_option == "Load Config File": + folder_path = os.path.join(app_directory, config_folder) + + # Check if folder exists and is not empty + if not os.path.exists(folder_path) or not any(os.listdir(folder_path)): + dialog(stdscr, "", " No config files found. Export a config first.") + continue # Return to menu + + file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))] + + # Ensure file_list is not empty before proceeding + if not file_list: + dialog(stdscr, "", " No config files found. Export a config first.") + continue + + filename = get_list_input("Choose a config file", None, file_list) + if filename: + file_path = os.path.join(app_directory, config_folder, filename) + overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) + if overwrite == "Yes": + config_import(interface, file_path) + continue + + elif selected_option == "Config URL": + current_value = interface.localNode.getURL() + new_value = get_text_input(f"Config URL is currently: {current_value}") + if new_value is not None: + current_value = new_value + overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"]) + if overwrite == "Yes": + interface.localNode.setURL(new_value) + logging.info(f"New Config URL sent to node") + continue + + elif selected_option == "Reboot": + confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"]) + if confirmation == "Yes": + interface.localNode.reboot() + logging.info(f"Node Reboot Requested by menu") + continue + + elif selected_option == "Reset Node DB": + confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"]) + if confirmation == "Yes": + interface.localNode.resetNodeDb() + logging.info(f"Node DB Reset Requested by menu") + continue + + elif selected_option == "Shutdown": + confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"]) + if confirmation == "Yes": + interface.localNode.shutdown() + logging.info(f"Node Shutdown Requested by menu") + continue + + elif selected_option == "Factory Reset": + confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"]) + if confirmation == "Yes": + interface.localNode.factoryReset() + logging.info(f"Factory Reset Requested by menu") + continue + + elif selected_option == "App Settings": + menu_win.clear() + menu_win.refresh() + json_editor(stdscr) # Open the App Settings menu + continue + # need_redraw = True + + field_info = current_menu.get(selected_option) + if isinstance(field_info, tuple): + field, current_value = field_info + + # Transform the menu path to get the full key + transformed_path = transform_menu_path(menu_path) + full_key = '.'.join(transformed_path + [selected_option]) + + # Fetch human-readable name from field_mapping + human_readable_name = field_mapping.get(full_key, selected_option) + + if selected_option in ['longName', 'shortName', 'isLicensed']: + if selected_option in ['longName', 'shortName']: + new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") + new_value = current_value if new_value is None else new_value + current_menu[selected_option] = (field, new_value) + + elif selected_option == 'isLicensed': + new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"]) + new_value = new_value == "True" + current_menu[selected_option] = (field, new_value) + + for option, (field, value) in current_menu.items(): + modified_settings[option] = value + + elif selected_option in ['latitude', 'longitude', 'altitude']: + new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") + new_value = current_value if new_value is None else new_value + current_menu[selected_option] = (field, new_value) + + for option in ['latitude', 'longitude', 'altitude']: + if option in current_menu: + modified_settings[option] = current_menu[option][1] + + elif selected_option == "admin_key": + new_values = get_admin_key_input(current_value) + new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values] + + elif field.type == 8: # Handle boolean type + new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) + new_value = new_value == "True" or new_value is True + + elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used + new_value = get_repeated_input(current_value) + new_value = current_value if new_value is None else new_value.split(", ") + + elif field.enum_type: # Enum field + enum_options = {v.name: v.number for v in field.enum_type.values} + new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys())) + new_value = enum_options.get(new_value_name, current_value) + + 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_text_input(f"{human_readable_name} is currently: {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_text_input(f"{human_readable_name} is currently: {current_value}") + new_value = current_value if new_value is None else float(new_value) + + else: # Handle other field types + new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") + new_value = current_value if new_value is None else new_value + + for key in menu_path[3:]: # Skip "Main Menu" + modified_settings = modified_settings.setdefault(key, {}) + + # Add the new value to the appropriate level + modified_settings[selected_option] = new_value + + # Convert enum string to int + if field and field.enum_type: + enum_value_descriptor = field.enum_type.values_by_number.get(new_value) + new_value = enum_value_descriptor.name if enum_value_descriptor else new_value + + current_menu[selected_option] = (field, new_value) + else: + current_menu = current_menu[selected_option] + menu_path.append(selected_option) + menu_index.append(selected_index) + selected_index = 0 + + elif key == curses.KEY_LEFT: + need_redraw = True + + menu_win.erase() + help_win.erase() + + # max_help_lines = 4 + # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, current_menu, selected_index, transform_menu_path(menu_path)) + + menu_win.refresh() + help_win.refresh() + + if len(menu_path) < 2: + 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 = menu_index.pop() + + elif key == 27: # Escape key + menu_win.erase() + menu_win.refresh() + break + +def set_region(interface): + node = interface.getNode('^local') + device_config = node.localConfig + lora_descriptor = device_config.lora.DESCRIPTOR + + # Get the enum mapping of region names to their numerical values + region_enum = lora_descriptor.fields_by_name["region"].enum_type + region_name_to_number = {v.name: v.number for v in region_enum.values} + + regions = list(region_name_to_number.keys()) + + new_region_name = get_list_input('Select your region:', 'UNSET', regions) + + # Convert region name to corresponding enum number + new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found + + node.localConfig.lora.region = new_region_number + node.writeConfig("lora") + \ No newline at end of file diff --git a/ui/curses_ui.py b/ui/curses_ui.py index d54117e..4cdcc04 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -6,8 +6,8 @@ from utilities.utils import get_channels, get_readable_duration, get_time_ago, r from settings import settings_menu from message_handlers.tx_handler import send_message, send_traceroute from ui.colors import setup_colors, get_color -from db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived -import default_config as config +from utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived +import ui.default_config as config import ui.dialog import globals diff --git a/default_config.py b/ui/default_config.py similarity index 87% rename from default_config.py rename to ui/default_config.py index db560e9..4b5d331 100644 --- a/default_config.py +++ b/ui/default_config.py @@ -2,6 +2,15 @@ import logging import json import os +# Get the parent directory of the script +script_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) + +# Paths +json_file_path = os.path.join(parent_dir, "config.json") +log_file_path = os.path.join(parent_dir, "client.log") +db_file_path = os.path.join(parent_dir, "client.db") + def format_json_single_line_arrays(data, indent=4): """ Formats JSON with arrays on a single line while keeping other elements properly indented. @@ -34,9 +43,6 @@ def update_dict(default, actual): return updated def initialize_config(): - app_directory = os.path.dirname(os.path.abspath(__file__)) - json_file_path = os.path.join(app_directory, "config.json") - COLOR_CONFIG_DARK = { "default": ["white", "black"], "background": [" ", "black"], @@ -57,9 +63,10 @@ def initialize_config(): "settings_default": ["white", "black"], "settings_sensitive": ["red", "black"], "settings_save": ["green", "black"], - "settings_breadcrumbs": ["white", "black"] + "settings_breadcrumbs": ["white", "black"], + "settings_warning": ["red", "black"], + "settings_note": ["green", "black"] } - COLOR_CONFIG_LIGHT = { "default": ["black", "white"], "background": [" ", "white"], @@ -80,7 +87,9 @@ def initialize_config(): "settings_default": ["black", "white"], "settings_sensitive": ["red", "white"], "settings_save": ["green", "white"], - "settings_breadcrumbs": ["black", "white"] + "settings_breadcrumbs": ["black", "white"], + "settings_warning": ["red", "white"], + "settings_note": ["green", "white"] } COLOR_CONFIG_GREEN = { "default": ["green", "black"], @@ -102,12 +111,15 @@ def initialize_config(): "settings_default": ["green", "black"], "settings_sensitive": ["green", "black"], "settings_save": ["green", "black"], - "settings_breadcrumbs": ["green", "black"] + "settings_breadcrumbs": ["green", "black"], + "settings_save": ["green", "black"], + "settings_breadcrumbs": ["green", "black"], + "settings_warning": ["green", "black"], + "settings_note": ["green", "black"] } - default_config_variables = { - "db_file_path": os.path.join(app_directory, "client.db"), - "log_file_path": os.path.join(app_directory, "client.log"), + "db_file_path": db_file_path, + "log_file_path": log_file_path, "message_prefix": ">>", "sent_message_prefix": ">> Sent", "notification_symbol": "*", diff --git a/ui/menus.py b/ui/menus.py index 6596a4e..2ec0fa9 100644 --- a/ui/menus.py +++ b/ui/menus.py @@ -2,11 +2,20 @@ from collections import OrderedDict from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2 import logging import base64 +import os +locals_dir = os.path.dirname(os.path.abspath(__file__)) +translation_file = os.path.join(locals_dir, "localisations", "en.ini") + +def encode_if_bytes(value): + """Encode byte values to base64 string.""" + if isinstance(value, bytes): + return base64.b64encode(value).decode('utf-8') + return value 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} + return {key: (None, encode_if_bytes(current_config.get(key, "Not Set"))) for key in current_config} if not hasattr(message_instance, "DESCRIPTOR"): return {} @@ -14,7 +23,7 @@ def extract_fields(message_instance, current_config=None): menu = {} fields = message_instance.DESCRIPTOR.fields for field in fields: - skip_fields = {"sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming"} + skip_fields = ["sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming", "DeviceUIConfig.version"] if any(skip_field in field.full_name for skip_field in skip_fields): continue @@ -35,21 +44,18 @@ def extract_fields(message_instance, current_config=None): 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) + menu[field.name] = (field, encode_if_bytes(current_value)) return menu def generate_menu_from_protobuf(interface): -# Function to generate the menu structure from protobuf messages menu_structure = {"Main Menu": {}} # 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")), @@ -70,8 +76,6 @@ def generate_menu_from_protobuf(interface): current_channel = interface.localNode.getChannelByChannelIndex(i) if current_channel: channel_config = extract_fields(channel, current_channel.settings) - # Convert 'psk' field to Base64 - channel_config["psk"] = (channel_config["psk"][0], base64.b64encode(channel_config["psk"][1]).decode('utf-8')) menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config # Add Radio Settings @@ -86,10 +90,7 @@ def generate_menu_from_protobuf(interface): "altitude": (None, current_node_info["position"].get("altitude", 0)) } - # Get existing position menu items existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {}) - - # Create an ordered position menu with Lat/Lon/Alt inserted in the middle ordered_position_menu = OrderedDict() for key, value in existing_position_menu.items(): @@ -99,27 +100,26 @@ def generate_menu_from_protobuf(interface): else: ordered_position_menu[key] = value - # Update the menu with the new order menu_structure["Main Menu"]["Radio Settings"]["position"] = ordered_position_menu - # 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 App Settings menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"} - # Add additional settings options - menu_structure["Main Menu"]["Export Config"] = None - menu_structure["Main Menu"]["Load Config"] = None - menu_structure["Main Menu"]["Reboot"] = None - menu_structure["Main Menu"]["Reset Node DB"] = None - menu_structure["Main Menu"]["Shutdown"] = None - menu_structure["Main Menu"]["Factory Reset"] = None + # Additional settings options + menu_structure["Main Menu"].update({ + "Export Config File": None, + "Load Config File": None, + "Config URL": None, + "Reboot": None, + "Reset Node DB": None, + "Shutdown": None, + "Factory Reset": None, + "Exit": None + }) - # Add Exit option - menu_structure["Main Menu"]["Exit"] = None - - return menu_structure \ No newline at end of file + return menu_structure diff --git a/user_config.py b/ui/user_config.py similarity index 98% rename from user_config.py rename to ui/user_config.py index 7ee723b..32aae7f 100644 --- a/user_config.py +++ b/ui/user_config.py @@ -2,8 +2,8 @@ import os import json import curses from ui.colors import get_color, setup_colors, COLOR_MAP -from default_config import format_json_single_line_arrays, loaded_config -from input_handlers import get_list_input +from ui.default_config import format_json_single_line_arrays, loaded_config +from utilities.input_handlers import get_list_input width = 60 save_option_text = "Save Changes" diff --git a/utilities/control_utils.py b/utilities/control_utils.py new file mode 100644 index 0000000..1761a9b --- /dev/null +++ b/utilities/control_utils.py @@ -0,0 +1,67 @@ +import re + + +def parse_ini_file(ini_file_path): + field_mapping = {} + help_text = {} + current_section = None + + with open(ini_file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + + # Skip empty lines and comments + if not line or line.startswith(';') or line.startswith('#'): + continue + + # Handle sections like [config.device] + if line.startswith('[') and line.endswith(']'): + current_section = line[1:-1] + continue + + # Parse lines like: key, "Human-readable name", "helptext" + parts = [p.strip().strip('"') for p in line.split(',', 2)] + if len(parts) >= 2: + key = parts[0] + + # If key is 'title', map directly to the section + if key == 'title': + full_key = current_section + else: + full_key = f"{current_section}.{key}" if current_section else key + + # Use the provided human-readable name or fallback to key + human_readable_name = parts[1] if parts[1] else key + field_mapping[full_key] = human_readable_name + + # Handle help text or default + help = parts[2] if len(parts) == 3 and parts[2] else "No help available." + help_text[full_key] = help + + else: + # Handle cases with only the key present + full_key = f"{current_section}.{key}" if current_section else key + field_mapping[full_key] = key + help_text[full_key] = "No help available." + + return field_mapping, help_text + +def transform_menu_path(menu_path): + """Applies path replacements and normalizes entries in the menu path.""" + path_replacements = { + "Radio Settings": "config", + "Module Settings": "module" + } + + transformed_path = [] + for part in menu_path[1:]: # Skip 'Main Menu' + # Apply fixed replacements + part = path_replacements.get(part, part) + + # Normalize entries like "Channel 1", "Channel 2", etc. + if re.match(r'Channel\s+\d+', part, re.IGNORECASE): + part = "channel" + + transformed_path.append(part) + + return transformed_path diff --git a/db_handler.py b/utilities/db_handler.py similarity index 99% rename from db_handler.py rename to utilities/db_handler.py index dbf73b5..ea875df 100644 --- a/db_handler.py +++ b/utilities/db_handler.py @@ -4,7 +4,7 @@ import logging from datetime import datetime from utilities.utils import decimal_to_hex -import default_config as config +import ui.default_config as config import globals def get_table_name(channel): diff --git a/utilities/input_handlers.py b/utilities/input_handlers.py new file mode 100644 index 0000000..4977cfe --- /dev/null +++ b/utilities/input_handlers.py @@ -0,0 +1,408 @@ +import base64 +import binascii +import curses +import ipaddress +import re +from ui.colors import get_color + +def wrap_text(text, wrap_width): + """Wraps text while preserving spaces and breaking long words.""" + words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately + wrapped_lines = [] + line_buffer = "" + line_length = 0 + margin = 2 # Left and right margin + wrap_width -= margin + + for word in words: + word_length = len(word) + + if word_length > wrap_width: # Break long words + if line_buffer: + wrapped_lines.append(line_buffer) + line_buffer = "" + line_length = 0 + for i in range(0, word_length, wrap_width): + wrapped_lines.append(word[i:i+wrap_width]) + continue + + if line_length + word_length > wrap_width and word.strip(): + wrapped_lines.append(line_buffer) + line_buffer = "" + line_length = 0 + + line_buffer += word + line_length += word_length + + if line_buffer: + wrapped_lines.append(line_buffer) + + return wrapped_lines + + +def get_text_input(prompt): + """Handles user input with wrapped text for long prompts.""" + height = 8 + width = 80 + margin = 2 # Left and right margin + input_width = width - (2 * margin) # Space available for text + max_input_rows = height - 4 # Space for input + + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + input_win = curses.newwin(height, width, start_y, start_x) + input_win.bkgd(get_color("background")) + input_win.attrset(get_color("window_frame")) + input_win.border() + + # Wrap the prompt text + wrapped_prompt = wrap_text(prompt, wrap_width=input_width) + row = 1 + for line in wrapped_prompt: + input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True)) + row += 1 + if row >= height - 3: # Prevent overflow + break + + prompt_text = "Enter new value: " + input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) + + input_win.refresh() + curses.curs_set(1) + + max_length = 4 if "shortName" in prompt else None + user_input = "" + + # Start user input after the prompt text + col_start = margin + len(prompt_text) + first_line_width = input_width - len(prompt_text) # Available space for first line + + while True: + key = input_win.get_wch() # Waits for user input + + if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow + input_win.erase() + input_win.refresh() + curses.curs_set(0) + return None # Exit without saving + + elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key + break + + elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace + if user_input: + user_input = user_input[:-1] # Remove last character + + elif max_length is None or len(user_input) < max_length: # Enforce max length + if isinstance(key, str): + user_input += key + else: + user_input += chr(key) + + # First line must be manually handled before using wrap_text() + first_line = user_input[:first_line_width] # Cut to max first line width + remaining_text = user_input[first_line_width:] # Remaining text for wrapping + + wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else [] + + # Clear only the input area (without touching prompt text) + for i in range(max_input_rows): + if row + 1 + i < height - 1: + input_win.addstr(row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default")) + + # Redraw the prompt text so it never disappears + input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) + + # Redraw wrapped input + input_win.addstr(row + 1, col_start, first_line, get_color("settings_default")) # First line next to prompt + for i, line in enumerate(wrapped_lines): + if row + 2 + i < height - 1: + input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default")) + + input_win.refresh() + + curses.curs_set(0) + input_win.erase() + input_win.refresh() + return user_input + + +def get_admin_key_input(current_value): + def to_base64(byte_strings): + """Convert byte values to Base64-encoded strings.""" + return [base64.b64encode(b).decode() for b in byte_strings] + + def is_valid_base64(s): + """Check if a string is valid Base64.""" + try: + decoded = base64.b64decode(s, validate=True) + return len(decoded) == 32 # Ensure it's exactly 32 bytes + except binascii.Error: + return False + + cvalue = to_base64(current_value) # Convert current values to Base64 + height = 9 + width = 80 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + repeated_win = curses.newwin(height, width, start_y, start_x) + repeated_win.bkgd(get_color("background")) + repeated_win.attrset(get_color("window_frame")) + repeated_win.keypad(True) # Enable keypad for special keys + + curses.echo() + curses.curs_set(1) + + # Editable list of values (max 3 values) + user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields + cursor_pos = 0 # Track which value is being edited + error_message = "" + + while True: + repeated_win.erase() + repeated_win.border() + repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True)) + + # Display current values, allowing editing + for i, line in enumerate(user_values): + prefix = "→ " if i == cursor_pos else " " # Highlight the current line + repeated_win.addstr(3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))) + repeated_win.addstr(3 + i, 18, line) # Align text for easier editing + + # Move cursor to the correct position inside the field + curses.curs_set(1) + repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text + + # Show error message if needed + if error_message: + repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True)) + + repeated_win.refresh() + key = repeated_win.getch() + + if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original + repeated_win.erase() + repeated_win.refresh() + curses.noecho() + curses.curs_set(0) + return None + + elif key == ord('\n'): # Enter key to save and return + if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes + curses.noecho() + curses.curs_set(0) + return user_values # Return the edited Base64 values + else: + error_message = "Error: Each key must be valid Base64 and 32 bytes long!" + elif key == curses.KEY_UP: # Move cursor up + cursor_pos = (cursor_pos - 1) % len(user_values) + elif key == curses.KEY_DOWN: # Move cursor down + cursor_pos = (cursor_pos + 1) % len(user_values) + elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key + if len(user_values[cursor_pos]) > 0: + user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character + else: + try: + user_values[cursor_pos] += chr(key) # Append valid character input to the selected field + error_message = "" # Clear error if user starts fixing input + except ValueError: + pass # Ignore invalid character inputs + + + +def get_repeated_input(current_value): + height = 9 + width = 80 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + repeated_win = curses.newwin(height, width, start_y, start_x) + repeated_win.bkgd(get_color("background")) + repeated_win.attrset(get_color("window_frame")) + repeated_win.keypad(True) # Enable keypad for special keys + + curses.echo() + curses.curs_set(1) # Show the cursor + + # Editable list of values (max 3 values) + user_values = current_value[:3] + cursor_pos = 0 # Track which value is being edited + error_message = "" + + while True: + repeated_win.erase() + repeated_win.border() + repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True)) + + # Display current values, allowing editing + for i, line in enumerate(user_values): + prefix = "→ " if i == cursor_pos else " " # Highlight the current line + repeated_win.addstr(3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))) + repeated_win.addstr(3 + i, 18, line) + + # Move cursor to the correct position inside the field + curses.curs_set(1) + repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text + + # Show error message if needed + if error_message: + repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True)) + + repeated_win.refresh() + key = repeated_win.getch() + + if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original + repeated_win.erase() + repeated_win.refresh() + curses.noecho() + curses.curs_set(0) + return None + + elif key == ord('\n'): # Enter key to save and return + curses.noecho() + curses.curs_set(0) + return ", ".join(user_values) + elif key == curses.KEY_UP: # Move cursor up + cursor_pos = (cursor_pos - 1) % len(user_values) + elif key == curses.KEY_DOWN: # Move cursor down + cursor_pos = (cursor_pos + 1) % len(user_values) + elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key + if len(user_values[cursor_pos]) > 0: + user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character + else: + try: + user_values[cursor_pos] += chr(key) # Append valid character input to the selected field + error_message = "" # Clear error if user starts fixing input + except ValueError: + pass # Ignore invalid character inputs + + +def get_fixed32_input(current_value): + cvalue = current_value + current_value = str(ipaddress.IPv4Address(current_value)) + height = 10 + width = 80 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + fixed32_win = curses.newwin(height, width, start_y, start_x) + fixed32_win.bkgd(get_color("background")) + fixed32_win.attrset(get_color("window_frame")) + fixed32_win.keypad(True) + + curses.echo() + curses.curs_set(1) + user_input = "" + + while True: + fixed32_win.erase() + 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 + fixed32_win.erase() + fixed32_win.refresh() + 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 + + +def get_list_input(prompt, current_option, list_options): + """ + Displays a scrollable list of list_options for the user to choose from. + """ + selected_index = list_options.index(current_option) if current_option in list_options else 0 + + height = min(len(list_options) + 5, curses.LINES) + width = 80 + start_y = (curses.LINES - height) // 2 + start_x = (curses.COLS - width) // 2 + + list_win = curses.newwin(height, width, start_y, start_x) + list_win.bkgd(get_color("background")) + list_win.attrset(get_color("window_frame")) + list_win.keypad(True) + + list_pad = curses.newpad(len(list_options) + 1, width - 8) + list_pad.bkgd(get_color("background")) + + # Render header + list_win.erase() + list_win.border() + list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True)) + + # Render options on the pad + for idx, color in enumerate(list_options): + if idx == selected_index: + list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True)) + else: + list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default")) + + # Initial refresh + list_win.refresh() + list_pad.refresh(0, 0, + list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, + list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) + + while True: + key = list_win.getch() + + if key == curses.KEY_UP: + old_selected_index = selected_index + selected_index = max(0, selected_index - 1) + move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) + elif key == curses.KEY_DOWN: + old_selected_index = selected_index + selected_index = min(len(list_options) - 1, selected_index + 1) + move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) + elif key == ord('\n'): # Enter key + return list_options[selected_index] + elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + return current_option + + +def move_highlight(old_idx, new_idx, options, list_win, list_pad): + if old_idx == new_idx: + return # no-op + + list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default")) + list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse = True)) + + list_win.refresh() + + start_index = max(0, new_idx - (list_win.getmaxyx()[0] - 5)) + + list_win.refresh() + list_pad.refresh(start_index, 0, + list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, + list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + 4 + list_win.getmaxyx()[1] - 4) + \ No newline at end of file diff --git a/save_to_radio.py b/utilities/save_to_radio.py similarity index 74% rename from save_to_radio.py rename to utilities/save_to_radio.py index 3fc7464..9546d02 100644 --- a/save_to_radio.py +++ b/utilities/save_to_radio.py @@ -2,10 +2,9 @@ from meshtastic.protobuf import channel_pb2 from google.protobuf.message import Message import logging import base64 -from db_handler import update_node_info_in_db -import globals +import time -def save_changes(menu_path, modified_settings): +def save_changes(interface, menu_path, modified_settings): """ Save changes to the device based on modified settings. :param interface: Meshtastic interface instance @@ -17,7 +16,41 @@ def save_changes(menu_path, modified_settings): logging.info("No changes to save. modified_settings is empty.") return - node = globals.interface.getNode('^local') + node = interface.getNode('^local') + admin_key_backup = None + if 'admin_key' in modified_settings: + # Get reference to security config + security_config = node.localConfig.security + admin_keys = modified_settings['admin_key'] + + # Filter out empty keys + valid_keys = [key for key in admin_keys if key and key.strip() and key != b''] + + if not valid_keys: + logging.warning("No valid admin keys provided. Skipping admin key update.") + else: + # Clear existing keys if needed + if security_config.admin_key: + logging.info("Clearing existing admin keys...") + del security_config.admin_key[:] + node.writeConfig("security") + time.sleep(2) # Give time for device to process + + # Append new keys + for key in valid_keys: + logging.info(f"Adding admin key: {key}") + security_config.admin_key.append(key) + node.writeConfig("security") + logging.info("Admin keys updated successfully!") + + # Backup 'admin_key' before removing it + admin_key_backup = modified_settings.get('admin_key', None) + # Remove 'admin_key' from modified_settings to prevent interference + del modified_settings['admin_key'] + + # Return early if there are no other settings left to process + if not modified_settings: + return if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings": config_category = menu_path[2].lower() # for radio and module configs @@ -27,7 +60,7 @@ def save_changes(menu_path, modified_settings): lon = float(modified_settings.get('longitude', 0.0)) alt = int(modified_settings.get('altitude', 0)) - globals.interface.localNode.setFixedPosition(lat, lon, alt) + interface.localNode.setFixedPosition(lat, lon, alt) logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") return @@ -40,9 +73,6 @@ def save_changes(menu_path, modified_settings): node.setOwner(long_name, short_name, is_licensed) - # Update only the changed fields and preserve others - update_node_info_in_db(globals.myNodeNum, long_name=long_name, short_name=short_name, is_licensed=is_licensed) - logging.info(f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}") return @@ -119,6 +149,9 @@ def save_changes(menu_path, modified_settings): try: node.writeConfig(config_category) logging.info(f"Changes written to config category: {config_category}") + + if admin_key_backup is not None: + modified_settings['admin_key'] = admin_key_backup except Exception as e: logging.error(f"Failed to write configuration for category '{config_category}': {e}") diff --git a/utilities/utils.py b/utilities/utils.py index 1aeaf9c..ce8d567 100644 --- a/utilities/utils.py +++ b/utilities/utils.py @@ -1,7 +1,7 @@ import globals import datetime from meshtastic.protobuf import config_pb2 -import default_config as config +import ui.default_config as config def get_channels(): """Retrieve channels from the node and update globals.channel_list and globals.all_messages.""" From 02034e5821f60d374bdfd8aaafcba9e4188673ed Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 7 Mar 2025 16:54:39 -0800 Subject: [PATCH 45/73] bump version --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 9c39961..1ce2807 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ ''' Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Powered by Meshtastic.org -V 1.2.1 +V 1.2.2 ''' import contextlib From 1ccd337b35a7c577812fa7b6e174708fa2dddbeb Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 8 Mar 2025 12:30:28 -0800 Subject: [PATCH 46/73] maybe fix noon bug --- ui/curses_ui.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 4cdcc04..3105078 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -319,7 +319,10 @@ def draw_channel_list(): if isinstance(channel, int): if is_chat_archived(channel): continue - channel = get_name_from_database(channel, type='long') + channel_name = get_name_from_database(channel, type='long') + if channel_name is None: + continue + channel = channel_name # Determine whether to add the notification notification = " " + config.notification_symbol if idx in globals.notifications else "" From 3fc0495fb145fbf48cab079f0d150b76d5479585 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 8 Mar 2025 18:16:37 -0800 Subject: [PATCH 47/73] natural scrolling --- ui/control_ui.py | 81 ++++++++++++++++++++++++++----------- utilities/input_handlers.py | 38 ++++++++++++----- 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/ui/control_ui.py b/ui/control_ui.py index c5a9db4..7dba967 100644 --- a/ui/control_ui.py +++ b/ui/control_ui.py @@ -37,6 +37,10 @@ field_mapping, help_text = parse_ini_file(translation_file) def display_menu(current_menu, menu_path, selected_index, show_save_option, help_text): min_help_window_height = 6 num_items = len(current_menu) + (1 if show_save_option else 0) + # Track visible range + global start_index + if 'start_index' not in globals(): + start_index = [0] # Initialize if not set # Determine the available height for the menu max_menu_height = curses.LINES @@ -90,7 +94,7 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help menu_win.refresh() menu_pad.refresh( - 0, 0, + start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8 @@ -241,36 +245,45 @@ def get_wrapped_help_text(help_text, transformed_path, selected_option, width, m def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines): - if old_idx == new_idx: # No-op return max_index = len(options) + (1 if show_save_option else 0) - 1 + visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) - if show_save_option and old_idx == max_index: # Special case un-highlight "Save" option + # Adjust start_index only when moving out of visible range + if new_idx < start_index[-1]: # Moving above the visible area + start_index[-1] = new_idx + elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area + start_index[-1] = new_idx - visible_height + + # Ensure start_index is within bounds + start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1)) + + # Clear old selection + if show_save_option and old_idx == max_index: menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")) else: menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) - if show_save_option and new_idx == max_index: # Special case highlight "Save" option + # Highlight new selection + if show_save_option and new_idx == max_index: menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) else: menu_pad.chgat(new_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True)) menu_win.refresh() - - start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) - menu_pad.refresh(start_index, 0, + + # Refresh pad only if scrolling is needed + menu_pad.refresh(start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), + menu_win.getbegyx()[0] + 3 + visible_height, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8) - # Transform menu path + # Update help window transformed_path = transform_menu_path(menu_path) selected_option = options[new_idx] if new_idx < len(options) else None help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - - # Call helper function to update the help window help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) @@ -337,6 +350,7 @@ def settings_menu(stdscr, interface): elif key == curses.KEY_RIGHT or key == ord('\n'): need_redraw = True + start_index.append(0) menu_win.erase() help_win.erase() @@ -372,6 +386,7 @@ def settings_menu(stdscr, interface): filename = get_text_input("Enter a filename for the config file") if not filename: logging.info("Export aborted: No filename provided.") + start_index.pop() continue # Go back to the menu if not filename.lower().endswith(".yaml"): filename += ".yaml" @@ -384,14 +399,14 @@ def settings_menu(stdscr, interface): overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) if overwrite == "No": logging.info("Export cancelled: User chose not to overwrite.") + start_index.pop() continue # Return to menu - os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) with open(yaml_file_path, "w", encoding="utf-8") as file: file.write(config_text) logging.info(f"Config file saved to {yaml_file_path}") dialog(stdscr, "Config File Saved:", yaml_file_path) - + start_index.pop() continue except PermissionError: logging.error(f"Permission denied: Unable to write to {yaml_file_path}") @@ -399,8 +414,9 @@ def settings_menu(stdscr, interface): logging.error(f"OS error while saving config: {e}") except Exception as e: logging.error(f"Unexpected error: {e}") + start_index.pop() continue - + elif selected_option == "Load Config File": folder_path = os.path.join(app_directory, config_folder) @@ -422,6 +438,7 @@ def settings_menu(stdscr, interface): overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) if overwrite == "Yes": config_import(interface, file_path) + start_index.pop() continue elif selected_option == "Config URL": @@ -433,6 +450,7 @@ def settings_menu(stdscr, interface): if overwrite == "Yes": interface.localNode.setURL(new_value) logging.info(f"New Config URL sent to node") + start_index.pop() continue elif selected_option == "Reboot": @@ -440,6 +458,7 @@ def settings_menu(stdscr, interface): if confirmation == "Yes": interface.localNode.reboot() logging.info(f"Node Reboot Requested by menu") + start_index.pop() continue elif selected_option == "Reset Node DB": @@ -447,6 +466,7 @@ def settings_menu(stdscr, interface): if confirmation == "Yes": interface.localNode.resetNodeDb() logging.info(f"Node DB Reset Requested by menu") + start_index.pop() continue elif selected_option == "Shutdown": @@ -454,6 +474,7 @@ def settings_menu(stdscr, interface): if confirmation == "Yes": interface.localNode.shutdown() logging.info(f"Node Shutdown Requested by menu") + start_index.pop() continue elif selected_option == "Factory Reset": @@ -461,14 +482,15 @@ def settings_menu(stdscr, interface): if confirmation == "Yes": interface.localNode.factoryReset() logging.info(f"Factory Reset Requested by menu") + start_index.pop() continue - elif selected_option == "App Settings": - menu_win.clear() - menu_win.refresh() - json_editor(stdscr) # Open the App Settings menu - continue - # need_redraw = True + # elif selected_option == "App Settings": + # menu_win.clear() + # menu_win.refresh() + # json_editor(stdscr) # Open the App Settings menu + # continue + # # need_redraw = True field_info = current_menu.get(selected_option) if isinstance(field_info, tuple): @@ -495,6 +517,8 @@ def settings_menu(stdscr, interface): for option, (field, value) in current_menu.items(): modified_settings[option] = value + start_index.pop() + elif selected_option in ['latitude', 'longitude', 'altitude']: new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = current_value if new_value is None else new_value @@ -504,37 +528,47 @@ def settings_menu(stdscr, interface): if option in current_menu: modified_settings[option] = current_menu[option][1] + start_index.pop() + elif selected_option == "admin_key": new_values = get_admin_key_input(current_value) new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values] + start_index.pop() elif field.type == 8: # Handle boolean type new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) new_value = new_value == "True" or new_value is True + start_index.pop() elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used new_value = get_repeated_input(current_value) new_value = current_value if new_value is None else new_value.split(", ") + start_index.pop() elif field.enum_type: # Enum field enum_options = {v.name: v.number for v in field.enum_type.values} new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys())) new_value = enum_options.get(new_value_name, current_value) + start_index.pop() elif field.type == 7: # Field type 7 corresponds to FIXED32 new_value = get_fixed32_input(current_value) + start_index.pop() elif field.type == 13: # Field type 13 corresponds to UINT32 new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = current_value if new_value is None else int(new_value) + start_index.pop() elif field.type == 2: # Field type 13 corresponds to INT64 new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = current_value if new_value is None else float(new_value) + start_index.pop() else: # Handle other field types new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = current_value if new_value is None else new_value + start_index.pop() for key in menu_path[3:]: # Skip "Main Menu" modified_settings = modified_settings.setdefault(key, {}) @@ -554,6 +588,7 @@ def settings_menu(stdscr, interface): menu_index.append(selected_index) selected_index = 0 + elif key == curses.KEY_LEFT: need_redraw = True @@ -576,7 +611,8 @@ def settings_menu(stdscr, interface): for step in menu_path[1:]: current_menu = current_menu.get(step, {}) selected_index = menu_index.pop() - + start_index.pop() + elif key == 27: # Escape key menu_win.erase() menu_win.refresh() @@ -599,5 +635,4 @@ def set_region(interface): new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found node.localConfig.lora.region = new_region_number - node.writeConfig("lora") - \ No newline at end of file + node.writeConfig("lora") \ No newline at end of file diff --git a/utilities/input_handlers.py b/utilities/input_handlers.py index 4977cfe..1aac5da 100644 --- a/utilities/input_handlers.py +++ b/utilities/input_handlers.py @@ -391,18 +391,38 @@ def get_list_input(prompt, current_option, list_options): def move_highlight(old_idx, new_idx, options, list_win, list_pad): + + global scroll_offset + if 'scroll_offset' not in globals(): + scroll_offset = 0 # Initialize if not set + if old_idx == new_idx: - return # no-op + return # No-op + max_index = len(options) - 1 + visible_height = list_win.getmaxyx()[0] - 5 + + # Adjust scroll_offset only when moving out of visible range + if new_idx < scroll_offset: # Moving above the visible area + scroll_offset = new_idx + elif new_idx >= scroll_offset + visible_height: # Moving below the visible area + scroll_offset = new_idx - visible_height + + # Ensure scroll_offset is within bounds + scroll_offset = max(0, min(scroll_offset, max_index - visible_height + 1)) + + # Clear old highlight list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default")) - list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse = True)) + + # Highlight new selection + list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse=True)) list_win.refresh() - - start_index = max(0, new_idx - (list_win.getmaxyx()[0] - 5)) - - list_win.refresh() - list_pad.refresh(start_index, 0, + + # Refresh pad only if scrolling is needed + list_pad.refresh(scroll_offset, 0, list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, - list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + 4 + list_win.getmaxyx()[1] - 4) - \ No newline at end of file + list_win.getbegyx()[0] + 3 + visible_height, + list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) + + return scroll_offset # Return updated scroll_offset to be stored externally \ No newline at end of file From 04381585ab3c9863b73632ab7aec2dbf9c89120c Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sat, 8 Mar 2025 18:40:01 -0800 Subject: [PATCH 48/73] restore app settings --- ui/control_ui.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/control_ui.py b/ui/control_ui.py index 7dba967..e502838 100644 --- a/ui/control_ui.py +++ b/ui/control_ui.py @@ -485,12 +485,12 @@ def settings_menu(stdscr, interface): start_index.pop() continue - # elif selected_option == "App Settings": - # menu_win.clear() - # menu_win.refresh() - # json_editor(stdscr) # Open the App Settings menu - # continue - # # need_redraw = True + elif selected_option == "App Settings": + menu_win.clear() + menu_win.refresh() + json_editor(stdscr) # Open the App Settings menu + continue + # need_redraw = True field_info = current_menu.get(selected_option) if isinstance(field_info, tuple): From ee5f2fa4d441c5324950ce67930a771204d4423f Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Mon, 10 Mar 2025 18:19:10 -0700 Subject: [PATCH 49/73] scroll arrows (#137) --- ui/control_ui.py | 35 +++++++++++++++++++++++++++++++---- utilities/input_handlers.py | 28 +++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/ui/control_ui.py b/ui/control_ui.py index e502838..5c52fd8 100644 --- a/ui/control_ui.py +++ b/ui/control_ui.py @@ -100,8 +100,14 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8 ) + max_index = num_items + (1 if show_save_option else 0) - 1 + visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) + + draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option) + return menu_win, menu_pad + def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, current_menu, selected_index, transformed_path): global help_win @@ -151,7 +157,6 @@ def update_help_window(help_win, help_text, transformed_path, selected_option, m return help_win - def get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_lines): """Fetches and formats help text for display, ensuring it fits within the allowed lines.""" @@ -252,10 +257,13 @@ def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_p visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) # Adjust start_index only when moving out of visible range - if new_idx < start_index[-1]: # Moving above the visible area + if new_idx == max_index and show_save_option: + pass + elif new_idx < start_index[-1]: # Moving above the visible area start_index[-1] = new_idx elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area - start_index[-1] = new_idx - visible_height + start_index[-1] = new_idx - visible_height + pass # Ensure start_index is within bounds start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1)) @@ -278,7 +286,7 @@ def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_p menu_pad.refresh(start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3 + visible_height, - menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8) + menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4) # Update help window transformed_path = transform_menu_path(menu_path) @@ -286,6 +294,25 @@ def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_p help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) + draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option) + + +def draw_arrows(win, visible_height, max_index, start_index, show_save_option): + + # vh = visible_height + (1 if show_save_option else 0) + mi = max_index - (2 if show_save_option else 0) + + if visible_height < mi: + if start_index[-1] > 0: + win.addstr(3, 2, "▲", get_color("settings_default")) + else: + win.addstr(3, 2, " ", get_color("settings_default")) + + if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1) : + win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) + else: + win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) + def settings_menu(stdscr, interface): curses.update_lines_cols() diff --git a/utilities/input_handlers.py b/utilities/input_handlers.py index 1aac5da..34b41ae 100644 --- a/utilities/input_handlers.py +++ b/utilities/input_handlers.py @@ -372,6 +372,11 @@ def get_list_input(prompt, current_option, list_options): list_pad.refresh(0, 0, list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) + + max_index = len(list_options) - 1 + visible_height = list_win.getmaxyx()[0] - 5 + + draw_arrows(list_win, visible_height, max_index, 0) while True: key = list_win.getch() @@ -385,8 +390,12 @@ def get_list_input(prompt, current_option, list_options): selected_index = min(len(list_options) - 1, selected_index + 1) move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) elif key == ord('\n'): # Enter key + list_win.clear() + list_win.refresh() return list_options[selected_index] elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + list_win.clear() + list_win.refresh() return current_option @@ -424,5 +433,22 @@ def move_highlight(old_idx, new_idx, options, list_win, list_pad): list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, list_win.getbegyx()[0] + 3 + visible_height, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) + + draw_arrows(list_win, visible_height, max_index, scroll_offset) - return scroll_offset # Return updated scroll_offset to be stored externally \ No newline at end of file + return scroll_offset # Return updated scroll_offset to be stored externally + + +def draw_arrows(win, visible_height, max_index, start_index): + + if visible_height < max_index: + if start_index > 0: + win.addstr(3, 2, "▲", get_color("settings_default")) + else: + win.addstr(3, 2, " ", get_color("settings_default")) + + if max_index - start_index > visible_height: + win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) + else: + win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) + \ No newline at end of file From 4bc1654eeda6c954ab2b840e6a30b5a342d4f7e4 Mon Sep 17 00:00:00 2001 From: pdxlocations <117498748+pdxlocations@users.noreply.github.com> Date: Tue, 11 Mar 2025 10:28:06 -0700 Subject: [PATCH 50/73] fix noon bug (#138) --- utilities/db_handler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/utilities/db_handler.py b/utilities/db_handler.py index ea875df..c471fb7 100644 --- a/utilities/db_handler.py +++ b/utilities/db_handler.py @@ -190,12 +190,16 @@ def maybe_store_nodeinfo_in_db(packet): except Exception as e: logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}") - -def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None): +def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model="UNSET", is_licensed=0, role="CLIENT", public_key="", chat_archived=0): """Update or insert node information into the database, preserving unchanged fields.""" try: ensure_node_table_exists() # Ensure the table exists before any operation + if long_name == None: + long_name = "Meshtastic " + str(decimal_to_hex(user_id)[-4:]) + if short_name == None: + short_name = str(decimal_to_hex(user_id)[-4:]) + with sqlite3.connect(config.db_file_path) as db_connection: db_cursor = db_connection.cursor() table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names From b9cecaea315f1f10437ea0036a3f50255f0cb5d4 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Mon, 17 Mar 2025 16:25:26 -0700 Subject: [PATCH 51/73] config filepath cleanup --- ui/control_ui.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/ui/control_ui.py b/ui/control_ui.py index 5c52fd8..c89a1a6 100644 --- a/ui/control_ui.py +++ b/ui/control_ui.py @@ -386,10 +386,6 @@ def settings_menu(stdscr, interface): menu_win.refresh() help_win.refresh() - # Get the parent directory of the script - app_directory = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) - config_folder = "node-configs" - if show_save_option and selected_index == len(options): save_changes(interface, menu_path, modified_settings) modified_settings.clear() @@ -401,7 +397,6 @@ def settings_menu(stdscr, interface): for step in menu_path[1:]: current_menu = current_menu.get(step, {}) selected_index = 0 - continue selected_option = options[selected_index] @@ -420,7 +415,7 @@ def settings_menu(stdscr, interface): try: config_text = config_export(interface) - yaml_file_path = os.path.join(app_directory, config_folder, filename) + yaml_file_path = os.path.join(config_folder, filename) if os.path.exists(yaml_file_path): overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) @@ -445,14 +440,13 @@ def settings_menu(stdscr, interface): continue elif selected_option == "Load Config File": - folder_path = os.path.join(app_directory, config_folder) # Check if folder exists and is not empty - if not os.path.exists(folder_path) or not any(os.listdir(folder_path)): + if not os.path.exists(config_folder) or not any(os.listdir(config_folder)): dialog(stdscr, "", " No config files found. Export a config first.") continue # Return to menu - file_list = [f for f in os.listdir(folder_path) if os.path.isfile(os.path.join(folder_path, f))] + file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))] # Ensure file_list is not empty before proceeding if not file_list: @@ -461,7 +455,7 @@ def settings_menu(stdscr, interface): filename = get_list_input("Choose a config file", None, file_list) if filename: - file_path = os.path.join(app_directory, config_folder, filename) + file_path = os.path.join(config_folder, filename) overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) if overwrite == "Yes": config_import(interface, file_path) From c97942d35dfd43d4ebc0488b48910ab9fab4c90e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Mon, 17 Mar 2025 16:54:50 -0700 Subject: [PATCH 52/73] attribution --- main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/main.py b/main.py index 1ce2807..c0028c3 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,8 @@ Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Powered by Meshtastic.org V 1.2.2 + +Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk. ''' import contextlib From 8e6edf8e83f0cd5ca5c6088797192883edf2f893 Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Tue, 18 Mar 2025 11:58:00 -0500 Subject: [PATCH 53/73] Fix crash sending long message Don't move the input window itself when restoring the cursor to the input window - just put the cursor there and refresh. --- ui/curses_ui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 7388b9d..0ce46d9 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -443,9 +443,9 @@ def draw_node_list(): refresh_pad(2) # Restore cursor to input field - entry_win.move(1, len("Input: ") + len(input_text)+1) - entry_win.refresh() + entry_win.keypad(True) curses.curs_set(1) + entry_win.refresh() def select_channel(idx): old_selected_channel = globals.selected_channel @@ -544,9 +544,9 @@ def draw_packetlog_win(): packetlog_win.refresh() # Restore cursor to input field - entry_win.move(1, len("Input: ") + len(input_text)+1) - entry_win.refresh() + entry_win.keypad(True) curses.curs_set(1) + entry_win.refresh() def search(win): start_idx = globals.selected_node From 3be31698dfcbdc6761cc1d1b7ccf2966f7d6acd2 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Mon, 24 Mar 2025 19:14:02 -0700 Subject: [PATCH 54/73] remove fox references --- localisations/en.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/localisations/en.ini b/localisations/en.ini index ebd01ef..a677aba 100644 --- a/localisations/en.ini +++ b/localisations/en.ini @@ -24,9 +24,9 @@ serial_enabled, "Enable serial console", "" button_gpio, "Button GPIO", "GPIO pin for user button." buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer." rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast." -node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Femtofox will still send nodeinfo in response to new nodes on the mesh." +node_info_broadcast_secs, "Nodeinfo broadcast interval", "This is the number of seconds between NodeInfo message broadcasts. Will also send a nodeinfo in response to new nodes on the mesh." double_tap_as_button_press, "Double tap as button press", "This option will enable a double tap, when a supported accelerometer is attached to the device, to be treated as a button press." -is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" +is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note] Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" disable_triple_click, "Disable triple button press", "" tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs." led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED." @@ -115,7 +115,7 @@ spread_factor, "Spread factor", "Indicates the number of chirps per symbol. Only coding_rate, "Coding rate", "The proportion of each LoRa transmission that contains actual data - the rest is used for error correction." frequency_offset, "Frequency offset", "This parameter is for advanced users with advanced test equipment." region, "Region", "Sets the region for your node. As long as this is not set, the node will display a message and not transmit any packets." -hop_limit, "Hop limit", "The maximum number of intermediate nodes between Femtofox and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7." +hop_limit, "Hop limit", "The maximum number of intermediate nodes between our node and a node it is sending to. Does not impact received messages.\n[warning]Excessive hop limit increases congestion![/warning]\nMust be between 0-7." tx_enabled, "Enable TX", "Enables/disables the radio chip. Useful for hot-swapping antennas." tx_power, "TX power in dBm", "[warning]Setting a 33db radio above 8db will permanently damage it. ERP above 27db violates EU law. ERP above 36db violates US (unlicensed) law.[/warning] If 0, will use the max continuous power legal in region. Must be 0-30 (0=automatic)." channel_num, "Frequency slot", "Determines the exact frequency the radio transmits and receives. If unset or set to 0, determined automatically by the primary channel name." @@ -139,7 +139,7 @@ private_key, "Private key", "The private key of the device, used to create a sha is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps and web UI from changing configuration. [note]This setting is not required for remote node administration.[/note]Before enabling, verify that node can be controlled via Remote Admin to [warning]prevent being locked out.[/warning]" serial_enabled, "Enable serial console", "" debug_log_api_enabled, "Enable debug log", "Set this to true to continue outputting live debug logs over serial or Bluetooth when the API is active." -admin_channel_enabled, "Enable legacy admin channel", "If the node you Femtofox needs to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes." +admin_channel_enabled, "Enable legacy admin channel", "If the node you need to administer or be administered by is running 2.4.x or earlier, you should set this to enabled. Requires a secondary channel named 'admin' be present on both nodes." admin_key, "Admin keys", "The public key(s) authorized to send administrative messages to this node. Only messages signed by these keys will be accepted for administrative control. Up to 3." [module.mqtt] From 6d204581ce520a2b0d6815db0dda326f9a94a63f Mon Sep 17 00:00:00 2001 From: vidplace7 Date: Tue, 25 Mar 2025 08:58:11 -0400 Subject: [PATCH 55/73] Package with poetry --- .gitignore | 3 +- globals.py => mtcontact/globals.py | 0 .../localisations}/en.ini | 0 main.py => mtcontact/main.py | 31 ++++++++++--------- .../message_handlers}/rx_handler.py | 10 +++--- .../message_handlers}/tx_handler.py | 6 ++-- settings.py => mtcontact/settings.py | 14 ++++----- {ui => mtcontact/ui}/colors.py | 2 +- {ui => mtcontact/ui}/control_ui.py | 23 ++++++++------ {ui => mtcontact/ui}/curses_ui.py | 18 +++++------ {ui => mtcontact/ui}/default_config.py | 0 {ui => mtcontact/ui}/dialog.py | 2 +- {ui => mtcontact/ui}/menus.py | 0 {ui => mtcontact/ui}/splash.py | 2 +- {ui => mtcontact/ui}/user_config.py | 6 ++-- .../utilities}/arg_parser.py | 0 .../utilities}/config_io.py | 0 .../utilities}/control_utils.py | 0 .../utilities}/db_handler.py | 6 ++-- .../utilities}/input_handlers.py | 2 +- .../utilities}/interfaces.py | 2 +- .../utilities}/save_to_radio.py | 0 {utilities => mtcontact/utilities}/utils.py | 4 +-- pyproject.toml | 21 +++++++++++++ 24 files changed, 90 insertions(+), 62 deletions(-) rename globals.py => mtcontact/globals.py (100%) rename {localisations => mtcontact/localisations}/en.ini (100%) rename main.py => mtcontact/main.py (81%) rename {message_handlers => mtcontact/message_handlers}/rx_handler.py (91%) rename {message_handlers => mtcontact/message_handlers}/tx_handler.py (97%) rename settings.py => mtcontact/settings.py (83%) rename {ui => mtcontact/ui}/colors.py (95%) rename {ui => mtcontact/ui}/control_ui.py (97%) rename {ui => mtcontact/ui}/curses_ui.py (97%) rename {ui => mtcontact/ui}/default_config.py (100%) rename {ui => mtcontact/ui}/dialog.py (96%) rename {ui => mtcontact/ui}/menus.py (100%) rename {ui => mtcontact/ui}/splash.py (92%) rename {ui => mtcontact/ui}/user_config.py (98%) rename {utilities => mtcontact/utilities}/arg_parser.py (100%) rename {utilities => mtcontact/utilities}/config_io.py (100%) rename {utilities => mtcontact/utilities}/control_utils.py (100%) rename {utilities => mtcontact/utilities}/db_handler.py (99%) rename {utilities => mtcontact/utilities}/input_handlers.py (99%) rename {utilities => mtcontact/utilities}/interfaces.py (96%) rename {utilities => mtcontact/utilities}/save_to_radio.py (100%) rename {utilities => mtcontact/utilities}/utils.py (97%) create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore index 5bceb25..1d30efb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ client.db client.log settings.log config.json -default_config.log \ No newline at end of file +default_config.log +dist/ \ No newline at end of file diff --git a/globals.py b/mtcontact/globals.py similarity index 100% rename from globals.py rename to mtcontact/globals.py diff --git a/localisations/en.ini b/mtcontact/localisations/en.ini similarity index 100% rename from localisations/en.ini rename to mtcontact/localisations/en.ini diff --git a/main.py b/mtcontact/main.py similarity index 81% rename from main.py rename to mtcontact/main.py index c0028c3..51f6fd2 100644 --- a/main.py +++ b/mtcontact/main.py @@ -18,18 +18,18 @@ import logging import traceback import threading -from utilities.db_handler import init_nodedb, load_messages_from_db -from message_handlers.rx_handler import on_receive -from settings import set_region -from ui.curses_ui import main_ui -from ui.colors import setup_colors -from ui.splash import draw_splash -import ui.default_config as config -from utilities.arg_parser import setup_parser -from utilities.interfaces import initialize_interface -from utilities.input_handlers import get_list_input -from utilities.utils import get_channels, get_node_list, get_nodeNum -import globals +from mtcontact.utilities.db_handler import init_nodedb, load_messages_from_db +from mtcontact.message_handlers.rx_handler import on_receive +from mtcontact.settings import set_region +from mtcontact.ui.curses_ui import main_ui +from mtcontact.ui.colors import setup_colors +from mtcontact.ui.splash import draw_splash +import mtcontact.ui.default_config as config +from mtcontact.utilities.arg_parser import setup_parser +from mtcontact.utilities.interfaces import initialize_interface +from mtcontact.utilities.input_handlers import get_list_input +from mtcontact.utilities.utils import get_channels, get_node_list, get_nodeNum +import mtcontact.globals as globals # Set ncurses compatibility settings os.environ["NCURSES_NO_UTF8_ACS"] = "1" @@ -87,7 +87,7 @@ def main(stdscr): logging.error("Console output before crash:\n%s", console_output) raise # Re-raise only unexpected errors -if __name__ == "__main__": +def start(): log_file = config.log_file_path log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes @@ -103,4 +103,7 @@ if __name__ == "__main__": except Exception as e: logging.error("Fatal error in curses wrapper: %s", e) logging.error("Traceback: %s", traceback.format_exc()) - sys.exit(1) # Exit with an error code \ No newline at end of file + sys.exit(1) # Exit with an error code + +if __name__ == "__main__": + start() diff --git a/message_handlers/rx_handler.py b/mtcontact/message_handlers/rx_handler.py similarity index 91% rename from message_handlers/rx_handler.py rename to mtcontact/message_handlers/rx_handler.py index c95f0c5..b260587 100644 --- a/message_handlers/rx_handler.py +++ b/mtcontact/message_handlers/rx_handler.py @@ -1,11 +1,11 @@ import logging import time -from utilities.utils import refresh_node_list +from mtcontact.utilities.utils import refresh_node_list from datetime import datetime -from ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification -from utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db -import ui.default_config as config -import globals +from mtcontact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification +from mtcontact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db +import mtcontact.ui.default_config as config +import mtcontact.globals as globals from datetime import datetime diff --git a/message_handlers/tx_handler.py b/mtcontact/message_handlers/tx_handler.py similarity index 97% rename from message_handlers/tx_handler.py rename to mtcontact/message_handlers/tx_handler.py index 0b72fc9..e0772c5 100644 --- a/message_handlers/tx_handler.py +++ b/mtcontact/message_handlers/tx_handler.py @@ -3,9 +3,9 @@ import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 -from utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db -import ui.default_config as config -import globals +from mtcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import mtcontact.ui.default_config as config +import mtcontact.globals as globals ack_naks = {} diff --git a/settings.py b/mtcontact/settings.py similarity index 83% rename from settings.py rename to mtcontact/settings.py index 5be03b4..cf6593c 100644 --- a/settings.py +++ b/mtcontact/settings.py @@ -5,13 +5,13 @@ import logging import sys import traceback -import ui.default_config as config -from utilities.input_handlers import get_list_input -from ui.colors import setup_colors -from ui.splash import draw_splash -from ui.control_ui import set_region, settings_menu -from utilities.arg_parser import setup_parser -from utilities.interfaces import initialize_interface +import mtcontact.ui.default_config as config +from mtcontact.utilities.input_handlers import get_list_input +from mtcontact.ui.colors import setup_colors +from mtcontact.ui.splash import draw_splash +from mtcontact.ui.control_ui import set_region, settings_menu +from mtcontact.utilities.arg_parser import setup_parser +from mtcontact.utilities.interfaces import initialize_interface def main(stdscr): diff --git a/ui/colors.py b/mtcontact/ui/colors.py similarity index 95% rename from ui/colors.py rename to mtcontact/ui/colors.py index 8016542..3891073 100644 --- a/ui/colors.py +++ b/mtcontact/ui/colors.py @@ -1,5 +1,5 @@ import curses -import ui.default_config as config +import mtcontact.ui.default_config as config COLOR_MAP = { "black": curses.COLOR_BLACK, diff --git a/ui/control_ui.py b/mtcontact/ui/control_ui.py similarity index 97% rename from ui/control_ui.py rename to mtcontact/ui/control_ui.py index c89a1a6..00630cf 100644 --- a/ui/control_ui.py +++ b/mtcontact/ui/control_ui.py @@ -5,14 +5,16 @@ import os import re import sys -from utilities.save_to_radio import save_changes -from utilities.config_io import config_export, config_import -from utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input -from ui.menus import generate_menu_from_protobuf -from ui.colors import get_color -from ui.dialog import dialog -from utilities.control_utils import parse_ini_file, transform_menu_path -from ui.user_config import json_editor +from mtcontact.utilities.save_to_radio import save_changes +from mtcontact.utilities.config_io import config_export, config_import +from mtcontact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input +from mtcontact.ui.menus import generate_menu_from_protobuf +from mtcontact.ui.colors import get_color +from mtcontact.ui.dialog import dialog +from mtcontact.utilities.control_utils import parse_ini_file, transform_menu_path +from mtcontact.ui.user_config import json_editor + +import mtcontact.localisations # Constants width = 80 @@ -27,8 +29,9 @@ parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) # Paths locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory -translation_file = os.path.join(locals_dir, "localisations", "en.ini") -config_folder = os.path.join(parent_dir, "node-configs") +translation_file = os.path.join(parent_dir, "localisations", "en.ini") + +config_folder = os.path.join(locals_dir, "node-configs") # Load translations field_mapping, help_text = parse_ini_file(translation_file) diff --git a/ui/curses_ui.py b/mtcontact/ui/curses_ui.py similarity index 97% rename from ui/curses_ui.py rename to mtcontact/ui/curses_ui.py index 7547cfd..ca2f559 100644 --- a/ui/curses_ui.py +++ b/mtcontact/ui/curses_ui.py @@ -2,14 +2,14 @@ import curses import textwrap import logging import traceback -from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list -from settings import settings_menu -from message_handlers.tx_handler import send_message, send_traceroute -from ui.colors import setup_colors, get_color -from utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived -import ui.default_config as config -import ui.dialog -import globals +from mtcontact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list +from mtcontact.settings import settings_menu +from mtcontact.message_handlers.tx_handler import send_message, send_traceroute +from mtcontact.ui.colors import setup_colors, get_color +from mtcontact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived +import mtcontact.ui.default_config as config +import mtcontact.ui.dialog +import mtcontact.globals as globals def handle_resize(stdscr, firstrun): global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win @@ -212,7 +212,7 @@ def main_ui(stdscr): elif char == chr(20): send_traceroute() curses.curs_set(0) # Hide cursor - ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") + mtcontact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") curses.curs_set(1) # Show cursor again handle_resize(stdscr, False) diff --git a/ui/default_config.py b/mtcontact/ui/default_config.py similarity index 100% rename from ui/default_config.py rename to mtcontact/ui/default_config.py diff --git a/ui/dialog.py b/mtcontact/ui/dialog.py similarity index 96% rename from ui/dialog.py rename to mtcontact/ui/dialog.py index 2d9ed67..c5a4c7a 100644 --- a/ui/dialog.py +++ b/mtcontact/ui/dialog.py @@ -1,5 +1,5 @@ import curses -from ui.colors import get_color +from mtcontact.ui.colors import get_color def dialog(stdscr, title, message): height, width = stdscr.getmaxyx() diff --git a/ui/menus.py b/mtcontact/ui/menus.py similarity index 100% rename from ui/menus.py rename to mtcontact/ui/menus.py diff --git a/ui/splash.py b/mtcontact/ui/splash.py similarity index 92% rename from ui/splash.py rename to mtcontact/ui/splash.py index 08fe2e5..785b5da 100644 --- a/ui/splash.py +++ b/mtcontact/ui/splash.py @@ -1,5 +1,5 @@ import curses -from ui.colors import get_color +from mtcontact.ui.colors import get_color def draw_splash(stdscr): curses.curs_set(0) diff --git a/ui/user_config.py b/mtcontact/ui/user_config.py similarity index 98% rename from ui/user_config.py rename to mtcontact/ui/user_config.py index 32aae7f..dc10db8 100644 --- a/ui/user_config.py +++ b/mtcontact/ui/user_config.py @@ -1,9 +1,9 @@ import os import json import curses -from ui.colors import get_color, setup_colors, COLOR_MAP -from ui.default_config import format_json_single_line_arrays, loaded_config -from utilities.input_handlers import get_list_input +from mtcontact.ui.colors import get_color, setup_colors, COLOR_MAP +from mtcontact.ui.default_config import format_json_single_line_arrays, loaded_config +from mtcontact.utilities.input_handlers import get_list_input width = 60 save_option_text = "Save Changes" diff --git a/utilities/arg_parser.py b/mtcontact/utilities/arg_parser.py similarity index 100% rename from utilities/arg_parser.py rename to mtcontact/utilities/arg_parser.py diff --git a/utilities/config_io.py b/mtcontact/utilities/config_io.py similarity index 100% rename from utilities/config_io.py rename to mtcontact/utilities/config_io.py diff --git a/utilities/control_utils.py b/mtcontact/utilities/control_utils.py similarity index 100% rename from utilities/control_utils.py rename to mtcontact/utilities/control_utils.py diff --git a/utilities/db_handler.py b/mtcontact/utilities/db_handler.py similarity index 99% rename from utilities/db_handler.py rename to mtcontact/utilities/db_handler.py index c471fb7..5a483aa 100644 --- a/utilities/db_handler.py +++ b/mtcontact/utilities/db_handler.py @@ -3,9 +3,9 @@ import time import logging from datetime import datetime -from utilities.utils import decimal_to_hex -import ui.default_config as config -import globals +from mtcontact.utilities.utils import decimal_to_hex +import mtcontact.ui.default_config as config +import mtcontact.globals as globals def get_table_name(channel): # Construct the table name diff --git a/utilities/input_handlers.py b/mtcontact/utilities/input_handlers.py similarity index 99% rename from utilities/input_handlers.py rename to mtcontact/utilities/input_handlers.py index 34b41ae..2c0ed34 100644 --- a/utilities/input_handlers.py +++ b/mtcontact/utilities/input_handlers.py @@ -3,7 +3,7 @@ import binascii import curses import ipaddress import re -from ui.colors import get_color +from mtcontact.ui.colors import get_color def wrap_text(text, wrap_width): """Wraps text while preserving spaces and breaking long words.""" diff --git a/utilities/interfaces.py b/mtcontact/utilities/interfaces.py similarity index 96% rename from utilities/interfaces.py rename to mtcontact/utilities/interfaces.py index 66c6e7b..03aeace 100644 --- a/utilities/interfaces.py +++ b/mtcontact/utilities/interfaces.py @@ -1,6 +1,6 @@ import logging import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface -import globals +import mtcontact.globals as globals def initialize_interface(args): try: diff --git a/utilities/save_to_radio.py b/mtcontact/utilities/save_to_radio.py similarity index 100% rename from utilities/save_to_radio.py rename to mtcontact/utilities/save_to_radio.py diff --git a/utilities/utils.py b/mtcontact/utilities/utils.py similarity index 97% rename from utilities/utils.py rename to mtcontact/utilities/utils.py index ce8d567..1997c36 100644 --- a/utilities/utils.py +++ b/mtcontact/utilities/utils.py @@ -1,7 +1,7 @@ -import globals +import mtcontact.globals as globals import datetime from meshtastic.protobuf import config_pb2 -import ui.default_config as config +import mtcontact.ui.default_config as config def get_channels(): """Retrieve channels from the node and update globals.channel_list and globals.all_messages.""" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0a20233 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "mtcontact" +version = "1.2.3" +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 = "pdxlocations",email = "you@example.com"} +] +license = "GPL-3.0-only" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "meshtastic (>=2.6.0,<3.0.0)" +] + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.scripts] +mtcontact = "mtcontact.main:start" From 3df012dd69c84dd4eadc02f00cecac25d13961a7 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 26 Mar 2025 21:59:33 -0700 Subject: [PATCH 56/73] add launch.json --- .vscode/launch.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b70e770 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.1.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "cwd": "${workspaceFolder}", + "module": "mtcontact.main", + "args": [] + } + ] +} \ No newline at end of file From 66a9954149e7bb9de863b6e9e75e24a2cdd4b061 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 26 Mar 2025 22:27:03 -0700 Subject: [PATCH 57/73] mtcontact to mcontact --- .vscode/launch.json | 2 +- mtcontact/main.py | 24 ++++++++++++------------ mtcontact/message_handlers/rx_handler.py | 10 +++++----- mtcontact/message_handlers/tx_handler.py | 6 +++--- mtcontact/settings.py | 14 +++++++------- mtcontact/ui/colors.py | 2 +- mtcontact/ui/control_ui.py | 18 +++++++++--------- mtcontact/ui/curses_ui.py | 18 +++++++++--------- mtcontact/ui/dialog.py | 2 +- mtcontact/ui/splash.py | 2 +- mtcontact/ui/user_config.py | 6 +++--- mtcontact/utilities/db_handler.py | 6 +++--- mtcontact/utilities/input_handlers.py | 2 +- mtcontact/utilities/interfaces.py | 2 +- mtcontact/utilities/utils.py | 4 ++-- pyproject.toml | 4 ++-- 16 files changed, 61 insertions(+), 61 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b70e770..f10e079 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "debugpy", "request": "launch", "cwd": "${workspaceFolder}", - "module": "mtcontact.main", + "module": "mcontact.main", "args": [] } ] diff --git a/mtcontact/main.py b/mtcontact/main.py index 51f6fd2..4851967 100644 --- a/mtcontact/main.py +++ b/mtcontact/main.py @@ -18,18 +18,18 @@ import logging import traceback import threading -from mtcontact.utilities.db_handler import init_nodedb, load_messages_from_db -from mtcontact.message_handlers.rx_handler import on_receive -from mtcontact.settings import set_region -from mtcontact.ui.curses_ui import main_ui -from mtcontact.ui.colors import setup_colors -from mtcontact.ui.splash import draw_splash -import mtcontact.ui.default_config as config -from mtcontact.utilities.arg_parser import setup_parser -from mtcontact.utilities.interfaces import initialize_interface -from mtcontact.utilities.input_handlers import get_list_input -from mtcontact.utilities.utils import get_channels, get_node_list, get_nodeNum -import mtcontact.globals as globals +from mcontact.utilities.db_handler import init_nodedb, load_messages_from_db +from mcontact.message_handlers.rx_handler import on_receive +from mcontact.settings import set_region +from mcontact.ui.curses_ui import main_ui +from mcontact.ui.colors import setup_colors +from mcontact.ui.splash import draw_splash +import mcontact.ui.default_config as config +from mcontact.utilities.arg_parser import setup_parser +from mcontact.utilities.interfaces import initialize_interface +from mcontact.utilities.input_handlers import get_list_input +from mcontact.utilities.utils import get_channels, get_node_list, get_nodeNum +import mcontact.globals as globals # Set ncurses compatibility settings os.environ["NCURSES_NO_UTF8_ACS"] = "1" diff --git a/mtcontact/message_handlers/rx_handler.py b/mtcontact/message_handlers/rx_handler.py index b260587..f461f62 100644 --- a/mtcontact/message_handlers/rx_handler.py +++ b/mtcontact/message_handlers/rx_handler.py @@ -1,11 +1,11 @@ import logging import time -from mtcontact.utilities.utils import refresh_node_list +from mcontact.utilities.utils import refresh_node_list from datetime import datetime -from mtcontact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification -from mtcontact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db -import mtcontact.ui.default_config as config -import mtcontact.globals as globals +from mcontact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification +from mcontact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db +import mcontact.ui.default_config as config +import mcontact.globals as globals from datetime import datetime diff --git a/mtcontact/message_handlers/tx_handler.py b/mtcontact/message_handlers/tx_handler.py index e0772c5..0c0cb2b 100644 --- a/mtcontact/message_handlers/tx_handler.py +++ b/mtcontact/message_handlers/tx_handler.py @@ -3,9 +3,9 @@ import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 -from mtcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db -import mtcontact.ui.default_config as config -import mtcontact.globals as globals +from mcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import mcontact.ui.default_config as config +import mcontact.globals as globals ack_naks = {} diff --git a/mtcontact/settings.py b/mtcontact/settings.py index cf6593c..2ba7f94 100644 --- a/mtcontact/settings.py +++ b/mtcontact/settings.py @@ -5,13 +5,13 @@ import logging import sys import traceback -import mtcontact.ui.default_config as config -from mtcontact.utilities.input_handlers import get_list_input -from mtcontact.ui.colors import setup_colors -from mtcontact.ui.splash import draw_splash -from mtcontact.ui.control_ui import set_region, settings_menu -from mtcontact.utilities.arg_parser import setup_parser -from mtcontact.utilities.interfaces import initialize_interface +import mcontact.ui.default_config as config +from mcontact.utilities.input_handlers import get_list_input +from mcontact.ui.colors import setup_colors +from mcontact.ui.splash import draw_splash +from mcontact.ui.control_ui import set_region, settings_menu +from mcontact.utilities.arg_parser import setup_parser +from mcontact.utilities.interfaces import initialize_interface def main(stdscr): diff --git a/mtcontact/ui/colors.py b/mtcontact/ui/colors.py index 3891073..3c579d6 100644 --- a/mtcontact/ui/colors.py +++ b/mtcontact/ui/colors.py @@ -1,5 +1,5 @@ import curses -import mtcontact.ui.default_config as config +import mcontact.ui.default_config as config COLOR_MAP = { "black": curses.COLOR_BLACK, diff --git a/mtcontact/ui/control_ui.py b/mtcontact/ui/control_ui.py index 00630cf..1672771 100644 --- a/mtcontact/ui/control_ui.py +++ b/mtcontact/ui/control_ui.py @@ -5,16 +5,16 @@ import os import re import sys -from mtcontact.utilities.save_to_radio import save_changes -from mtcontact.utilities.config_io import config_export, config_import -from mtcontact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input -from mtcontact.ui.menus import generate_menu_from_protobuf -from mtcontact.ui.colors import get_color -from mtcontact.ui.dialog import dialog -from mtcontact.utilities.control_utils import parse_ini_file, transform_menu_path -from mtcontact.ui.user_config import json_editor +from mcontact.utilities.save_to_radio import save_changes +from mcontact.utilities.config_io import config_export, config_import +from mcontact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input +from mcontact.ui.menus import generate_menu_from_protobuf +from mcontact.ui.colors import get_color +from mcontact.ui.dialog import dialog +from mcontact.utilities.control_utils import parse_ini_file, transform_menu_path +from mcontact.ui.user_config import json_editor -import mtcontact.localisations +import mcontact.localisations # Constants width = 80 diff --git a/mtcontact/ui/curses_ui.py b/mtcontact/ui/curses_ui.py index ca2f559..b27808f 100644 --- a/mtcontact/ui/curses_ui.py +++ b/mtcontact/ui/curses_ui.py @@ -2,14 +2,14 @@ import curses import textwrap import logging import traceback -from mtcontact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list -from mtcontact.settings import settings_menu -from mtcontact.message_handlers.tx_handler import send_message, send_traceroute -from mtcontact.ui.colors import setup_colors, get_color -from mtcontact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived -import mtcontact.ui.default_config as config -import mtcontact.ui.dialog -import mtcontact.globals as globals +from mcontact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list +from mcontact.settings import settings_menu +from mcontact.message_handlers.tx_handler import send_message, send_traceroute +from mcontact.ui.colors import setup_colors, get_color +from mcontact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived +import mcontact.ui.default_config as config +import mcontact.ui.dialog +import mcontact.globals as globals def handle_resize(stdscr, firstrun): global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win @@ -212,7 +212,7 @@ def main_ui(stdscr): elif char == chr(20): send_traceroute() curses.curs_set(0) # Hide cursor - mtcontact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") + mcontact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") curses.curs_set(1) # Show cursor again handle_resize(stdscr, False) diff --git a/mtcontact/ui/dialog.py b/mtcontact/ui/dialog.py index c5a4c7a..0edd950 100644 --- a/mtcontact/ui/dialog.py +++ b/mtcontact/ui/dialog.py @@ -1,5 +1,5 @@ import curses -from mtcontact.ui.colors import get_color +from mcontact.ui.colors import get_color def dialog(stdscr, title, message): height, width = stdscr.getmaxyx() diff --git a/mtcontact/ui/splash.py b/mtcontact/ui/splash.py index 785b5da..fab6739 100644 --- a/mtcontact/ui/splash.py +++ b/mtcontact/ui/splash.py @@ -1,5 +1,5 @@ import curses -from mtcontact.ui.colors import get_color +from mcontact.ui.colors import get_color def draw_splash(stdscr): curses.curs_set(0) diff --git a/mtcontact/ui/user_config.py b/mtcontact/ui/user_config.py index dc10db8..5e0f938 100644 --- a/mtcontact/ui/user_config.py +++ b/mtcontact/ui/user_config.py @@ -1,9 +1,9 @@ import os import json import curses -from mtcontact.ui.colors import get_color, setup_colors, COLOR_MAP -from mtcontact.ui.default_config import format_json_single_line_arrays, loaded_config -from mtcontact.utilities.input_handlers import get_list_input +from mcontact.ui.colors import get_color, setup_colors, COLOR_MAP +from mcontact.ui.default_config import format_json_single_line_arrays, loaded_config +from mcontact.utilities.input_handlers import get_list_input width = 60 save_option_text = "Save Changes" diff --git a/mtcontact/utilities/db_handler.py b/mtcontact/utilities/db_handler.py index 5a483aa..3ca7418 100644 --- a/mtcontact/utilities/db_handler.py +++ b/mtcontact/utilities/db_handler.py @@ -3,9 +3,9 @@ import time import logging from datetime import datetime -from mtcontact.utilities.utils import decimal_to_hex -import mtcontact.ui.default_config as config -import mtcontact.globals as globals +from mcontact.utilities.utils import decimal_to_hex +import mcontact.ui.default_config as config +import mcontact.globals as globals def get_table_name(channel): # Construct the table name diff --git a/mtcontact/utilities/input_handlers.py b/mtcontact/utilities/input_handlers.py index 2c0ed34..e6a12c1 100644 --- a/mtcontact/utilities/input_handlers.py +++ b/mtcontact/utilities/input_handlers.py @@ -3,7 +3,7 @@ import binascii import curses import ipaddress import re -from mtcontact.ui.colors import get_color +from mcontact.ui.colors import get_color def wrap_text(text, wrap_width): """Wraps text while preserving spaces and breaking long words.""" diff --git a/mtcontact/utilities/interfaces.py b/mtcontact/utilities/interfaces.py index 03aeace..5b51eff 100644 --- a/mtcontact/utilities/interfaces.py +++ b/mtcontact/utilities/interfaces.py @@ -1,6 +1,6 @@ import logging import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface -import mtcontact.globals as globals +import mcontact.globals as globals def initialize_interface(args): try: diff --git a/mtcontact/utilities/utils.py b/mtcontact/utilities/utils.py index 1997c36..c51ab27 100644 --- a/mtcontact/utilities/utils.py +++ b/mtcontact/utilities/utils.py @@ -1,7 +1,7 @@ -import mtcontact.globals as globals +import mcontact.globals as globals import datetime from meshtastic.protobuf import config_pb2 -import mtcontact.ui.default_config as config +import mcontact.ui.default_config as config def get_channels(): """Retrieve channels from the node and update globals.channel_list and globals.all_messages.""" diff --git a/pyproject.toml b/pyproject.toml index 0a20233..9e49405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "mtcontact" +name = "mcontact" version = "1.2.3" 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 = [ @@ -18,4 +18,4 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -mtcontact = "mtcontact.main:start" +mcontact = "mcontact.main:start" From c236a386a5633465ee998ced908834017fd81cf4 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 26 Mar 2025 22:30:35 -0700 Subject: [PATCH 58/73] mcontact --- {mtcontact => mcontact}/globals.py | 0 {mtcontact => mcontact}/localisations/en.ini | 0 {mtcontact => mcontact}/main.py | 0 {mtcontact => mcontact}/message_handlers/rx_handler.py | 0 {mtcontact => mcontact}/message_handlers/tx_handler.py | 0 {mtcontact => mcontact}/settings.py | 0 {mtcontact => mcontact}/ui/colors.py | 0 {mtcontact => mcontact}/ui/control_ui.py | 0 {mtcontact => mcontact}/ui/curses_ui.py | 0 {mtcontact => mcontact}/ui/default_config.py | 0 {mtcontact => mcontact}/ui/dialog.py | 0 {mtcontact => mcontact}/ui/menus.py | 0 {mtcontact => mcontact}/ui/splash.py | 0 {mtcontact => mcontact}/ui/user_config.py | 0 {mtcontact => mcontact}/utilities/arg_parser.py | 0 {mtcontact => mcontact}/utilities/config_io.py | 0 {mtcontact => mcontact}/utilities/control_utils.py | 0 {mtcontact => mcontact}/utilities/db_handler.py | 0 {mtcontact => mcontact}/utilities/input_handlers.py | 0 {mtcontact => mcontact}/utilities/interfaces.py | 0 {mtcontact => mcontact}/utilities/save_to_radio.py | 0 {mtcontact => mcontact}/utilities/utils.py | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename {mtcontact => mcontact}/globals.py (100%) rename {mtcontact => mcontact}/localisations/en.ini (100%) rename {mtcontact => mcontact}/main.py (100%) rename {mtcontact => mcontact}/message_handlers/rx_handler.py (100%) rename {mtcontact => mcontact}/message_handlers/tx_handler.py (100%) rename {mtcontact => mcontact}/settings.py (100%) rename {mtcontact => mcontact}/ui/colors.py (100%) rename {mtcontact => mcontact}/ui/control_ui.py (100%) rename {mtcontact => mcontact}/ui/curses_ui.py (100%) rename {mtcontact => mcontact}/ui/default_config.py (100%) rename {mtcontact => mcontact}/ui/dialog.py (100%) rename {mtcontact => mcontact}/ui/menus.py (100%) rename {mtcontact => mcontact}/ui/splash.py (100%) rename {mtcontact => mcontact}/ui/user_config.py (100%) rename {mtcontact => mcontact}/utilities/arg_parser.py (100%) rename {mtcontact => mcontact}/utilities/config_io.py (100%) rename {mtcontact => mcontact}/utilities/control_utils.py (100%) rename {mtcontact => mcontact}/utilities/db_handler.py (100%) rename {mtcontact => mcontact}/utilities/input_handlers.py (100%) rename {mtcontact => mcontact}/utilities/interfaces.py (100%) rename {mtcontact => mcontact}/utilities/save_to_radio.py (100%) rename {mtcontact => mcontact}/utilities/utils.py (100%) diff --git a/mtcontact/globals.py b/mcontact/globals.py similarity index 100% rename from mtcontact/globals.py rename to mcontact/globals.py diff --git a/mtcontact/localisations/en.ini b/mcontact/localisations/en.ini similarity index 100% rename from mtcontact/localisations/en.ini rename to mcontact/localisations/en.ini diff --git a/mtcontact/main.py b/mcontact/main.py similarity index 100% rename from mtcontact/main.py rename to mcontact/main.py diff --git a/mtcontact/message_handlers/rx_handler.py b/mcontact/message_handlers/rx_handler.py similarity index 100% rename from mtcontact/message_handlers/rx_handler.py rename to mcontact/message_handlers/rx_handler.py diff --git a/mtcontact/message_handlers/tx_handler.py b/mcontact/message_handlers/tx_handler.py similarity index 100% rename from mtcontact/message_handlers/tx_handler.py rename to mcontact/message_handlers/tx_handler.py diff --git a/mtcontact/settings.py b/mcontact/settings.py similarity index 100% rename from mtcontact/settings.py rename to mcontact/settings.py diff --git a/mtcontact/ui/colors.py b/mcontact/ui/colors.py similarity index 100% rename from mtcontact/ui/colors.py rename to mcontact/ui/colors.py diff --git a/mtcontact/ui/control_ui.py b/mcontact/ui/control_ui.py similarity index 100% rename from mtcontact/ui/control_ui.py rename to mcontact/ui/control_ui.py diff --git a/mtcontact/ui/curses_ui.py b/mcontact/ui/curses_ui.py similarity index 100% rename from mtcontact/ui/curses_ui.py rename to mcontact/ui/curses_ui.py diff --git a/mtcontact/ui/default_config.py b/mcontact/ui/default_config.py similarity index 100% rename from mtcontact/ui/default_config.py rename to mcontact/ui/default_config.py diff --git a/mtcontact/ui/dialog.py b/mcontact/ui/dialog.py similarity index 100% rename from mtcontact/ui/dialog.py rename to mcontact/ui/dialog.py diff --git a/mtcontact/ui/menus.py b/mcontact/ui/menus.py similarity index 100% rename from mtcontact/ui/menus.py rename to mcontact/ui/menus.py diff --git a/mtcontact/ui/splash.py b/mcontact/ui/splash.py similarity index 100% rename from mtcontact/ui/splash.py rename to mcontact/ui/splash.py diff --git a/mtcontact/ui/user_config.py b/mcontact/ui/user_config.py similarity index 100% rename from mtcontact/ui/user_config.py rename to mcontact/ui/user_config.py diff --git a/mtcontact/utilities/arg_parser.py b/mcontact/utilities/arg_parser.py similarity index 100% rename from mtcontact/utilities/arg_parser.py rename to mcontact/utilities/arg_parser.py diff --git a/mtcontact/utilities/config_io.py b/mcontact/utilities/config_io.py similarity index 100% rename from mtcontact/utilities/config_io.py rename to mcontact/utilities/config_io.py diff --git a/mtcontact/utilities/control_utils.py b/mcontact/utilities/control_utils.py similarity index 100% rename from mtcontact/utilities/control_utils.py rename to mcontact/utilities/control_utils.py diff --git a/mtcontact/utilities/db_handler.py b/mcontact/utilities/db_handler.py similarity index 100% rename from mtcontact/utilities/db_handler.py rename to mcontact/utilities/db_handler.py diff --git a/mtcontact/utilities/input_handlers.py b/mcontact/utilities/input_handlers.py similarity index 100% rename from mtcontact/utilities/input_handlers.py rename to mcontact/utilities/input_handlers.py diff --git a/mtcontact/utilities/interfaces.py b/mcontact/utilities/interfaces.py similarity index 100% rename from mtcontact/utilities/interfaces.py rename to mcontact/utilities/interfaces.py diff --git a/mtcontact/utilities/save_to_radio.py b/mcontact/utilities/save_to_radio.py similarity index 100% rename from mtcontact/utilities/save_to_radio.py rename to mcontact/utilities/save_to_radio.py diff --git a/mtcontact/utilities/utils.py b/mcontact/utilities/utils.py similarity index 100% rename from mtcontact/utilities/utils.py rename to mcontact/utilities/utils.py From 25240f211b15e4e6ee9bef911bf63e7093594c68 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 27 Mar 2025 20:55:50 -0700 Subject: [PATCH 59/73] add release.yaml --- .github/workflows/release.yaml | 143 ++++++++++++++++++ mtcontact/message_handlers/tx_handler.py | 181 +++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 mtcontact/message_handlers/tx_handler.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..4a82287 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,143 @@ +name: release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+a[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+b[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" + +env: + PACKAGE_NAME: "mcontact" + OWNER: "pdxlocations" + +jobs: + details: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.release.outputs.new_version }} + suffix: ${{ steps.release.outputs.suffix }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: actions/checkout@v2 + + - name: Extract tag and Details + id: release + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + TAG_NAME=${GITHUB_REF#refs/tags/} + NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}') + SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "") + echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT" + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "Version is $NEW_VERSION" + echo "Suffix is $SUFFIX" + echo "Tag name is $TAG_NAME" + else + echo "No tag found" + exit 1 + fi + + check_pypi: + needs: details + runs-on: ubuntu-latest + steps: + - name: Fetch information from PyPI + run: | + response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}") + latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") + if [ -z "$latest_previous_version" ]; then + echo "Package not found on PyPI." + latest_previous_version="0.0.0" + fi + echo "Latest version on PyPI: $latest_previous_version" + echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV + + - name: Compare versions and exit if not newer + run: | + NEW_VERSION=${{ needs.details.outputs.new_version }} + LATEST_VERSION=$latest_previous_version + if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then + echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI." + exit 1 + else + echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI." + fi + + setup_and_build: + needs: [details, check_pypi] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Set project version with Poetry + run: | + poetry version ${{ needs.details.outputs.new_version }} + + - name: Install dependencies + run: poetry install --sync --no-interaction + + - name: Build source and wheel distribution + run: | + poetry build + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: dist + path: dist/ + + pypi_publish: + name: Upload release to PyPI + needs: [setup_and_build, details] + runs-on: ubuntu-latest + environment: + name: release + permissions: + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github_release: + name: Create GitHub Release + needs: [setup_and_build, details] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout Code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + id: create_release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes diff --git a/mtcontact/message_handlers/tx_handler.py b/mtcontact/message_handlers/tx_handler.py new file mode 100644 index 0000000..7fe9ed1 --- /dev/null +++ b/mtcontact/message_handlers/tx_handler.py @@ -0,0 +1,181 @@ +from datetime import datetime +import google.protobuf.json_format +from meshtastic import BROADCAST_NUM +from meshtastic.protobuf import mesh_pb2, portnums_pb2 + +from mcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import mcontact.ui.default_config as config +import mcontact.globals as globals + +ack_naks = {} + +# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention +# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 +def onAckNak(packet): + from mcontact.ui.curses_ui import draw_messages_window + request = packet['decoded']['requestId'] + if(request not in ack_naks): + return + + acknak = ack_naks.pop(request) + message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1] + + confirm_string = " " + ack_type = None + if(packet['decoded']['routing']['errorReason'] == "NONE"): + if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK + confirm_string = config.ack_implicit_str + ack_type = "Implicit" + else: + confirm_string = config.ack_str + ack_type = "Ack" + else: + confirm_string = config.nak_str + ack_type = "Nak" + + globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message) + + update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type) + + channel_number = globals.channel_list.index(acknak['channel']) + if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: + draw_messages_window() + +def on_response_traceroute(packet): + """on response for trace route""" + from mcontact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification + + refresh_channels = False + refresh_messages = False + + UNK_SNR = -128 # Value representing unknown SNR + + route_discovery = mesh_pb2.RouteDiscovery() + route_discovery.ParseFromString(packet["decoded"]["payload"]) + msg_dict = google.protobuf.json_format.MessageToDict(route_discovery) + + msg_str = "Traceroute to:\n" + + route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response + + # SNR list should have one more entry than the route, as the final destination adds its SNR also + lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"]) + snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1 + if lenTowards > 0: # Loop through hops in route and add SNR if available + for idx, node_num in enumerate(msg_dict["route"]): + route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ + + " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)" + + # End with origin of response + route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \ + + " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)" + + msg_str += route_str + "\n" # Print the route towards destination + + # Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection) + lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"]) + backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1 + if backValid: + msg_str += "Back:\n" + route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response + + if lenBack > 0: # Loop through hops in routeBack and add SNR if available + for idx, node_num in enumerate(msg_dict["routeBack"]): + route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ + + " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)" + + # End with destination of response (us) + route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \ + + " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)" + + msg_str += route_str + "\n" # Print the route back to us + + if(packet['from'] not in globals.channel_list): + globals.channel_list.append(packet['from']) + refresh_channels = True + + if(is_chat_archived(packet['from'])): + update_node_info_in_db(packet['from'], chat_archived=False) + + channel_number = globals.channel_list.index(packet['from']) + + if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: + refresh_messages = True + else: + add_notification(channel_number) + refresh_channels = True + + message_from_string = get_name_from_database(packet['from'], type='short') + ":\n" + + if globals.channel_list[channel_number] not in globals.all_messages: + globals.all_messages[globals.channel_list[channel_number]] = [] + globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str)) + + if refresh_channels: + draw_channel_list() + if refresh_messages: + draw_messages_window(True) + save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str) + + +def send_message(message, destination=BROADCAST_NUM, channel=0): + myid = globals.myNodeNum + send_on_channel = 0 + channel_id = globals.channel_list[channel] + if isinstance(channel_id, int): + send_on_channel = 0 + destination = channel_id + elif isinstance(channel_id, str): + send_on_channel = channel + + sent_message_data = globals.interface.sendText( + text=message, + destinationId=destination, + wantAck=True, + wantResponse=False, + onResponse=onAckNak, + channelIndex=send_on_channel, + ) + + # Add sent message to the messages dictionary + if channel_id not in globals.all_messages: + globals.all_messages[channel_id] = [] + + # Handle timestamp logic + current_timestamp = int(datetime.now().timestamp()) # Get current timestamp + current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00') + + # Retrieve the last timestamp if available + channel_messages = globals.all_messages[channel_id] + if channel_messages: + # Check the last entry for a timestamp + for entry in reversed(channel_messages): + if entry[0].startswith("--"): + last_hour = entry[0].strip("- ").strip() + break + else: + last_hour = None + else: + last_hour = None + + # Add a new timestamp if it's a new hour + if last_hour != current_hour: + globals.all_messages[channel_id].append((f"-- {current_hour} --", "")) + + globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message)) + + timestamp = save_message_to_db(channel_id, myid, message) + + ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp} + +def send_traceroute(): + r = mesh_pb2.RouteDiscovery() + globals.interface.sendData( + r, + destinationId=globals.node_list[globals.selected_node], + portNum=portnums_pb2.PortNum.TRACEROUTE_APP, + wantResponse=True, + onResponse=on_response_traceroute, + channelIndex=0, + hopLimit=3, + ) From 37c5a7dbc392a4ed8e251329653fa4767803f84e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 27 Mar 2025 21:07:15 -0700 Subject: [PATCH 60/73] fix some imports --- mcontact/message_handlers/tx_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcontact/message_handlers/tx_handler.py b/mcontact/message_handlers/tx_handler.py index 0c0cb2b..7fe9ed1 100644 --- a/mcontact/message_handlers/tx_handler.py +++ b/mcontact/message_handlers/tx_handler.py @@ -12,7 +12,7 @@ ack_naks = {} # Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention # See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 def onAckNak(packet): - from ui.curses_ui import draw_messages_window + from mcontact.ui.curses_ui import draw_messages_window request = packet['decoded']['requestId'] if(request not in ack_naks): return @@ -43,7 +43,7 @@ def onAckNak(packet): def on_response_traceroute(packet): """on response for trace route""" - from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification + from mcontact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification refresh_channels = False refresh_messages = False From 2960553fef64694a562169dde9603b5f07725b5b Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 27 Mar 2025 21:08:26 -0700 Subject: [PATCH 61/73] rm extra file --- mtcontact/message_handlers/tx_handler.py | 181 ----------------------- 1 file changed, 181 deletions(-) delete mode 100644 mtcontact/message_handlers/tx_handler.py diff --git a/mtcontact/message_handlers/tx_handler.py b/mtcontact/message_handlers/tx_handler.py deleted file mode 100644 index 7fe9ed1..0000000 --- a/mtcontact/message_handlers/tx_handler.py +++ /dev/null @@ -1,181 +0,0 @@ -from datetime import datetime -import google.protobuf.json_format -from meshtastic import BROADCAST_NUM -from meshtastic.protobuf import mesh_pb2, portnums_pb2 - -from mcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db -import mcontact.ui.default_config as config -import mcontact.globals as globals - -ack_naks = {} - -# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention -# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 -def onAckNak(packet): - from mcontact.ui.curses_ui import draw_messages_window - request = packet['decoded']['requestId'] - if(request not in ack_naks): - return - - acknak = ack_naks.pop(request) - message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1] - - confirm_string = " " - ack_type = None - if(packet['decoded']['routing']['errorReason'] == "NONE"): - if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK - confirm_string = config.ack_implicit_str - ack_type = "Implicit" - else: - confirm_string = config.ack_str - ack_type = "Ack" - else: - confirm_string = config.nak_str - ack_type = "Nak" - - globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message) - - update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type) - - channel_number = globals.channel_list.index(acknak['channel']) - if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: - draw_messages_window() - -def on_response_traceroute(packet): - """on response for trace route""" - from mcontact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification - - refresh_channels = False - refresh_messages = False - - UNK_SNR = -128 # Value representing unknown SNR - - route_discovery = mesh_pb2.RouteDiscovery() - route_discovery.ParseFromString(packet["decoded"]["payload"]) - msg_dict = google.protobuf.json_format.MessageToDict(route_discovery) - - msg_str = "Traceroute to:\n" - - route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response - - # SNR list should have one more entry than the route, as the final destination adds its SNR also - lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"]) - snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1 - if lenTowards > 0: # Loop through hops in route and add SNR if available - for idx, node_num in enumerate(msg_dict["route"]): - route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ - + " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)" - - # End with origin of response - route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \ - + " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)" - - msg_str += route_str + "\n" # Print the route towards destination - - # Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection) - lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"]) - backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1 - if backValid: - msg_str += "Back:\n" - route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response - - if lenBack > 0: # Loop through hops in routeBack and add SNR if available - for idx, node_num in enumerate(msg_dict["routeBack"]): - route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \ - + " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)" - - # End with destination of response (us) - route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \ - + " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)" - - msg_str += route_str + "\n" # Print the route back to us - - if(packet['from'] not in globals.channel_list): - globals.channel_list.append(packet['from']) - refresh_channels = True - - if(is_chat_archived(packet['from'])): - update_node_info_in_db(packet['from'], chat_archived=False) - - channel_number = globals.channel_list.index(packet['from']) - - if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]: - refresh_messages = True - else: - add_notification(channel_number) - refresh_channels = True - - message_from_string = get_name_from_database(packet['from'], type='short') + ":\n" - - if globals.channel_list[channel_number] not in globals.all_messages: - globals.all_messages[globals.channel_list[channel_number]] = [] - globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string}", msg_str)) - - if refresh_channels: - draw_channel_list() - if refresh_messages: - draw_messages_window(True) - save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str) - - -def send_message(message, destination=BROADCAST_NUM, channel=0): - myid = globals.myNodeNum - send_on_channel = 0 - channel_id = globals.channel_list[channel] - if isinstance(channel_id, int): - send_on_channel = 0 - destination = channel_id - elif isinstance(channel_id, str): - send_on_channel = channel - - sent_message_data = globals.interface.sendText( - text=message, - destinationId=destination, - wantAck=True, - wantResponse=False, - onResponse=onAckNak, - channelIndex=send_on_channel, - ) - - # Add sent message to the messages dictionary - if channel_id not in globals.all_messages: - globals.all_messages[channel_id] = [] - - # Handle timestamp logic - current_timestamp = int(datetime.now().timestamp()) # Get current timestamp - current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00') - - # Retrieve the last timestamp if available - channel_messages = globals.all_messages[channel_id] - if channel_messages: - # Check the last entry for a timestamp - for entry in reversed(channel_messages): - if entry[0].startswith("--"): - last_hour = entry[0].strip("- ").strip() - break - else: - last_hour = None - else: - last_hour = None - - # Add a new timestamp if it's a new hour - if last_hour != current_hour: - globals.all_messages[channel_id].append((f"-- {current_hour} --", "")) - - globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message)) - - timestamp = save_message_to_db(channel_id, myid, message) - - ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp} - -def send_traceroute(): - r = mesh_pb2.RouteDiscovery() - globals.interface.sendData( - r, - destinationId=globals.node_list[globals.selected_node], - portNum=portnums_pb2.PortNum.TRACEROUTE_APP, - wantResponse=True, - onResponse=on_response_traceroute, - channelIndex=0, - hopLimit=3, - ) From 92a30ad8b0d7c74ea845b19ef1b61a3f9f77e237 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Thu, 27 Mar 2025 21:14:51 -0700 Subject: [PATCH 62/73] flix user config location --- mcontact/ui/user_config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mcontact/ui/user_config.py b/mcontact/ui/user_config.py index 5e0f938..9a8f586 100644 --- a/mcontact/ui/user_config.py +++ b/mcontact/ui/user_config.py @@ -196,7 +196,10 @@ def json_editor(stdscr): menu_path = ["App Settings"] selected_index = 0 # Track the selected option - file_path = "config.json" + script_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) + file_path = os.path.join(parent_dir, "config.json") + # file_path = "config.json" show_save_option = True # Always show the Save button # Ensure the file exists From 4cd7c4e24d479f1f0e1d791373ef5e5a006804a7 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sun, 30 Mar 2025 16:28:26 -0700 Subject: [PATCH 63/73] change email --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9e49405..3f9fcfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "mcontact" -version = "1.2.3" +version = "1.3.0" 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 = "pdxlocations",email = "you@example.com"} + {name = "Ben Lipsey",email = "ben@pdxlocations.com"} ] license = "GPL-3.0-only" readme = "README.md" From 2b0f6515af62071bc8b8182c33bff60ac4c058ba Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Sun, 30 Mar 2025 19:15:59 -0700 Subject: [PATCH 64/73] update workflow --- .github/workflows/release.yaml | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4a82287..8c2c5f7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -70,7 +70,7 @@ jobs: needs: [details, check_pypi] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 @@ -94,7 +94,7 @@ jobs: poetry build - name: Upload artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dist path: dist/ @@ -109,7 +109,7 @@ jobs: id-token: write steps: - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: dist path: dist/ @@ -125,12 +125,12 @@ jobs: contents: write steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Download artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: dist path: dist/ diff --git a/pyproject.toml b/pyproject.toml index 3f9fcfd..96e1754 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = "GPL-3.0-only" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.9,<3.14" dependencies = [ "meshtastic (>=2.6.0,<3.0.0)" ] From db8496b2e3f411423b84130361931e91f1150e08 Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Mon, 31 Mar 2025 19:23:15 -0500 Subject: [PATCH 65/73] Fix updating data on existing nodes Since 4bc1654 changed the defaults of the parameters to update_node_info_in_db(), any call to that funciton that didn't specify a value for chat_archived would cause chat_archived to be set to 0, because 0 is not None, we wouldn't preserve the existing value stored in the DB. Update to use None paramters so we can tell what the caller specified and did not specify again. --- utilities/db_handler.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/utilities/db_handler.py b/utilities/db_handler.py index c471fb7..8368f38 100644 --- a/utilities/db_handler.py +++ b/utilities/db_handler.py @@ -190,21 +190,15 @@ def maybe_store_nodeinfo_in_db(packet): except Exception as e: logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}") -def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model="UNSET", is_licensed=0, role="CLIENT", public_key="", chat_archived=0): +def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None): """Update or insert node information into the database, preserving unchanged fields.""" try: ensure_node_table_exists() # Ensure the table exists before any operation - if long_name == None: - long_name = "Meshtastic " + str(decimal_to_hex(user_id)[-4:]) - if short_name == None: - short_name = str(decimal_to_hex(user_id)[-4:]) - with sqlite3.connect(config.db_file_path) as db_connection: db_cursor = db_connection.cursor() table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names - table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')] if "chat_archived" not in table_columns: update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER" @@ -225,6 +219,14 @@ def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model="U public_key = public_key if public_key is not None else existing_public_key chat_archived = chat_archived if chat_archived is not None else existing_chat_archived + long_name = long_name if long_name is not None else "Meshtastic " + str(decimal_to_hex(user_id)[-4:]) + short_name = short_name if short_name is not None else str(decimal_to_hex(user_id)[-4:]) + hw_model = hw_model if hw_model is not None else "UNSET" + is_licensed = is_licensed if is_licensed is not None else 0 + role = role if role is not None else "CLIENT" + public_key = public_key if public_key is not None else "" + chat_archived = chat_archived if chat_archived is not None else 0 + # Upsert logic upsert_query = f''' INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived) From d9088ccd68dafb2fd5049300f1413eecddf8b9a8 Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Mon, 31 Mar 2025 21:29:24 -0500 Subject: [PATCH 66/73] Add favorite node support Press Ctrl+F to favorite/unfavorite a node. Favorite nodes always appear at the top of the node list --- ui/curses_ui.py | 34 +++++++++++++++++++++++++++++++++- utilities/utils.py | 1 + 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 7547cfd..7687896 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -4,6 +4,7 @@ import logging import traceback from utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list from settings import settings_menu +from utilities.input_handlers import get_list_input from message_handlers.tx_handler import send_message, send_traceroute from ui.colors import setup_colors, get_color from utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived @@ -293,10 +294,41 @@ def main_ui(stdscr): draw_channel_list() draw_messages_window() + # ^/ elif char == chr(31): if(globals.current_window == 2 or globals.current_window == 0): search(globals.current_window) + # ^F + elif char == chr(6): + if globals.current_window == 2: + selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]] + if 'isFavorite' not in selectedNode or selectedNode['isFavorite'] == False: + curses.curs_set(0) + confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Favorite?", "no", ["yes", "no"]) + if confirmation == "yes": + globals.interface.localNode.setFavorite(globals.node_list[globals.selected_node]) + # Maybe we shouldn't be modifying the nodedb, but maybe it should update itself + globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = True + + refresh_node_list() + draw_node_list() + draw_channel_list() + draw_messages_window() + + else: + curses.curs_set(0) + confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Favorites?", "no", ["yes", "no"]) + if confirmation == "yes": + globals.interface.localNode.removeFavorite(globals.node_list[globals.selected_node]) + # Maybe we shouldn't be modifying the nodedb, but maybe it should update itself + globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = False + + refresh_node_list() + draw_node_list() + draw_channel_list() + draw_messages_window() + else: # Append typed character to input text if(isinstance(char, str)): @@ -618,7 +650,7 @@ def draw_node_details(): draw_centered_text_field(function_win, nodestr, 0, get_color("commands")) def draw_help(): - cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat"] + cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat", " ^f = Favorite"] function_str = "" for s in cmds: if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2): diff --git a/utilities/utils.py b/utilities/utils.py index ce8d567..d92d17f 100644 --- a/utilities/utils.py +++ b/utilities/utils.py @@ -48,6 +48,7 @@ def get_node_list(): else: return node sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort) + sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True) node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num] return [my_node_num] + node_list # Ensuring your node is always first return [] From 8ff55c3de99839180588dc47356e6173561e6bff Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Mon, 31 Mar 2025 21:56:23 -0500 Subject: [PATCH 67/73] Add ignore node support Press Ctrl+G to ignore/unignore a node. --- ui/curses_ui.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/ui/curses_ui.py b/ui/curses_ui.py index 7687896..69bc960 100644 --- a/ui/curses_ui.py +++ b/ui/curses_ui.py @@ -303,8 +303,10 @@ def main_ui(stdscr): elif char == chr(6): if globals.current_window == 2: selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]] + + curses.curs_set(0) + if 'isFavorite' not in selectedNode or selectedNode['isFavorite'] == False: - curses.curs_set(0) confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Favorite?", "no", ["yes", "no"]) if confirmation == "yes": globals.interface.localNode.setFavorite(globals.node_list[globals.selected_node]) @@ -312,12 +314,8 @@ def main_ui(stdscr): globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = True refresh_node_list() - draw_node_list() - draw_channel_list() - draw_messages_window() else: - curses.curs_set(0) confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Favorites?", "no", ["yes", "no"]) if confirmation == "yes": globals.interface.localNode.removeFavorite(globals.node_list[globals.selected_node]) @@ -325,9 +323,27 @@ def main_ui(stdscr): globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = False refresh_node_list() - draw_node_list() - draw_channel_list() - draw_messages_window() + + handle_resize(stdscr, False) + + elif char == chr(7): + if globals.current_window == 2: + selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]] + + curses.curs_set(0) + + if 'isIgnored' not in selectedNode or selectedNode['isIgnored'] == False: + confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Ignored?", "no", ["yes", "no"]) + if confirmation == "yes": + globals.interface.localNode.setIgnored(globals.node_list[globals.selected_node]) + globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = True + else: + confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Ignored?", "no", ["yes", "no"]) + if confirmation == "yes": + globals.interface.localNode.removeIgnored(globals.node_list[globals.selected_node]) + globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = False + + handle_resize(stdscr, False) else: # Append typed character to input text @@ -650,7 +666,7 @@ def draw_node_details(): draw_centered_text_field(function_win, nodestr, 0, get_color("commands")) def draw_help(): - cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat", " ^f = Favorite"] + cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat", " ^f = Favorite", " ^g = Ignore"] function_str = "" for s in cmds: if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2): From 372204a684b4d3fc32025c2ed3173ff4c9746e7e Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 1 Apr 2025 14:57:51 -0700 Subject: [PATCH 68/73] mcontact -> contact --- .github/workflows/release.yaml | 2 +- .vscode/launch.json | 2 +- mcontact/main.py | 24 ++++++++++++------------ mcontact/message_handlers/rx_handler.py | 10 +++++----- mcontact/message_handlers/tx_handler.py | 10 +++++----- mcontact/settings.py | 14 +++++++------- mcontact/ui/colors.py | 2 +- mcontact/ui/control_ui.py | 18 +++++++++--------- mcontact/ui/curses_ui.py | 18 +++++++++--------- mcontact/ui/dialog.py | 2 +- mcontact/ui/splash.py | 2 +- mcontact/ui/user_config.py | 6 +++--- mcontact/utilities/db_handler.py | 6 +++--- mcontact/utilities/input_handlers.py | 2 +- mcontact/utilities/interfaces.py | 2 +- mcontact/utilities/utils.py | 4 ++-- pyproject.toml | 4 ++-- 17 files changed, 64 insertions(+), 64 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8c2c5f7..700c750 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,7 @@ on: - "[0-9]+.[0-9]+.[0-9]+rc[0-9]+" env: - PACKAGE_NAME: "mcontact" + PACKAGE_NAME: "contact" OWNER: "pdxlocations" jobs: diff --git a/.vscode/launch.json b/.vscode/launch.json index f10e079..6d8c679 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ "type": "debugpy", "request": "launch", "cwd": "${workspaceFolder}", - "module": "mcontact.main", + "module": "contact.main", "args": [] } ] diff --git a/mcontact/main.py b/mcontact/main.py index 4851967..9b861bd 100644 --- a/mcontact/main.py +++ b/mcontact/main.py @@ -18,18 +18,18 @@ import logging import traceback import threading -from mcontact.utilities.db_handler import init_nodedb, load_messages_from_db -from mcontact.message_handlers.rx_handler import on_receive -from mcontact.settings import set_region -from mcontact.ui.curses_ui import main_ui -from mcontact.ui.colors import setup_colors -from mcontact.ui.splash import draw_splash -import mcontact.ui.default_config as config -from mcontact.utilities.arg_parser import setup_parser -from mcontact.utilities.interfaces import initialize_interface -from mcontact.utilities.input_handlers import get_list_input -from mcontact.utilities.utils import get_channels, get_node_list, get_nodeNum -import mcontact.globals as globals +from contact.utilities.db_handler import init_nodedb, load_messages_from_db +from contact.message_handlers.rx_handler import on_receive +from contact.settings import set_region +from contact.ui.curses_ui import main_ui +from contact.ui.colors import setup_colors +from contact.ui.splash import draw_splash +import contact.ui.default_config as config +from contact.utilities.arg_parser import setup_parser +from contact.utilities.interfaces import initialize_interface +from contact.utilities.input_handlers import get_list_input +from contact.utilities.utils import get_channels, get_node_list, get_nodeNum +import contact.globals as globals # Set ncurses compatibility settings os.environ["NCURSES_NO_UTF8_ACS"] = "1" diff --git a/mcontact/message_handlers/rx_handler.py b/mcontact/message_handlers/rx_handler.py index f461f62..74cc5f9 100644 --- a/mcontact/message_handlers/rx_handler.py +++ b/mcontact/message_handlers/rx_handler.py @@ -1,11 +1,11 @@ import logging import time -from mcontact.utilities.utils import refresh_node_list +from contact.utilities.utils import refresh_node_list from datetime import datetime -from mcontact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification -from mcontact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db -import mcontact.ui.default_config as config -import mcontact.globals as globals +from contact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification +from contact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db +import contact.ui.default_config as config +import contact.globals as globals from datetime import datetime diff --git a/mcontact/message_handlers/tx_handler.py b/mcontact/message_handlers/tx_handler.py index 7fe9ed1..45cfc13 100644 --- a/mcontact/message_handlers/tx_handler.py +++ b/mcontact/message_handlers/tx_handler.py @@ -3,16 +3,16 @@ import google.protobuf.json_format from meshtastic import BROADCAST_NUM from meshtastic.protobuf import mesh_pb2, portnums_pb2 -from mcontact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db -import mcontact.ui.default_config as config -import mcontact.globals as globals +from contact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db +import contact.ui.default_config as config +import contact.globals as globals ack_naks = {} # Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention # See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 def onAckNak(packet): - from mcontact.ui.curses_ui import draw_messages_window + from contact.ui.curses_ui import draw_messages_window request = packet['decoded']['requestId'] if(request not in ack_naks): return @@ -43,7 +43,7 @@ def onAckNak(packet): def on_response_traceroute(packet): """on response for trace route""" - from mcontact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification + from contact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification refresh_channels = False refresh_messages = False diff --git a/mcontact/settings.py b/mcontact/settings.py index 2ba7f94..c0ee3ae 100644 --- a/mcontact/settings.py +++ b/mcontact/settings.py @@ -5,13 +5,13 @@ import logging import sys import traceback -import mcontact.ui.default_config as config -from mcontact.utilities.input_handlers import get_list_input -from mcontact.ui.colors import setup_colors -from mcontact.ui.splash import draw_splash -from mcontact.ui.control_ui import set_region, settings_menu -from mcontact.utilities.arg_parser import setup_parser -from mcontact.utilities.interfaces import initialize_interface +import contact.ui.default_config as config +from contact.utilities.input_handlers import get_list_input +from contact.ui.colors import setup_colors +from contact.ui.splash import draw_splash +from contact.ui.control_ui import set_region, settings_menu +from contact.utilities.arg_parser import setup_parser +from contact.utilities.interfaces import initialize_interface def main(stdscr): diff --git a/mcontact/ui/colors.py b/mcontact/ui/colors.py index 3c579d6..cb8c064 100644 --- a/mcontact/ui/colors.py +++ b/mcontact/ui/colors.py @@ -1,5 +1,5 @@ import curses -import mcontact.ui.default_config as config +import contact.ui.default_config as config COLOR_MAP = { "black": curses.COLOR_BLACK, diff --git a/mcontact/ui/control_ui.py b/mcontact/ui/control_ui.py index 1672771..b3c5b9e 100644 --- a/mcontact/ui/control_ui.py +++ b/mcontact/ui/control_ui.py @@ -5,16 +5,16 @@ import os import re import sys -from mcontact.utilities.save_to_radio import save_changes -from mcontact.utilities.config_io import config_export, config_import -from mcontact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input -from mcontact.ui.menus import generate_menu_from_protobuf -from mcontact.ui.colors import get_color -from mcontact.ui.dialog import dialog -from mcontact.utilities.control_utils import parse_ini_file, transform_menu_path -from mcontact.ui.user_config import json_editor +from contact.utilities.save_to_radio import save_changes +from contact.utilities.config_io import config_export, config_import +from contact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input +from contact.ui.menus import generate_menu_from_protobuf +from contact.ui.colors import get_color +from contact.ui.dialog import dialog +from contact.utilities.control_utils import parse_ini_file, transform_menu_path +from contact.ui.user_config import json_editor -import mcontact.localisations +import contact.localisations # Constants width = 80 diff --git a/mcontact/ui/curses_ui.py b/mcontact/ui/curses_ui.py index b27808f..c71a172 100644 --- a/mcontact/ui/curses_ui.py +++ b/mcontact/ui/curses_ui.py @@ -2,14 +2,14 @@ import curses import textwrap import logging import traceback -from mcontact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list -from mcontact.settings import settings_menu -from mcontact.message_handlers.tx_handler import send_message, send_traceroute -from mcontact.ui.colors import setup_colors, get_color -from mcontact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived -import mcontact.ui.default_config as config -import mcontact.ui.dialog -import mcontact.globals as globals +from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list +from contact.settings import settings_menu +from contact.message_handlers.tx_handler import send_message, send_traceroute +from contact.ui.colors import setup_colors, get_color +from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived +import contact.ui.default_config as config +import contact.ui.dialog +import contact.globals as globals def handle_resize(stdscr, firstrun): global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win @@ -212,7 +212,7 @@ def main_ui(stdscr): elif char == chr(20): send_traceroute() curses.curs_set(0) # Hide cursor - mcontact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") + contact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") curses.curs_set(1) # Show cursor again handle_resize(stdscr, False) diff --git a/mcontact/ui/dialog.py b/mcontact/ui/dialog.py index 0edd950..ea39b43 100644 --- a/mcontact/ui/dialog.py +++ b/mcontact/ui/dialog.py @@ -1,5 +1,5 @@ import curses -from mcontact.ui.colors import get_color +from contact.ui.colors import get_color def dialog(stdscr, title, message): height, width = stdscr.getmaxyx() diff --git a/mcontact/ui/splash.py b/mcontact/ui/splash.py index fab6739..403bc34 100644 --- a/mcontact/ui/splash.py +++ b/mcontact/ui/splash.py @@ -1,5 +1,5 @@ import curses -from mcontact.ui.colors import get_color +from contact.ui.colors import get_color def draw_splash(stdscr): curses.curs_set(0) diff --git a/mcontact/ui/user_config.py b/mcontact/ui/user_config.py index 9a8f586..eb4e9b6 100644 --- a/mcontact/ui/user_config.py +++ b/mcontact/ui/user_config.py @@ -1,9 +1,9 @@ import os import json import curses -from mcontact.ui.colors import get_color, setup_colors, COLOR_MAP -from mcontact.ui.default_config import format_json_single_line_arrays, loaded_config -from mcontact.utilities.input_handlers import get_list_input +from contact.ui.colors import get_color, setup_colors, COLOR_MAP +from contact.ui.default_config import format_json_single_line_arrays, loaded_config +from contact.utilities.input_handlers import get_list_input width = 60 save_option_text = "Save Changes" diff --git a/mcontact/utilities/db_handler.py b/mcontact/utilities/db_handler.py index 3ca7418..ebd588b 100644 --- a/mcontact/utilities/db_handler.py +++ b/mcontact/utilities/db_handler.py @@ -3,9 +3,9 @@ import time import logging from datetime import datetime -from mcontact.utilities.utils import decimal_to_hex -import mcontact.ui.default_config as config -import mcontact.globals as globals +from contact.utilities.utils import decimal_to_hex +import contact.ui.default_config as config +import contact.globals as globals def get_table_name(channel): # Construct the table name diff --git a/mcontact/utilities/input_handlers.py b/mcontact/utilities/input_handlers.py index e6a12c1..847b9f0 100644 --- a/mcontact/utilities/input_handlers.py +++ b/mcontact/utilities/input_handlers.py @@ -3,7 +3,7 @@ import binascii import curses import ipaddress import re -from mcontact.ui.colors import get_color +from contact.ui.colors import get_color def wrap_text(text, wrap_width): """Wraps text while preserving spaces and breaking long words.""" diff --git a/mcontact/utilities/interfaces.py b/mcontact/utilities/interfaces.py index 5b51eff..6cb0918 100644 --- a/mcontact/utilities/interfaces.py +++ b/mcontact/utilities/interfaces.py @@ -1,6 +1,6 @@ import logging import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface -import mcontact.globals as globals +import contact.globals as globals def initialize_interface(args): try: diff --git a/mcontact/utilities/utils.py b/mcontact/utilities/utils.py index c51ab27..6392899 100644 --- a/mcontact/utilities/utils.py +++ b/mcontact/utilities/utils.py @@ -1,7 +1,7 @@ -import mcontact.globals as globals +import contact.globals as globals import datetime from meshtastic.protobuf import config_pb2 -import mcontact.ui.default_config as config +import contact.ui.default_config as config def get_channels(): """Retrieve channels from the node and update globals.channel_list and globals.all_messages.""" diff --git a/pyproject.toml b/pyproject.toml index 96e1754..3798286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "mcontact" +name = "contact" version = "1.3.0" 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 = [ @@ -18,4 +18,4 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -mcontact = "mcontact.main:start" +contact = "contact.main:start" From ec0554df14671812f30dbf14482fbffe7342cbb0 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 1 Apr 2025 14:58:35 -0700 Subject: [PATCH 69/73] working changes --- {mcontact => contact}/globals.py | 0 {mcontact => contact}/localisations/en.ini | 0 {mcontact => contact}/main.py | 0 {mcontact => contact}/message_handlers/rx_handler.py | 0 {mcontact => contact}/message_handlers/tx_handler.py | 0 {mcontact => contact}/settings.py | 0 {mcontact => contact}/ui/colors.py | 0 {mcontact => contact}/ui/control_ui.py | 0 {mcontact => contact}/ui/curses_ui.py | 0 {mcontact => contact}/ui/default_config.py | 0 {mcontact => contact}/ui/dialog.py | 0 {mcontact => contact}/ui/menus.py | 0 {mcontact => contact}/ui/splash.py | 0 {mcontact => contact}/ui/user_config.py | 0 {mcontact => contact}/utilities/arg_parser.py | 0 {mcontact => contact}/utilities/config_io.py | 0 {mcontact => contact}/utilities/control_utils.py | 0 {mcontact => contact}/utilities/db_handler.py | 0 {mcontact => contact}/utilities/input_handlers.py | 0 {mcontact => contact}/utilities/interfaces.py | 0 {mcontact => contact}/utilities/save_to_radio.py | 0 {mcontact => contact}/utilities/utils.py | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename {mcontact => contact}/globals.py (100%) rename {mcontact => contact}/localisations/en.ini (100%) rename {mcontact => contact}/main.py (100%) rename {mcontact => contact}/message_handlers/rx_handler.py (100%) rename {mcontact => contact}/message_handlers/tx_handler.py (100%) rename {mcontact => contact}/settings.py (100%) rename {mcontact => contact}/ui/colors.py (100%) rename {mcontact => contact}/ui/control_ui.py (100%) rename {mcontact => contact}/ui/curses_ui.py (100%) rename {mcontact => contact}/ui/default_config.py (100%) rename {mcontact => contact}/ui/dialog.py (100%) rename {mcontact => contact}/ui/menus.py (100%) rename {mcontact => contact}/ui/splash.py (100%) rename {mcontact => contact}/ui/user_config.py (100%) rename {mcontact => contact}/utilities/arg_parser.py (100%) rename {mcontact => contact}/utilities/config_io.py (100%) rename {mcontact => contact}/utilities/control_utils.py (100%) rename {mcontact => contact}/utilities/db_handler.py (100%) rename {mcontact => contact}/utilities/input_handlers.py (100%) rename {mcontact => contact}/utilities/interfaces.py (100%) rename {mcontact => contact}/utilities/save_to_radio.py (100%) rename {mcontact => contact}/utilities/utils.py (100%) diff --git a/mcontact/globals.py b/contact/globals.py similarity index 100% rename from mcontact/globals.py rename to contact/globals.py diff --git a/mcontact/localisations/en.ini b/contact/localisations/en.ini similarity index 100% rename from mcontact/localisations/en.ini rename to contact/localisations/en.ini diff --git a/mcontact/main.py b/contact/main.py similarity index 100% rename from mcontact/main.py rename to contact/main.py diff --git a/mcontact/message_handlers/rx_handler.py b/contact/message_handlers/rx_handler.py similarity index 100% rename from mcontact/message_handlers/rx_handler.py rename to contact/message_handlers/rx_handler.py diff --git a/mcontact/message_handlers/tx_handler.py b/contact/message_handlers/tx_handler.py similarity index 100% rename from mcontact/message_handlers/tx_handler.py rename to contact/message_handlers/tx_handler.py diff --git a/mcontact/settings.py b/contact/settings.py similarity index 100% rename from mcontact/settings.py rename to contact/settings.py diff --git a/mcontact/ui/colors.py b/contact/ui/colors.py similarity index 100% rename from mcontact/ui/colors.py rename to contact/ui/colors.py diff --git a/mcontact/ui/control_ui.py b/contact/ui/control_ui.py similarity index 100% rename from mcontact/ui/control_ui.py rename to contact/ui/control_ui.py diff --git a/mcontact/ui/curses_ui.py b/contact/ui/curses_ui.py similarity index 100% rename from mcontact/ui/curses_ui.py rename to contact/ui/curses_ui.py diff --git a/mcontact/ui/default_config.py b/contact/ui/default_config.py similarity index 100% rename from mcontact/ui/default_config.py rename to contact/ui/default_config.py diff --git a/mcontact/ui/dialog.py b/contact/ui/dialog.py similarity index 100% rename from mcontact/ui/dialog.py rename to contact/ui/dialog.py diff --git a/mcontact/ui/menus.py b/contact/ui/menus.py similarity index 100% rename from mcontact/ui/menus.py rename to contact/ui/menus.py diff --git a/mcontact/ui/splash.py b/contact/ui/splash.py similarity index 100% rename from mcontact/ui/splash.py rename to contact/ui/splash.py diff --git a/mcontact/ui/user_config.py b/contact/ui/user_config.py similarity index 100% rename from mcontact/ui/user_config.py rename to contact/ui/user_config.py diff --git a/mcontact/utilities/arg_parser.py b/contact/utilities/arg_parser.py similarity index 100% rename from mcontact/utilities/arg_parser.py rename to contact/utilities/arg_parser.py diff --git a/mcontact/utilities/config_io.py b/contact/utilities/config_io.py similarity index 100% rename from mcontact/utilities/config_io.py rename to contact/utilities/config_io.py diff --git a/mcontact/utilities/control_utils.py b/contact/utilities/control_utils.py similarity index 100% rename from mcontact/utilities/control_utils.py rename to contact/utilities/control_utils.py diff --git a/mcontact/utilities/db_handler.py b/contact/utilities/db_handler.py similarity index 100% rename from mcontact/utilities/db_handler.py rename to contact/utilities/db_handler.py diff --git a/mcontact/utilities/input_handlers.py b/contact/utilities/input_handlers.py similarity index 100% rename from mcontact/utilities/input_handlers.py rename to contact/utilities/input_handlers.py diff --git a/mcontact/utilities/interfaces.py b/contact/utilities/interfaces.py similarity index 100% rename from mcontact/utilities/interfaces.py rename to contact/utilities/interfaces.py diff --git a/mcontact/utilities/save_to_radio.py b/contact/utilities/save_to_radio.py similarity index 100% rename from mcontact/utilities/save_to_radio.py rename to contact/utilities/save_to_radio.py diff --git a/mcontact/utilities/utils.py b/contact/utilities/utils.py similarity index 100% rename from mcontact/utilities/utils.py rename to contact/utilities/utils.py From 857d8d0c0472e7557f5fadb7901aa2876adab152 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 1 Apr 2025 15:52:57 -0700 Subject: [PATCH 70/73] update readme --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0badf56..f4e2ce9 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ If no connection arguments are specified, the client will attempt a serial conne ### Example Usage ```sh -python main.py --port /dev/ttyUSB0 -python main.py --host 192.168.1.1 -python main.py --ble BlAddressOfDevice +contact --port /dev/ttyUSB0 +contact --host 192.168.1.1 +contact --ble BlAddressOfDevice ``` To quickly connect to localhost, use: ```sh -python main.py -t +contact -t ``` From 9e3b684a5fbedd4e29edaa8ef3a7d87b13812152 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 1 Apr 2025 22:04:16 -0700 Subject: [PATCH 71/73] add home page --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3798286..1e3c58d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "contact" version = "1.3.0" 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"} + {name = "Ben Lipsey",email = "ben@pdxlocations.com"}, ] license = "GPL-3.0-only" readme = "README.md" @@ -12,6 +12,9 @@ dependencies = [ "meshtastic (>=2.6.0,<3.0.0)" ] +[project.urls] +Homepage = "https://github.com/pdxlocations/contact" +Issues = "https://github.com/pdxlocations/contact/issues" [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] From 5999deac1a58e777769c82e719cc54ba4b33a9ed Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Tue, 1 Apr 2025 22:07:21 -0700 Subject: [PATCH 72/73] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1e3c58d..75f7aa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "contact" -version = "1.3.0" +version = "1.3.1" 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"}, From 696370308fc7e83c7c8302b63a5e930396de9d37 Mon Sep 17 00:00:00 2001 From: Russell Schmidt Date: Wed, 2 Apr 2025 12:05:01 -0500 Subject: [PATCH 73/73] Rename main to __main__ Most commonly, the __main__.py file is used to provide a command-line interface for a package. __main__.py will be executed when the package itself is invoked directly from the command line using the -m flag. --- .vscode/launch.json | 4 ++-- contact/{main.py => __main__.py} | 0 pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename contact/{main.py => __main__.py} (100%) diff --git a/.vscode/launch.json b/.vscode/launch.json index 6d8c679..fe2dc5b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,8 +6,8 @@ "type": "debugpy", "request": "launch", "cwd": "${workspaceFolder}", - "module": "contact.main", + "module": "contact.__main__", "args": [] } ] -} \ No newline at end of file +} diff --git a/contact/main.py b/contact/__main__.py similarity index 100% rename from contact/main.py rename to contact/__main__.py diff --git a/pyproject.toml b/pyproject.toml index 75f7aa4..96d0301 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,4 +21,4 @@ requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] -contact = "contact.main:start" +contact = "contact.__main__:start"