Compare commits

...

10 Commits

Author SHA1 Message Date
pdxlocations
3be31698df remove fox references 2025-03-24 19:14:02 -07:00
pdxlocations
140d794213 Merge pull request #140 from rfschmid/fix-crash-sending-long-message 2025-03-18 10:28:38 -07:00
Russell Schmidt
8e6edf8e83 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.
2025-03-18 11:58:00 -05:00
pdxlocations
c97942d35d attribution 2025-03-17 16:54:50 -07:00
pdxlocations
b9cecaea31 config filepath cleanup 2025-03-17 16:25:26 -07:00
pdxlocations
4bc1654eed fix noon bug (#138) 2025-03-11 10:28:06 -07:00
pdxlocations
ee5f2fa4d4 scroll arrows (#137) 2025-03-10 18:19:10 -07:00
pdxlocations
04381585ab restore app settings 2025-03-08 18:40:01 -08:00
pdxlocations
3fc0495fb1 natural scrolling 2025-03-08 18:16:37 -08:00
pdxlocations
1ccd337b35 maybe fix noon bug 2025-03-08 12:30:28 -08:00
6 changed files with 160 additions and 49 deletions

View File

@@ -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]

View File

@@ -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

View File

@@ -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,14 +94,20 @@ 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
)
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
@@ -147,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."""
@@ -241,38 +250,69 @@ 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 == 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
pass
# 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()[1] + menu_win.getmaxyx()[1] - 8)
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
# 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])
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()
@@ -337,6 +377,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()
@@ -345,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()
@@ -360,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]
@@ -372,26 +408,27 @@ 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"
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"])
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,17 +436,17 @@ 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)
# 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:
@@ -418,10 +455,11 @@ 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)
start_index.pop()
continue
elif selected_option == "Config URL":
@@ -433,6 +471,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 +479,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 +487,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 +495,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,6 +503,7 @@ 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":
@@ -495,6 +538,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 +549,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 +609,7 @@ def settings_menu(stdscr, interface):
menu_index.append(selected_index)
selected_index = 0
elif key == curses.KEY_LEFT:
need_redraw = True
@@ -576,7 +632,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 +656,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")
node.writeConfig("lora")

View File

@@ -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 ""
@@ -418,9 +421,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
@@ -519,9 +522,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

View File

@@ -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

View File

@@ -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,24 +390,65 @@ 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
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)
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
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"))