Compare commits

..

44 Commits

Author SHA1 Message Date
pdxlocations
1f270b5ba5 devpath 2025-04-08 20:50:30 -07:00
pdxlocations
4abe9611e3 bump version 2025-04-06 22:03:05 -07:00
pdxlocations
4d20df17fe Update README.md 2025-04-06 21:59:01 -07:00
pdxlocations
3bb57b9420 Merge pull request #163 from pdxlocations:localhost-fallback
Fallback to localhost not meshtastic.local
2025-04-06 21:44:32 -07:00
pdxlocations
e305bb4464 use localhost not meshtastic.local 2025-04-06 21:44:04 -07:00
pdxlocations
636b27cf9b fix typo 2025-04-06 21:28:07 -07:00
pdxlocations
8e500cb305 bump version 2025-04-06 20:19:44 -07:00
pdxlocations
0878937194 correct instructions for launching control 2025-04-06 20:17:03 -07:00
pdxlocations
ac2016322b update en.ini 2025-04-05 22:35:35 -07:00
pdxlocations
031d74a290 Fix options "not set" displaying values 2025-04-05 22:20:19 -07:00
pdxlocations
14913ce5ae fix new_idx 2025-04-05 21:22:19 -07:00
pdxlocations
c9e39d89b0 rename curses_ui 2025-04-05 19:55:33 -07:00
pdxlocations
dc27e9e02f Refactor into MenuState Class (#162)
* rename state

* changes

* not working changes

* working changes

* not working changes

* working changes

* comments
2025-04-05 19:48:38 -07:00
pdxlocations
4f64131d2e Scroll Arrows for User Config (#161)
* almost working

* likely working changes

* fix width and launch

* unused UI state
2025-04-04 22:49:40 -07:00
pdxlocations
a55d68a828 change debug level 2025-04-03 21:26:12 -07:00
pdxlocations
bd41870567 Merge pull request #160 from rfschmid/make-add-remove-favorite-default-to-yes
Make favorite confirmations default to "Yes"
2025-04-03 18:46:29 -07:00
Russell Schmidt
5a722cbf7d Make favorite confirmations default to "Yes"
Putting the highlight on "no" when pushing the dialog and requiring
scrolling to "yes" feels unnecessary.

Change case on yes/no dialogs to be more consistent.
2025-04-03 17:41:45 -05:00
pdxlocations
9cbc2d51f8 Merge pull request #159 from rfschmid/rm-node-from-db
Rm node from db
2025-04-03 09:00:31 -07:00
Russell Schmidt
5ce3e62fdb Merge 'upstream/main' into rm-node-from-db 2025-04-03 07:27:42 -05:00
pdxlocations
5628758de0 add settings to readme 2025-04-02 22:14:24 -07:00
pdxlocations
890a3b6dc4 cant add multiple authors? 2025-04-02 22:12:32 -07:00
pdxlocations
db01d241c7 bump version 2025-04-02 22:04:53 -07:00
pdxlocations
9044d8d380 Merge pull request #158 from pdxlocations:settings-flag
add settings flag
2025-04-02 22:03:56 -07:00
pdxlocations
0288a1d190 add settings flag 2025-04-02 22:03:28 -07:00
pdxlocations
3674afc216 remove version from main 2025-04-02 21:27:36 -07:00
pdxlocations
da24902bd0 Add Authors 2025-04-02 21:24:51 -07:00
pdxlocations
f9bc7f9be9 Merge pull request #157 from rfschmid:show-favorite-ignored-nodes
Color favorite/ignored nodes
2025-04-02 21:16:45 -07:00
pdxlocations
ffd28c02a3 Merge pull request #156 from rfschmid:rename-main-__main__
Rename main to __main__
2025-04-02 21:14:15 -07:00
Russell Schmidt
d22b3abc2f Make removing node from DB work
Since the Python API doesn't update the nodes table itself, we can just
modify it ourselves. This fixes removing a node so it doesn't just pop
right back up immediately and seems to actually work now.
2025-04-02 15:30:33 -05:00
Russell Schmidt
3c9b81f391 Merge branch 'rename-main-__main__' into rm-node-from-db 2025-04-02 13:17:28 -05:00
Russell Schmidt
ecc360dba9 Color favorite/ignored nodes
Show favorite nodes in color node_favorite (green by default) and
ignored nodes in color node_favorite (red by default). Sort ignored
nodes at the bottom of the node list.
2025-04-02 12:16:15 -05:00
Russell Schmidt
696370308f 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.
2025-04-02 12:05:01 -05:00
pdxlocations
5999deac1a bump version 2025-04-01 22:07:21 -07:00
pdxlocations
492c1d30d6 Merge pull request #149 from pdxlocations/pyproject-update
add home page
2025-04-01 22:05:38 -07:00
pdxlocations
9e3b684a5f add home page 2025-04-01 22:04:16 -07:00
pdxlocations
25f388ed23 Merge pull request #145 from rfschmid/fix-updating-data-for-nodes-not-working 2025-04-01 21:57:29 -07:00
pdxlocations
07fbdb92e3 Merge pull request #147 from rfschmid/add-ignore-node-support 2025-04-01 21:31:37 -07:00
Russell Schmidt
7c4cc1dd2f Merge 'upstream/main' into fix-updating-data-for-nodes-not-working 2025-04-01 22:52:39 -05:00
Russell Schmidt
06ce9f7ac2 Merge 'upstream/main' into add-ignore-node-support 2025-04-01 22:43:23 -05:00
Russell Schmidt
8ff55c3de9 Add ignore node support
Press Ctrl+G to ignore/unignore a node.
2025-03-31 21:56:23 -05:00
Russell Schmidt
d9088ccd68 Add favorite node support
Press Ctrl+F to favorite/unfavorite a node. Favorite nodes always appear
at the top of the node list
2025-03-31 21:35:15 -05:00
Russell Schmidt
db8496b2e3 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.
2025-03-31 19:23:15 -05:00
pdxlocations
c100539ff9 Merge remote-tracking branch 'origin/main' into rm-node-from-db 2025-02-03 18:10:05 -08:00
pdxlocations
5e1ede0bea init 2025-02-03 18:01:38 -08:00
17 changed files with 481 additions and 260 deletions

4
.vscode/launch.json vendored
View File

@@ -6,8 +6,8 @@
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.main",
"module": "contact.__main__",
"args": []
}
]
}
}

View File

@@ -9,9 +9,9 @@ This Python curses client for Meshtastic is a terminal-based client designed to
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<br><br>
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py`
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
<img width="441" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dd47f52a-d4d8-4e40-8001-9ea53d87f816" />
<img width="573" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dbe1287b-5558-407c-84b8-2a1bc913dec8" />
## Message Persistence
@@ -48,6 +48,7 @@ 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, will default to localhost if no host is passed.
- `--ble`, `-b`: The BLE device MAC address or name to connect to.
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.

View File

@@ -3,7 +3,6 @@
'''
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.
'''
@@ -15,13 +14,14 @@ from pubsub import pub
import sys
import io
import logging
import subprocess
import traceback
import threading
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.contact_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
@@ -58,6 +58,11 @@ def main(stdscr):
parser = setup_parser()
args = parser.parse_args()
# Check if --settings was passed and run settings.py as a subprocess
if getattr(args, 'settings', False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface %s", args)
with globals.lock:
globals.interface = initialize_interface(args)

View File

@@ -78,6 +78,13 @@ dns, "IPv4 DNS server", ""
rsyslog_server, "RSyslog server", ""
enabled_protocols, "Enabled protocols", ""
[config.network.ipv4_config]
title, "IPv4 Config", ""
ip, "IP", ""
gateway, "Gateway", ""
subnet, "Subnet", ""
dns, "DNS", ""
[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."
@@ -105,6 +112,35 @@ theme, "Theme", ""
alert_enabled, "Alert enabled", ""
banner_enabled, "Banner enabled", ""
ring_tone_id, "Ring tone ID", ""
language, "Language", ""
node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
[config.device_ui.node_filter]
title, "Node Filter"
unknown_switch, "Unknown Switch", ""
offline_switch, "Offline Switch", ""
public_key_switch, "Public Key Switch", ""
hops_away, "Hops Away", ""
position_switch, "Position Switch", ""
node_name, "Node Name", ""
channel, "Channel", ""
[config.device_ui.node_highlight]
title, "Node Highlight"
chat_switch, "Chat Switch", ""
position_switch, "Position Switch", ""
telemetry_switch, "Telemetry Switch", ""
iaq_switch, "IAQ Switch", ""
node_name, "Node Name", ""
[config.device_ui.map_data]
title, "Map Data"
home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.lora]
title, "LoRa"

View File

@@ -2,7 +2,7 @@ import logging
import time
from contact.utilities.utils import refresh_node_list
from datetime import datetime
from contact.ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
from contact.ui.contact_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

View File

@@ -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 contact.ui.curses_ui import draw_messages_window
from contact.ui.contact_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 contact.ui.curses_ui import draw_channel_list, draw_messages_window, add_notification
from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
refresh_channels = False
refresh_messages = False

View File

@@ -1,5 +1,6 @@
import curses
import textwrap
import time
import logging
import traceback
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
@@ -7,6 +8,7 @@ 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
from contact.utilities.input_handlers import get_list_input
import contact.ui.default_config as config
import contact.ui.dialog
import contact.globals as globals
@@ -293,10 +295,79 @@ def main_ui(stdscr):
draw_channel_list()
draw_messages_window()
if(globals.current_window == 2):
curses.curs_set(0)
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from nodedb?", "No", ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.removeNode(globals.node_list[globals.selected_node])
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
del(globals.interface.nodesByNum[globals.node_list[globals.selected_node]])
# Convert to "!hex" representation that interface.nodes uses
hexid = f"!{hex(globals.node_list[globals.selected_node])[2:]}"
del(globals.interface.nodes[hexid])
globals.node_list.pop(globals.selected_node)
draw_messages_window()
draw_node_list()
else:
draw_messages_window()
curses.curs_set(1)
continue
# ^/
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]]
curses.curs_set(0)
if 'isFavorite' not in selectedNode or selectedNode['isFavorite'] == False:
confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Favorite?", None, ["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()
else:
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Favorites?", None, ["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()
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
if(isinstance(char, str)):
@@ -411,7 +482,12 @@ def draw_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))
color = "node_list"
if 'isFavorite' in node and node['isFavorite']:
color = "node_favorite"
if 'isIgnored' in node and node['isIgnored']:
color = "node_ignored"
nodes_pad.addstr(i, 1, node_str, get_color(color, reverse=globals.selected_node == i and globals.current_window == 2))
nodes_win.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
nodes_win.box()
@@ -618,7 +694,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", " ^g = Ignore"]
function_str = ""
for s in cmds:
if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2):
@@ -672,9 +748,18 @@ def refresh_pad(window):
def highlight_line(highlight, window, line):
pad = nodes_pad
color = get_color("node_list")
select_len = nodes_win.getmaxyx()[1] - 2
if window == 2:
node_num = globals.node_list[line]
node = globals.interface.nodesByNum[node_num]
if 'isFavorite' in node and node['isFavorite']:
color = get_color("node_favorite")
if 'isIgnored' in node and node['isIgnored']:
color = get_color("node_ignored")
if(window == 0):
pad = channel_pad
color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list")
@@ -703,4 +788,4 @@ def draw_centered_text_field(win, text, y_offset, color):
def draw_debug(value):
function_win.addstr(1, 1, f"debug: {value} ")
function_win.refresh()
function_win.refresh()

View File

@@ -13,8 +13,10 @@ 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
from contact.ui.ui_state import MenuState
state = MenuState()
import contact.localisations
# Constants
width = 80
@@ -37,13 +39,10 @@ config_folder = os.path.join(locals_dir, "node-configs")
field_mapping, help_text = parse_ini_file(translation_file)
def display_menu(current_menu, menu_path, selected_index, show_save_option, help_text):
def display_menu(state):
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
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Determine the available height for the menu
max_menu_height = curses.LINES
@@ -63,18 +62,18 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(current_menu) + 1, width - 8)
menu_pad = curses.newpad(len(state.current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_path)
header = " > ".join(word.title() for word in state.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)
transformed_path = transform_menu_path(state.menu_path)
for idx, option in enumerate(current_menu):
field_info = current_menu[option]
for idx, option in enumerate(state.current_menu):
field_info = state.current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else ""
full_key = '.'.join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
@@ -83,41 +82,41 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help
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))
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == state.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:
if state.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))))
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.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)
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, state)
menu_win.refresh()
menu_pad.refresh(
start_index[-1], 0,
state.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 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
)
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)
max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
draw_arrows(menu_win, visible_height, max_index, state)
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):
def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, transformed_path, state):
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
selected_option = list(state.current_menu.keys())[state.selected_index] if state.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)
@@ -252,66 +251,66 @@ def get_wrapped_help_text(help_text, transformed_path, selected_option, width, m
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
def move_highlight(old_idx, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state):
if old_idx == state.selected_index: # 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)
max_index = len(options) + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
# Adjust start_index only when moving out of visible range
if new_idx == max_index and show_save_option:
# Adjust state.start_index only when moving out of visible range
if state.selected_index == max_index and state.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
elif state.selected_index < state.start_index[-1]: # Moving above the visible area
state.start_index[-1] = state.selected_index
elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
state.start_index[-1] = state.selected_index - visible_height
pass
# Ensure start_index is within bounds
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))
# Ensure state.start_index is within bounds
state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
# Clear old selection
if show_save_option and old_idx == max_index:
if state.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"))
# Highlight new selection
if show_save_option and new_idx == max_index:
if state.show_save_option and state.selected_index == 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_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(start_index[-1], 0,
menu_pad.refresh(state.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] - 4)
# Update help window
transformed_path = transform_menu_path(menu_path)
selected_option = options[new_idx] if new_idx < len(options) else None
transformed_path = transform_menu_path(state.menu_path)
selected_option = options[state.selected_index] if state.selected_index < len(options) else None
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)
draw_arrows(menu_win, visible_height, max_index, state)
def draw_arrows(win, visible_height, max_index, start_index, show_save_option):
def draw_arrows(win, visible_height, max_index, state):
# vh = visible_height + (1 if show_save_option else 0)
mi = max_index - (2 if show_save_option else 0)
mi = max_index - (2 if state.show_save_option else 0)
if visible_height < mi:
if start_index[-1] > 0:
if state.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) :
if mi - state.start_index[-1] >= visible_height + (0 if state.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"))
@@ -321,47 +320,47 @@ 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
state.current_menu = menu["Main Menu"]
state.menu_path = ["Main Menu"]
modified_settings = {}
need_redraw = True
show_save_option = False
state.show_save_option = False
while True:
if(need_redraw):
options = list(current_menu.keys())
options = list(state.current_menu.keys())
show_save_option = (
len(menu_path) > 2 and ("Radio Settings" in menu_path or "Module Settings" in menu_path)
state.show_save_option = (
len(state.menu_path) > 2 and ("Radio Settings" in state.menu_path or "Module Settings" in state.menu_path)
) or (
len(menu_path) == 2 and "User Settings" in menu_path
len(state.menu_path) == 2 and "User Settings" in state.menu_path
) or (
len(menu_path) == 3 and "Channels" in menu_path
len(state.menu_path) == 3 and "Channels" in state.menu_path
)
# Display the menu
menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option, help_text)
menu_win, menu_pad = display_menu(state)
need_redraw = False
# Capture user input
key = menu_win.getch()
max_index = len(options) + (1 if show_save_option else 0) - 1
max_index = len(options) + (1 if state.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)
old_selected_index = state.selected_index
state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
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)
old_selected_index = state.selected_index
state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
elif key == curses.KEY_RESIZE:
need_redraw = True
@@ -373,36 +372,36 @@ def settings_menu(stdscr, interface):
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 == ord("\t") and state.show_save_option:
old_selected_index = state.selected_index
state.selected_index = max_index
move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
elif key == curses.KEY_RIGHT or key == ord('\n'):
need_redraw = True
start_index.append(0)
state.start_index.append(0)
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))
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
menu_win.refresh()
help_win.refresh()
if show_save_option and selected_index == len(options):
save_changes(interface, menu_path, modified_settings)
if state.show_save_option and state.selected_index == len(options):
save_changes(interface, modified_settings, state)
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
if len(state.menu_path) > 1:
state.menu_path.pop()
state.current_menu = menu["Main Menu"]
for step in state.menu_path[1:]:
state.current_menu = state.current_menu.get(step, {})
state.selected_index = 0
continue
selected_option = options[selected_index]
selected_option = options[state.selected_index]
if selected_option == "Exit":
break
@@ -411,7 +410,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()
state.start_index.pop()
continue # Go back to the menu
if not filename.lower().endswith(".yaml"):
filename += ".yaml"
@@ -424,14 +423,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()
state.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()
state.start_index.pop()
continue
except PermissionError:
logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
@@ -439,7 +438,7 @@ 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()
state.start_index.pop()
continue
elif selected_option == "Load Config File":
@@ -462,7 +461,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()
state.start_index.pop()
continue
elif selected_option == "Config URL":
@@ -474,7 +473,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()
state.start_index.pop()
continue
elif selected_option == "Reboot":
@@ -482,7 +481,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
start_index.pop()
state.start_index.pop()
continue
elif selected_option == "Reset Node DB":
@@ -490,7 +489,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
start_index.pop()
state.start_index.pop()
continue
elif selected_option == "Shutdown":
@@ -498,7 +497,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes":
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
start_index.pop()
state.start_index.pop()
continue
elif selected_option == "Factory Reset":
@@ -506,22 +505,28 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
start_index.pop()
state.start_index.pop()
continue
elif selected_option == "App Settings":
menu_win.clear()
menu_win.refresh()
json_editor(stdscr) # Open the App Settings menu
state.menu_path.append("App Settings")
state.menu_index.append(state.selected_index)
json_editor(stdscr, state) # Open the App Settings menu
state.current_menu = menu["Main Menu"]
state.menu_path = ["Main Menu"]
state.start_index.pop()
state.selected_index = 4
continue
# need_redraw = True
field_info = current_menu.get(selected_option)
field_info = state.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)
transformed_path = transform_menu_path(state.menu_path)
full_key = '.'.join(transformed_path + [selected_option])
# Fetch human-readable name from field_mapping
@@ -531,70 +536,73 @@ def settings_menu(stdscr, interface):
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)
state.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)
state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in current_menu.items():
for option, (field, value) in state.current_menu.items():
modified_settings[option] = value
start_index.pop()
state.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
current_menu[selected_option] = (field, new_value)
state.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]
if option in state.current_menu:
modified_settings[option] = state.current_menu[option][1]
start_index.pop()
state.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()
state.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()
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
pass # Leave it as-is
else:
new_value = new_value == "True" or new_value is True
state.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()
state.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()
state.start_index.pop()
elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value)
start_index.pop()
state.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()
state.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()
state.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()
state.start_index.pop()
for key in menu_path[3:]: # Skip "Main Menu"
for key in state.menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {})
# Add the new value to the appropriate level
@@ -605,12 +613,12 @@ def settings_menu(stdscr, interface):
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)
state.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
state.current_menu = state.current_menu[selected_option]
state.menu_path.append(selected_option)
state.menu_index.append(state.selected_index)
state.selected_index = 0
elif key == curses.KEY_LEFT:
@@ -620,22 +628,22 @@ def settings_menu(stdscr, interface):
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))
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
menu_win.refresh()
help_win.refresh()
if len(menu_path) < 2:
if len(state.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()
start_index.pop()
if len(state.menu_path) > 1:
state.menu_path.pop()
state.current_menu = menu["Main Menu"]
for step in state.menu_path[1:]:
state.current_menu = state.current_menu.get(step, {})
state.selected_index = state.menu_index.pop()
state.start_index.pop()
elif key == 27: # Escape key
menu_win.erase()

View File

@@ -65,7 +65,9 @@ def initialize_config():
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["white", "black"],
"settings_warning": ["red", "black"],
"settings_note": ["green", "black"]
"settings_note": ["green", "black"],
"node_favorite": ["green", "black"],
"node_ignored": ["red", "black"]
}
COLOR_CONFIG_LIGHT = {
"default": ["black", "white"],
@@ -89,7 +91,9 @@ def initialize_config():
"settings_save": ["green", "white"],
"settings_breadcrumbs": ["black", "white"],
"settings_warning": ["red", "white"],
"settings_note": ["green", "white"]
"settings_note": ["green", "white"],
"node_favorite": ["green", "white"],
"node_ignored": ["red", "white"]
}
COLOR_CONFIG_GREEN = {
"default": ["green", "black"],
@@ -115,7 +119,9 @@ def initialize_config():
"settings_save": ["green", "black"],
"settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"],
"settings_note": ["green", "black"]
"settings_note": ["green", "black"],
"node_favorite": ["cyan", "white"],
"node_ignored": ["red", "white"]
}
default_config_variables = {
"db_file_path": db_file_path,

8
contact/ui/ui_state.py Normal file
View File

@@ -0,0 +1,8 @@
class MenuState:
def __init__(self):
self.menu_index = [] # Row we left the previous menus
self.start_index = [0] # Row to start the menu if it doesn't all fit
self.selected_index = 0 # Selected Row
self.current_menu = {} # Contents of the current menu
self.menu_path = [] # Menu Path
self.show_save_option = False

View File

@@ -5,8 +5,9 @@ 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"
width = 80
save_option = "Save Changes"
sensitive_settings = []
def edit_color_pair(key, current_value):
@@ -19,8 +20,8 @@ def edit_color_pair(key, current_value):
return [fg_color, bg_color]
def edit_value(key, current_value):
width = 60
def edit_value(key, current_value, state):
height = 10
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
@@ -73,8 +74,10 @@ def edit_value(key, current_value):
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value # 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
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
@@ -93,114 +96,144 @@ def edit_value(key, current_value):
return user_input if user_input else current_value
def render_menu(current_data, menu_path, selected_index):
def display_menu(state):
"""
Render the configuration menu with a Save button directly added to the window.
"""
# Determine menu items based on the type of current_data
if isinstance(current_data, dict):
options = list(current_data.keys())
elif isinstance(current_data, list):
options = [f"[{i}]" for i in range(len(current_data))]
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Determine menu items based on the type of current_menu
if isinstance(state.current_menu, dict):
options = list(state.current_menu.keys())
elif isinstance(state.current_menu, list):
options = [f"[{i}]" for i in range(len(state.current_menu))]
else:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
height = min(curses.LINES - 2, num_items + 6) # Include space for borders and Save button
start_y = (curses.LINES - height) // 2
start_y = (curses.LINES - menu_height) // 2
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(height, width, start_y, start_x)
menu_win.clear()
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)
# Display the menu path
header = " > ".join(menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the menu path
header = " > ".join(state.menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = current_data[key] if isinstance(current_data, dict) else current_data[int(key.strip("[]"))]
value = state.current_menu[key] if isinstance(state.current_menu, dict) else state.current_menu[int(key.strip("[]"))]
display_key = f"{key}"[:width // 2 - 2]
display_value = (
f"{value}"[:width // 2 - 8]
)
color = get_color("settings_default", reverse=(idx == selected_index))
color = get_color("settings_default", reverse=(idx == state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
save_button_position = height - 2
menu_win.addstr(
save_button_position,
(width - len(save_option_text)) // 2,
save_option_text,
get_color("settings_save", reverse=(selected_index == len(options))),
)
if state.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=(state.selected_index == len(state.current_menu))))
# Refresh menu and pad
menu_win.refresh()
menu_pad.refresh(
0,
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
state.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 state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
)
max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, state)
return menu_win, menu_pad, options
def move_highlight(old_idx, new_idx, options, menu_win, menu_pad):
if old_idx == new_idx:
return # no-op
def move_highlight(old_idx, options, menu_win, menu_pad, state):
if old_idx == state.selected_index: # No-op
return
show_save_option = True
max_index = len(options) + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
max_index = len(options) + (1 if show_save_option else 0) - 1
# Adjust state.start_index only when moving out of visible range
if state.selected_index == max_index and state.show_save_option:
pass
elif state.selected_index < state.start_index[-1]: # Moving above the visible area
state.start_index[-1] = state.selected_index
elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
state.start_index[-1] = state.selected_index - visible_height
pass
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_text)) // 2, len(save_option_text), get_color("settings_save"))
# Ensure state.start_index is within bounds
state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
# Clear old selection
if state.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_default"))
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_text)) // 2, len(save_option_text), get_color("settings_save", reverse = True))
# Highlight new selection
if state.show_save_option and state.selected_index == 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_default", reverse = True))
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 6))
menu_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh()
menu_pad.refresh(start_index, 0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + 4 + menu_win.getmaxyx()[1] - 4)
# Refresh pad only if scrolling is needed
menu_pad.refresh(state.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] - 4)
draw_arrows(menu_win, visible_height, max_index, state)
def json_editor(stdscr):
menu_path = ["App Settings"]
selected_index = 0 # Track the selected option
def draw_arrows(win, visible_height, max_index, state):
mi = max_index - (2 if state.show_save_option else 0)
if visible_height < mi:
if state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - state.start_index[-1] >= visible_height + (0 if state.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 json_editor(stdscr, state):
state.selected_index = 0 # Track the selected option
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
state.show_save_option = True # Always show the Save button
# Ensure the file exists
if not os.path.exists(file_path):
@@ -212,76 +245,81 @@ def json_editor(stdscr):
original_data = json.load(f)
data = original_data # Reference to the original data
current_data = data # Track the current level of the menu
state.current_menu = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
menu_win, menu_pad, options = display_menu(state)
need_redraw = True
while True:
if(need_redraw):
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
menu_win, menu_pad, options = display_menu(state)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if show_save_option else 0) - 1
max_index = len(options) + (1 if state.show_save_option else 0) - 1
key = menu_win.getch()
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, menu_win, menu_pad)
old_selected_index = state.selected_index
state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
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, menu_win, menu_pad)
old_selected_index = state.selected_index
state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
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, menu_win, menu_pad)
elif key == ord("\t") and state.show_save_option:
old_selected_index = state.selected_index
state.selected_index = max_index
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
elif key in (curses.KEY_RIGHT, ord("\n")):
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
need_redraw = True
menu_win.erase()
menu_win.refresh()
if selected_index < len(options): # Handle selection of a menu item
selected_key = options[selected_index]
if state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[state.selected_index]
state.menu_path.append(str(selected_key))
state.start_index.append(0)
state.menu_index.append(state.selected_index)
# Handle nested data
if isinstance(current_data, dict):
if selected_key in current_data:
selected_data = current_data[selected_key]
if isinstance(state.current_menu, dict):
if selected_key in state.current_menu:
selected_data = state.current_menu[selected_key]
else:
continue # Skip invalid key
elif isinstance(current_data, list):
selected_data = current_data[int(selected_key.strip("[]"))]
elif isinstance(state.current_menu, list):
selected_data = state.current_menu[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
new_value = edit_color_pair(
selected_key, selected_data)
current_data[selected_key] = new_value
new_value = edit_color_pair(selected_key, selected_data)
state.menu_path.pop()
state.start_index.pop()
state.menu_index.pop()
state.current_menu[selected_key] = new_value
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
menu_path.append(str(selected_key))
current_data = selected_data
selected_index = 0 # Reset the selected index
state.current_menu = selected_data
state.selected_index = 0 # Reset the selected index
else:
# General value editing
new_value = edit_value(selected_key, selected_data)
current_data[selected_key] = new_value
new_value = edit_value(selected_key, selected_data, state)
state.menu_path.pop()
state.menu_index.pop()
state.start_index.pop()
state.current_menu[selected_key] = new_value
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
@@ -294,17 +332,22 @@ def json_editor(stdscr):
menu_win.erase()
menu_win.refresh()
# state.selected_index = state.menu_index[-1]
# Navigate back in the menu
if len(menu_path) > 1:
menu_path.pop()
current_data = data
for path in menu_path[1:]:
current_data = current_data[path] if isinstance(current_data, dict) else current_data[int(path.strip("[]"))]
selected_index = 0
if len(state.menu_path) > 2:
state.menu_path.pop()
state.start_index.pop()
state.current_menu = data
for path in state.menu_path[2:]:
state.current_menu = state.current_menu[path] if isinstance(state.current_menu, dict) else state.current_menu[int(path.strip("[]"))]
else:
# Exit the editor
menu_win.clear()
menu_win.refresh()
break
@@ -315,10 +358,16 @@ def save_json(file_path, data):
setup_colors(reinit=True)
def main(stdscr):
from contact.ui.ui_state import MenuState
state = MenuState()
if len(state.menu_path) == 0:
state.menu_path = ["App Settings"] # Initialize if not set
curses.curs_set(0)
stdscr.keypad(True)
setup_colors()
json_editor(stdscr)
json_editor(stdscr, state)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -33,5 +33,14 @@ def setup_parser():
default=None,
const="any"
)
parser.add_argument(
"--settings",
"--set",
"--control",
"-c",
help="Launch directly into the settings",
action="store_true"
)
return parser

View File

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

View File

@@ -10,14 +10,15 @@ def initialize_interface(args):
return meshtastic.tcp_interface.TCPInterface(args.host)
else:
try:
return meshtastic.serial_interface.SerialInterface(args.port)
client = meshtastic.serial_interface.SerialInterface(args.port)
except PermissionError as ex:
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}")
if globals.interface.devPath is None:
return meshtastic.tcp_interface.TCPInterface("meshtastic.local")
if client.devPath is None:
client = meshtastic.tcp_interface.TCPInterface("localhost")
return client
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")

View File

@@ -4,7 +4,7 @@ import logging
import base64
import time
def save_changes(interface, menu_path, modified_settings):
def save_changes(interface, modified_settings, state):
"""
Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance
@@ -52,8 +52,8 @@ def save_changes(interface, menu_path, modified_settings):
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
if state.menu_path[1] == "Radio Settings" or state.menu_path[1] == "Module Settings":
config_category = state.menu_path[2].lower() # for radio and module configs
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
lat = float(modified_settings.get('latitude', 0.0))
@@ -64,7 +64,7 @@ def save_changes(interface, menu_path, modified_settings):
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return
elif menu_path[1] == "User Settings": # for user configs
elif state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings"
long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName")
@@ -77,11 +77,11 @@ def save_changes(interface, menu_path, modified_settings):
return
elif menu_path[1] == "Channels": # for channel configs
elif state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels"
try:
channel = menu_path[-1]
channel = state.menu_path[-1]
channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e:
channel_num = None

View File

@@ -47,7 +47,15 @@ def get_node_list():
return node['hopsAway'] if 'hopsAway' in node else 100
else:
return node
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
# Move favorite nodes to the beginning
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
# Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isIgnored'] if 'isIgnored' in node else False)
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 []

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.3.0"
version = "1.3.4"
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"}
@@ -12,10 +12,13 @@ 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"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
contact = "contact.main:start"
contact = "contact.__main__:start"