Compare commits

..

1 Commits

Author SHA1 Message Date
pdxlocations
1f270b5ba5 devpath 2025-04-08 20:50:30 -07:00
28 changed files with 1760 additions and 3013 deletions

View File

@@ -1,6 +0,0 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter",
"editor.formatOnSave": true
}
}

View File

@@ -1,19 +1,8 @@
## Contact - A Console UI for Meshtastic ## Contact - A Console UI for Meshtastic
### (Formerly Curses Client for Meshtastic)
#### Powered by Meshtastic.org #### Powered by Meshtastic.org
### Install with:
```bash
pip install contact
```
> [!NOTE]
> Windows users must also install:
>
> ```powershell
> pip install windows-curses
> ```
> because the built-in curses module is not available on Windows.
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. 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.
@@ -22,7 +11,7 @@ This Python curses client for Meshtastic is a terminal-based client designed to
<br><br> <br><br>
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` 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="696" alt="Screenshot 2025-04-08 at 6 10 06PM" src="https://github.com/user-attachments/assets/3d5e3964-f009-4772-bd6e-91b907c65a3b" /> <img width="573" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dbe1287b-5558-407c-84b8-2a1bc913dec8" />
## Message Persistence ## Message Persistence
@@ -40,7 +29,6 @@ By navigating to Settings -> App Settings, you may customize your UI's icons, co
- `CTRL` + `p` = Hide/show a log of raw received packets. - `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` + `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. - `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.
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed. - `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
### Search ### Search
@@ -70,17 +58,8 @@ If no connection arguments are specified, the client will attempt a serial conne
contact --port /dev/ttyUSB0 contact --port /dev/ttyUSB0
contact --host 192.168.1.1 contact --host 192.168.1.1
contact --ble BlAddressOfDevice contact --ble BlAddressOfDevice
contact --port COM3
``` ```
To quickly connect to localhost, use: To quickly connect to localhost, use:
```sh ```sh
contact -t contact -t
``` ```
## Install in development (editable) mode:
```bash
git clone https://github.com/pdxlocations/contact.git
cd contact
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```

View File

@@ -1,144 +1,114 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" '''
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org Powered by Meshtastic.org
Meshtastic® is a registered trademark of Meshtastic LLC. 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.
Meshtastic software components are released under various licenses—see GitHub for details. '''
No warranty is provided. Use at your own risk.
"""
# Standard library
import contextlib import contextlib
import curses import curses
import os
from pubsub import pub
import sys
import io import io
import logging import logging
import os
import subprocess import subprocess
import sys
import threading
import traceback import traceback
import threading
# Third-party from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from pubsub import pub
# Local application
import contact.ui.default_config as config
from contact.message_handlers.rx_handler import on_receive from contact.message_handlers.rx_handler import on_receive
from contact.settings import set_region from contact.settings import set_region
from contact.ui.colors import setup_colors
from contact.ui.contact_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 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.arg_parser import setup_parser
from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface from contact.utilities.interfaces import initialize_interface
from contact.utilities.utils import get_channels, get_nodeNum, get_node_list from contact.utilities.input_handlers import get_list_input
from contact.utilities.singleton import ui_state, interface_state, app_state from contact.utilities.utils import get_channels, get_node_list, get_nodeNum
import contact.globals as globals
# ------------------------------------------------------------------------------
# Environment & Logging Setup
# ------------------------------------------------------------------------------
# Set ncurses compatibility settings
os.environ["NCURSES_NO_UTF8_ACS"] = "1" os.environ["NCURSES_NO_UTF8_ACS"] = "1"
os.environ["LANG"] = "C.UTF-8" os.environ["LANG"] = "C.UTF-8"
os.environ.setdefault("TERM", "xterm-256color") os.environ.setdefault("TERM", "xterm-256color")
if os.environ.get("COLORTERM") == "gnome-terminal": if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color" os.environ["TERM"] = "xterm-256color"
# Configure logging
# Run `tail -f client.log` in another terminal to view live
logging.basicConfig( logging.basicConfig(
filename=config.log_file_path, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" filename=config.log_file_path,
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
) )
app_state.lock = threading.Lock() globals.lock = threading.Lock()
# ------------------------------------------------------------------------------
# Main Program Logic
# ------------------------------------------------------------------------------
def prompt_region_if_unset(args: object) -> None:
"""Prompt user to set region if it is unset."""
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface_state.interface)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
def initialize_globals() -> None:
"""Initializes interface and shared globals."""
interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
load_messages_from_db()
def main(stdscr: curses.window) -> None:
"""Main entry point for the curses UI."""
def main(stdscr):
output_capture = io.StringIO() output_capture = io.StringIO()
try: try:
setup_colors() with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
draw_splash(stdscr)
args = setup_parser().parse_args() setup_colors()
draw_splash(stdscr)
parser = setup_parser()
args = parser.parse_args()
if getattr(args, "settings", False): # Check if --settings was passed and run settings.py as a subprocess
subprocess.run([sys.executable, "-m", "contact.settings"], check=True) if getattr(args, 'settings', False):
return subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface...") logging.info("Initializing interface %s", args)
with app_state.lock: with globals.lock:
interface_state.interface = initialize_interface(args) globals.interface = initialize_interface(args)
if interface_state.interface.localNode.localConfig.lora.region == 0: if globals.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args) confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(globals.interface)
globals.interface.close()
globals.interface = initialize_interface(args)
initialize_globals() logging.info("Interface initialized")
logging.info("Starting main UI") 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")
try: main_ui(stdscr)
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
main_ui(stdscr)
except Exception:
console_output = output_capture.getvalue()
logging.error("Uncaught exception inside main_ui")
logging.error("Traceback:\n%s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
return
except Exception:
raise
def start() -> None:
"""Entry point for the application."""
if "--help" in sys.argv or "-h" in sys.argv:
setup_parser().print_help()
sys.exit(0)
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
sys.exit(0)
except Exception as e: except Exception as e:
logging.critical("Fatal error", exc_info=True) console_output = output_capture.getvalue()
try: logging.error("An error occurred: %s", e)
curses.endwin() logging.error("Traceback: %s", traceback.format_exc())
except Exception: logging.error("Console output before crash:\n%s", console_output)
pass raise # Re-raise only unexpected errors
print("Fatal error:", e)
traceback.print_exc()
sys.exit(1)
def start():
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
if __name__ == "__main__": if __name__ == "__main__":
start() start()

13
contact/globals.py Normal file
View File

@@ -0,0 +1,13 @@
interface = None
lock = None
display_log = False
all_messages = {}
channel_list = []
notifications = []
packet_buffer = []
node_list = []
myNodeNum = 0
selected_channel = 0
selected_message = 0
selected_node = 0
current_window = 0

View File

@@ -1,153 +1,105 @@
import logging import logging
import os import time
import platform from contact.utilities.utils import refresh_node_list
import shutil from datetime import datetime
import subprocess from contact.ui.contact_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
from typing import Any, Dict from contact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
)
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.ui.default_config as config
import contact.globals as globals
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
def play_sound(): from datetime import datetime
try:
system = platform.system()
sound_path = None
executable = None
if system == "Darwin": # macOS def on_receive(packet, interface):
sound_path = "/System/Library/Sounds/Ping.aiff"
executable = "afplay"
elif system == "Linux": with globals.lock:
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
if shutil.which("paplay") and os.path.exists(ogg_path):
executable = "paplay"
sound_path = ogg_path
elif shutil.which("ffplay") and os.path.exists(ogg_path):
executable = "ffplay"
sound_path = ogg_path
elif shutil.which("aplay") and os.path.exists(wav_path):
executable = "aplay"
sound_path = wav_path
else:
logging.warning("No suitable sound player or sound file found on Linux")
if executable and sound_path:
cmd = [executable, sound_path]
if executable == "ffplay":
cmd = [executable, "-nodisp", "-autoexit", sound_path]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
except subprocess.CalledProcessError as e:
logging.error(f"Sound playback failed: {e}")
except Exception as e:
logging.error(f"Unexpected error: {e}")
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
"""
Handles an incoming packet from a Meshtastic interface.
Args:
packet: The received Meshtastic packet as a dictionary.
interface: The Meshtastic interface instance that received the packet.
"""
with app_state.lock:
# Update packet log # Update packet log
ui_state.packet_buffer.append(packet) globals.packet_buffer.append(packet)
if len(ui_state.packet_buffer) > 20: if len(globals.packet_buffer) > 20:
# Trim buffer to 20 packets # Trim buffer to 20 packets
ui_state.packet_buffer = ui_state.packet_buffer[-20:] globals.packet_buffer = globals.packet_buffer[-20:]
if ui_state.display_log: if globals.display_log:
draw_packetlog_win() draw_packetlog_win()
if ui_state.current_window == 4:
menu_state.need_redraw = True
try: try:
if "decoded" not in packet: if 'decoded' not in packet:
return return
# Assume any incoming packet could update the last seen time for a node # Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list() changed = refresh_node_list()
if changed: if(changed):
draw_node_list() draw_node_list()
if packet["decoded"]["portnum"] == "NODEINFO_APP": if packet['decoded']['portnum'] == 'NODEINFO_APP':
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]: if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
maybe_store_nodeinfo_in_db(packet) maybe_store_nodeinfo_in_db(packet)
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP": elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
if config.notification_sound == "True": message_string = message_bytes.decode('utf-8')
play_sound()
message_bytes = packet["decoded"]["payload"]
message_string = message_bytes.decode("utf-8")
refresh_channels = False refresh_channels = False
refresh_messages = False refresh_messages = False
if packet.get("channel"): if packet.get('channel'):
channel_number = packet["channel"] channel_number = packet['channel']
else: else:
channel_number = 0 channel_number = 0
if packet["to"] == interface_state.myNodeNum: if packet['to'] == globals.myNodeNum:
if packet["from"] in ui_state.channel_list: if packet['from'] in globals.channel_list:
pass pass
else: else:
ui_state.channel_list.append(packet["from"]) globals.channel_list.append(packet['from'])
if packet["from"] not in ui_state.all_messages: if(packet['from'] not in globals.all_messages):
ui_state.all_messages[packet["from"]] = [] globals.all_messages[packet['from']] = []
update_node_info_in_db(packet["from"], chat_archived=False) update_node_info_in_db(packet['from'], chat_archived=False)
refresh_channels = True refresh_channels = True
channel_number = ui_state.channel_list.index(packet["from"]) channel_number = globals.channel_list.index(packet['from'])
channel_id = ui_state.channel_list[channel_number] if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
add_notification(channel_number) add_notification(channel_number)
refresh_channels = True refresh_channels = True
else: else:
refresh_messages = True refresh_messages = True
# Add received message to the messages list # Add received message to the messages list
message_from_id = packet["from"] message_from_id = packet['from']
message_from_string = get_name_from_database(message_from_id, type="short") + ":" message_from_string = get_name_from_database(message_from_id, type='short') + ":"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string} ", message_string) 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
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} --", ""))
globals.all_messages[globals.channel_list[channel_number]].append((f"{config.message_prefix} {message_from_string} ", message_string))
if refresh_channels: if refresh_channels:
draw_channel_list() draw_channel_list()
if refresh_messages: if refresh_messages:
draw_messages_window(True) draw_messages_window(True)
save_message_to_db(channel_id, message_from_id, message_string) save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
except KeyError as e: except KeyError as e:
logging.error(f"Error processing packet: {e}") logging.error(f"Error processing packet: {e}")

View File

@@ -1,44 +1,29 @@
from typing import Any, Dict from datetime import datetime
import google.protobuf.json_format import google.protobuf.json_format
from meshtastic import BROADCAST_NUM from meshtastic import BROADCAST_NUM
from meshtastic.protobuf import mesh_pb2, portnums_pb2 from meshtastic.protobuf import mesh_pb2, portnums_pb2
from contact.utilities.db_handler import ( 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
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.ui.default_config as config
import contact.globals as globals
from contact.utilities.singleton import ui_state, interface_state ack_naks = {}
from contact.utilities.utils import add_new_message
ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention # 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 # See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
def onAckNak(packet: Dict[str, Any]) -> None: def onAckNak(packet):
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import draw_messages_window from contact.ui.contact_ui import draw_messages_window
request = packet['decoded']['requestId']
request = packet["decoded"]["requestId"] if(request not in ack_naks):
if request not in ack_naks:
return return
acknak = ack_naks.pop(request) acknak = ack_naks.pop(request)
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1] message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
confirm_string = " " confirm_string = " "
ack_type = None ack_type = None
if packet["decoded"]["routing"]["errorReason"] == "NONE": if(packet['decoded']['routing']['errorReason'] == "NONE"):
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str confirm_string = config.ack_implicit_str
ack_type = "Implicit" ack_type = "Implicit"
else: else:
@@ -48,28 +33,22 @@ def onAckNak(packet: Dict[str, Any]) -> None:
confirm_string = config.nak_str confirm_string = config.nak_str
ack_type = "Nak" ack_type = "Nak"
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = ( globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
config.sent_message_prefix + confirm_string + ": ",
message,
)
update_ack_nak(acknak["channel"], acknak["timestamp"], message, ack_type) update_ack_nak(acknak['channel'], acknak['timestamp'], message, ack_type)
channel_number = ui_state.channel_list.index(acknak["channel"]) channel_number = globals.channel_list.index(acknak['channel'])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]: if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
draw_messages_window() draw_messages_window()
def on_response_traceroute(packet):
def on_response_traceroute(packet: Dict[str, Any]) -> None: """on response for trace route"""
"""
Handle traceroute response packets and render the route visually in the UI.
"""
from contact.ui.contact_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_channels = False
refresh_messages = False refresh_messages = False
UNK_SNR = -128 # Value representing unknown SNR UNK_SNR = -128 # Value representing unknown SNR
route_discovery = mesh_pb2.RouteDiscovery() route_discovery = mesh_pb2.RouteDiscovery()
route_discovery.ParseFromString(packet["decoded"]["payload"]) route_discovery.ParseFromString(packet["decoded"]["payload"])
@@ -77,109 +56,79 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
msg_str = "Traceroute to:\n" msg_str = "Traceroute to:\n"
route_str = ( route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
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 # 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"]) 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 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 if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]): for idx, node_num in enumerate(msg_dict["route"]):
route_str += ( 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)"
+ (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 # End with origin of response
route_str += ( 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)"
+ (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 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) # 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"]) 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 backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid: if backValid:
msg_str += "Back:\n" msg_str += "Back:\n"
route_str = ( route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
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 if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]): for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += ( 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)"
+ (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) # End with destination of response (us)
route_str += ( 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)"
+ (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 msg_str += route_str + "\n" # Print the route back to us
if packet["from"] not in ui_state.channel_list: if(packet['from'] not in globals.channel_list):
ui_state.channel_list.append(packet["from"]) globals.channel_list.append(packet['from'])
refresh_channels = True refresh_channels = True
if is_chat_archived(packet["from"]): if(is_chat_archived(packet['from'])):
update_node_info_in_db(packet["from"], chat_archived=False) update_node_info_in_db(packet['from'], chat_archived=False)
channel_number = ui_state.channel_list.index(packet["from"]) channel_number = globals.channel_list.index(packet['from'])
channel_id = ui_state.channel_list[channel_number]
if channel_id == ui_state.channel_list[ui_state.selected_channel]: if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
refresh_messages = True refresh_messages = True
else: else:
add_notification(channel_number) add_notification(channel_number)
refresh_channels = True refresh_channels = True
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n" message_from_string = get_name_from_database(packet['from'], type='short') + ":\n"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str) 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: if refresh_channels:
draw_channel_list() draw_channel_list()
if refresh_messages: if refresh_messages:
draw_messages_window(True) draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None: def send_message(message, destination=BROADCAST_NUM, channel=0):
""" myid = globals.myNodeNum
Sends a chat message using the selected channel.
"""
myid = interface_state.myNodeNum
send_on_channel = 0 send_on_channel = 0
channel_id = ui_state.channel_list[channel] channel_id = globals.channel_list[channel]
if isinstance(channel_id, int): if isinstance(channel_id, int):
send_on_channel = 0 send_on_channel = 0
destination = channel_id destination = channel_id
elif isinstance(channel_id, str): elif isinstance(channel_id, str):
send_on_channel = channel send_on_channel = channel
sent_message_data = interface_state.interface.sendText( sent_message_data = globals.interface.sendText(
text=message, text=message,
destinationId=destination, destinationId=destination,
wantAck=True, wantAck=True,
@@ -188,29 +137,42 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
channelIndex=send_on_channel, channelIndex=send_on_channel,
) )
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message) # 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) timestamp = save_message_to_db(channel_id, myid, message)
ack_naks[sent_message_data.id] = { ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp}
"channel": channel_id,
"messageIndex": len(ui_state.all_messages[channel_id]) - 1,
"timestamp": timestamp,
}
def send_traceroute() -> None:
"""
Sends a RouteDiscovery protobuf to the selected node.
"""
channel_id = ui_state.node_list[ui_state.selected_node]
add_new_message(channel_id, f"{config.message_prefix} Sent Traceroute", "")
def send_traceroute():
r = mesh_pb2.RouteDiscovery() r = mesh_pb2.RouteDiscovery()
interface_state.interface.sendData( globals.interface.sendData(
r, r,
destinationId=channel_id, destinationId=globals.node_list[globals.selected_node],
portNum=portnums_pb2.PortNum.TRACEROUTE_APP, portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
wantResponse=True, wantResponse=True,
onResponse=on_response_traceroute, onResponse=on_response_traceroute,

View File

@@ -14,10 +14,10 @@ from contact.utilities.arg_parser import setup_parser
from contact.utilities.interfaces import initialize_interface from contact.utilities.interfaces import initialize_interface
def main(stdscr: curses.window) -> None: def main(stdscr):
output_capture = io.StringIO() output_capture = io.StringIO()
try: try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture): with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors() setup_colors()
draw_splash(stdscr) draw_splash(stdscr)
curses.curs_set(0) curses.curs_set(0)
@@ -28,7 +28,7 @@ def main(stdscr: curses.window) -> None:
interface = initialize_interface(args) interface = initialize_interface(args)
if interface.localNode.localConfig.lora.region == 0: if 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": if confirmation == "Yes":
set_region(interface) set_region(interface)
interface.close() interface.close()
@@ -45,10 +45,10 @@ def main(stdscr: curses.window) -> None:
raise raise
logging.basicConfig( # Run `tail -f client.log` in another terminal to view live logging.basicConfig( # Run `tail -f client.log` in another terminal to view live
filename=config.log_file_path, filename=config.log_file_path,
level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL) level=logging.WARNING, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s"
) )
if __name__ == "__main__": if __name__ == "__main__":
@@ -67,4 +67,4 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e) logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc()) logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code sys.exit(1) # Exit with an error code

View File

@@ -9,11 +9,10 @@ COLOR_MAP = {
"blue": curses.COLOR_BLUE, "blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA, "magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN, "cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE, "white": curses.COLOR_WHITE
} }
def setup_colors(reinit=False):
def setup_colors(reinit: bool = False) -> None:
""" """
Initialize curses color pairs based on the COLOR_CONFIG. Initialize curses color pairs based on the COLOR_CONFIG.
""" """
@@ -30,7 +29,7 @@ def setup_colors(reinit: bool = False) -> None:
print() print()
def get_color(category: str, bold: bool = False, reverse: bool = False, underline: bool = False) -> int: def get_color(category, bold=False, reverse=False, underline=False):
""" """
Retrieve a curses color pair with optional attributes. Retrieve a curses color pair with optional attributes.
""" """
@@ -41,4 +40,4 @@ def get_color(category: str, bold: bool = False, reverse: bool = False, underlin
color |= curses.A_REVERSE color |= curses.A_REVERSE
if underline: if underline:
color |= curses.A_UNDERLINE color |= curses.A_UNDERLINE
return color return color

File diff suppressed because it is too large Load Diff

View File

@@ -2,306 +2,415 @@ import base64
import curses import curses
import logging import logging
import os import os
import re
import sys import sys
from typing import List
from contact.utilities.save_to_radio import save_changes from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import from contact.utilities.config_io import config_export, config_import
from contact.utilities.control_utils import parse_ini_file, transform_menu_path from contact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input
from contact.utilities.input_handlers import ( from contact.ui.menus import generate_menu_from_protobuf
get_repeated_input,
get_text_input,
get_fixed32_input,
get_list_input,
get_admin_key_input,
)
from contact.ui.colors import get_color from contact.ui.colors import get_color
from contact.ui.dialog import dialog from contact.ui.dialog import dialog
from contact.ui.menus import generate_menu_from_protobuf from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor from contact.ui.user_config import json_editor
from contact.utilities.singleton import menu_state from contact.ui.ui_state import MenuState
# Setup Variables state = MenuState()
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
# Constants
width = 80
save_option = "Save Changes" save_option = "Save Changes"
max_help_lines = 0 max_help_lines = 0
help_win = None help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Compute the effective menu width for the current terminal
def get_menu_width() -> int:
# Leave at least 2 columns for borders; clamp to >= 20 for usability
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Get the parent directory of the script # Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# Paths # Paths
# locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
translation_file = os.path.join(parent_dir, "localisations", "en.ini") translation_file = os.path.join(parent_dir, "localisations", "en.ini")
# config_folder = os.path.join(locals_dir, "node-configs") config_folder = os.path.join(locals_dir, "node-configs")
config_folder = os.path.abspath(config.node_configs_file_path)
# Load translations # Load translations
field_mapping, help_text = parse_ini_file(translation_file) field_mapping, help_text = parse_ini_file(translation_file)
def display_menu() -> tuple[object, object]: def display_menu(state):
if help_win:
min_help_window_height = 6
else:
min_help_window_height = 0
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) min_help_window_height = 6
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Determine the available height for the menu # Determine the available height for the menu
max_menu_height = curses.LINES max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5) menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
w = get_menu_width()
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = (curses.COLS - w) // 2 start_x = (curses.COLS - width) // 2
# Calculate remaining space for help window # Calculate remaining space for help window
global max_help_lines global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding 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 max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase() menu_win.erase()
menu_win.bkgd(get_color("background")) menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame")) menu_win.attrset(get_color("window_frame"))
menu_win.border() menu_win.border()
menu_win.keypad(True) menu_win.keypad(True)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8) menu_pad = curses.newpad(len(state.current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background")) menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_state.menu_path) header = " > ".join(word.title() for word in state.menu_path)
if len(header) > w - 4: if len(header) > width - 4:
header = header[: w - 7] + "..." header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path) transformed_path = transform_menu_path(state.menu_path)
for idx, option in enumerate(menu_state.current_menu): for idx, option in enumerate(state.current_menu):
field_info = menu_state.current_menu[option] field_info = state.current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else "" current_value = field_info[1] if isinstance(field_info, tuple) else ""
full_key = ".".join(transformed_path + [option]) full_key = '.'.join(transformed_path + [option])
display_name = field_mapping.get(full_key, option) display_name = field_mapping.get(full_key, option)
display_option = f"{display_name}"[: w // 2 - 2] display_option = f"{display_name}"[:width // 2 - 2]
display_value = f"{current_value}"[: w // 2 - 4] display_value = f"{current_value}"[:width // 2 - 4]
try: try:
color = get_color( color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == state.selected_index))
"settings_sensitive" if option in sensitive_settings else "settings_default", menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
reverse=(idx == menu_state.selected_index),
)
menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
except curses.error: except curses.error:
pass pass
if menu_state.show_save_option: if state.show_save_option:
save_position = menu_height - 2 save_position = menu_height - 2
menu_win.addstr( menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu))))
save_position,
(w - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
# Draw help window with dynamically updated max_help_lines # Draw help window with dynamically updated max_help_lines
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path) draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, state)
menu_win.refresh() menu_win.refresh()
menu_pad.refresh( menu_pad.refresh(
menu_state.start_index[-1], state.start_index[-1], 0,
0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
) )
curses.curs_set(0)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option) draw_arrows(menu_win, visible_height, max_index, state)
return menu_win, menu_pad return menu_win, menu_pad
def draw_help_window( def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, transformed_path, state):
menu_start_y: int,
menu_start_x: int,
menu_height: int,
max_help_lines: int,
transformed_path: List[str],
) -> None:
global help_win global help_win
if "help_win" not in globals(): if 'help_win' not in globals():
help_win = None # Initialize if it does not exist help_win = None # Initialize if it does not exist
selected_option = ( selected_option = list(state.current_menu.keys())[state.selected_index] if state.current_menu else None
list(menu_state.current_menu.keys())[menu_state.selected_index] if menu_state.current_menu else None
)
help_y = menu_start_y + menu_height help_y = menu_start_y + menu_height
# Use current terminal width for the help window width calculation help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x)
help_win = update_help_window(
help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_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)
def get_input_type_for_field(field) -> type: help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64): help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
return int
elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE): # Ensure help window does not exceed screen size
return float 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: else:
return str 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 settings_menu(stdscr: object, interface: object) -> None: 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, 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 state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
# 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
# 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_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# 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(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(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(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, state)
def draw_arrows(win, visible_height, max_index, state):
# vh = visible_height + (1 if show_save_option else 0)
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 settings_menu(stdscr, interface):
curses.update_lines_cols() curses.update_lines_cols()
menu = generate_menu_from_protobuf(interface) menu = generate_menu_from_protobuf(interface)
menu_state.current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"] state.menu_path = ["Main Menu"]
modified_settings = {} modified_settings = {}
menu_state.need_redraw = True need_redraw = True
menu_state.show_save_option = False state.show_save_option = False
while True: while True:
if menu_state.need_redraw: if(need_redraw):
menu_state.need_redraw = False options = list(state.current_menu.keys())
options = list(menu_state.current_menu.keys())
# Determine if save option should be shown state.show_save_option = (
path = menu_state.menu_path len(state.menu_path) > 2 and ("Radio Settings" in state.menu_path or "Module Settings" in state.menu_path)
menu_state.show_save_option = ( ) or (
(len(path) > 2 and ("Radio Settings" in path or "Module Settings" in path)) len(state.menu_path) == 2 and "User Settings" in state.menu_path
or (len(path) == 2 and "User Settings" in path) ) or (
or (len(path) == 3 and "Channels" in path) len(state.menu_path) == 3 and "Channels" in state.menu_path
) )
# Display the menu # Display the menu
menu_win, menu_pad = display_menu() menu_win, menu_pad = display_menu(state)
if menu_win is None: need_redraw = False
continue # Skip if menu_win is not initialized
menu_win.timeout(200) # wait up to 200 ms for a keypress (or less if key is pressed) # Capture user input
key = menu_win.getch() key = menu_win.getch()
if key == -1:
continue
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 max_index = len(options) + (1 if state.show_save_option else 0) - 1
# max_help_lines = 4 # max_help_lines = 4
if key == curses.KEY_UP: if key == curses.KEY_UP:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_RESIZE: elif key == curses.KEY_RESIZE:
menu_state.need_redraw = True need_redraw = True
curses.update_lines_cols() curses.update_lines_cols()
menu_win.erase() menu_win.erase()
if help_win: help_win.erase()
help_win.erase()
menu_win.refresh() menu_win.refresh()
if help_win: help_win.refresh()
help_win.refresh()
elif key == ord("\t") and menu_state.show_save_option: elif key == ord("\t") and state.show_save_option:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = max_index state.selected_index = max_index
move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
old_selected_index,
options,
menu_win,
menu_pad,
menu_state=menu_state,
help_win=help_win,
help_text=help_text,
max_help_lines=max_help_lines,
)
elif key == curses.KEY_RIGHT or key == ord("\n"): elif key == curses.KEY_RIGHT or key == ord('\n'):
menu_state.need_redraw = True need_redraw = True
menu_state.start_index.append(0) state.start_index.append(0)
menu_win.erase() menu_win.erase()
if help_win: help_win.erase()
help_win.erase()
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.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() menu_win.refresh()
if help_win: help_win.refresh()
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options): if state.show_save_option and state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state) save_changes(interface, modified_settings, state)
modified_settings.clear() modified_settings.clear()
logging.info("Changes Saved") logging.info("Changes Saved")
if len(menu_state.menu_path) > 1: if len(state.menu_path) > 1:
menu_state.menu_path.pop() state.menu_path.pop()
menu_state.current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
for step in menu_state.menu_path[1:]: for step in state.menu_path[1:]:
menu_state.current_menu = menu_state.current_menu.get(step, {}) state.current_menu = state.current_menu.get(step, {})
menu_state.selected_index = 0 state.selected_index = 0
continue continue
selected_option = options[menu_state.selected_index] selected_option = options[state.selected_index]
if selected_option == "Exit": if selected_option == "Exit":
break break
elif selected_option == "Export Config File": elif selected_option == "Export Config File":
filename = get_text_input("Enter a filename for the config file")
filename = get_text_input("Enter a filename for the config file", None, None)
if not filename: if not filename:
logging.info("Export aborted: No filename provided.") logging.info("Export aborted: No filename provided.")
menu_state.start_index.pop() state.start_index.pop()
continue # Go back to the menu continue # Go back to the menu
if not filename.lower().endswith(".yaml"): if not filename.lower().endswith(".yaml"):
filename += ".yaml" filename += ".yaml"
@@ -314,15 +423,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
if overwrite == "No": if overwrite == "No":
logging.info("Export cancelled: User chose not to overwrite.") logging.info("Export cancelled: User chose not to overwrite.")
menu_state.start_index.pop() state.start_index.pop()
continue # Return to menu continue # Return to menu
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
with open(yaml_file_path, "w", encoding="utf-8") as file: with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text) file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}") logging.info(f"Config file saved to {yaml_file_path}")
dialog("Config File Saved:", yaml_file_path) dialog(stdscr, "Config File Saved:", yaml_file_path)
menu_state.need_redraw = True state.start_index.pop()
menu_state.start_index.pop()
continue continue
except PermissionError: except PermissionError:
logging.error(f"Permission denied: Unable to write to {yaml_file_path}") logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
@@ -330,23 +438,21 @@ def settings_menu(stdscr: object, interface: object) -> None:
logging.error(f"OS error while saving config: {e}") logging.error(f"OS error while saving config: {e}")
except Exception as e: except Exception as e:
logging.error(f"Unexpected error: {e}") logging.error(f"Unexpected error: {e}")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Load Config File": elif selected_option == "Load Config File":
# Check if folder exists and is not empty # Check if folder exists and is not empty
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)): if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
dialog("", " No config files found. Export a config first.") dialog(stdscr, "", " No config files found. Export a config first.")
menu_state.need_redraw = True
continue # Return to menu continue # Return to menu
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, 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 # Ensure file_list is not empty before proceeding
if not file_list: if not file_list:
dialog("", " No config files found. Export a config first.") dialog(stdscr, "", " No config files found. Export a config first.")
menu_state.need_redraw = True
continue continue
filename = get_list_input("Choose a config file", None, file_list) filename = get_list_input("Choose a config file", None, file_list)
@@ -355,35 +461,35 @@ def settings_menu(stdscr: object, interface: object) -> None:
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes": if overwrite == "Yes":
config_import(interface, file_path) config_import(interface, file_path)
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Config URL": elif selected_option == "Config URL":
current_value = interface.localNode.getURL() current_value = interface.localNode.getURL()
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str) new_value = get_text_input(f"Config URL is currently: {current_value}")
if new_value is not None: if new_value is not None:
current_value = new_value current_value = new_value
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"]) overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
if overwrite == "Yes": if overwrite == "Yes":
interface.localNode.setURL(new_value) interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node") logging.info(f"New Config URL sent to node")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Reboot": elif selected_option == "Reboot":
confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"]) confirmation = get_list_input("Are you sure you want to Reboot?", None, ["Yes", "No"])
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.reboot() interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu") logging.info(f"Node Reboot Requested by menu")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Reset Node DB": elif selected_option == "Reset Node DB":
confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"]) confirmation = get_list_input("Are you sure you want to Reset Node DB?", None, ["Yes", "No"])
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.resetNodeDb() interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu") logging.info(f"Node DB Reset Requested by menu")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Shutdown": elif selected_option == "Shutdown":
@@ -391,79 +497,72 @@ def settings_menu(stdscr: object, interface: object) -> None:
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.shutdown() interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu") logging.info(f"Node Shutdown Requested by menu")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Factory Reset": elif selected_option == "Factory Reset":
confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"]) confirmation = get_list_input("Are you sure you want to Factory Reset?", None, ["Yes", "No"])
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.factoryReset() interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu") logging.info(f"Factory Reset Requested by menu")
menu_state.start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "App Settings": elif selected_option == "App Settings":
menu_win.clear() menu_win.clear()
menu_win.refresh() menu_win.refresh()
menu_state.menu_path.append("App Settings") state.menu_path.append("App Settings")
menu_state.menu_index.append(menu_state.selected_index) state.menu_index.append(state.selected_index)
json_editor(stdscr, menu_state) # Open the App Settings menu json_editor(stdscr, state) # Open the App Settings menu
menu_state.current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
menu_state.menu_path = ["Main Menu"] state.menu_path = ["Main Menu"]
menu_state.start_index.pop() state.start_index.pop()
menu_state.selected_index = 4 state.selected_index = 4
continue continue
# need_redraw = True
field_info = menu_state.current_menu.get(selected_option)
field_info = state.current_menu.get(selected_option)
if isinstance(field_info, tuple): if isinstance(field_info, tuple):
field, current_value = field_info field, current_value = field_info
# Transform the menu path to get the full key # Transform the menu path to get the full key
transformed_path = transform_menu_path(menu_state.menu_path) transformed_path = transform_menu_path(state.menu_path)
full_key = ".".join(transformed_path + [selected_option]) full_key = '.'.join(transformed_path + [selected_option])
# Fetch human-readable name from field_mapping # Fetch human-readable name from field_mapping
human_readable_name = field_mapping.get(full_key, selected_option) human_readable_name = field_mapping.get(full_key, selected_option)
if selected_option in ["longName", "shortName", "isLicensed"]: if selected_option in ['longName', 'shortName', 'isLicensed']:
if selected_option in ["longName", "shortName"]: if selected_option in ['longName', 'shortName']:
new_value = get_text_input( new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
f"{human_readable_name} is currently: {current_value}", selected_option, None
)
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
elif selected_option == "isLicensed": elif selected_option == 'isLicensed':
new_value = get_list_input( new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"])
f"{human_readable_name} is currently: {current_value}",
str(current_value),
["True", "False"],
)
new_value = new_value == "True" new_value = new_value == "True"
menu_state.current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in menu_state.current_menu.items(): for option, (field, value) in state.current_menu.items():
modified_settings[option] = value modified_settings[option] = value
menu_state.start_index.pop() state.start_index.pop()
elif selected_option in ["latitude", "longitude", "altitude"]: elif selected_option in ['latitude', 'longitude', 'altitude']:
new_value = get_text_input( new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
f"{human_readable_name} is currently: {current_value}", selected_option, float
)
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
for option in ["latitude", "longitude", "altitude"]: for option in ['latitude', 'longitude', 'altitude']:
if option in menu_state.current_menu: if option in state.current_menu:
modified_settings[option] = menu_state.current_menu[option][1] modified_settings[option] = state.current_menu[option][1]
menu_state.start_index.pop() state.start_index.pop()
elif selected_option == "admin_key": elif selected_option == "admin_key":
new_values = get_admin_key_input(current_value) 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] new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
menu_state.start_index.pop() state.start_index.pop()
elif field.type == 8: # Handle boolean type elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
@@ -471,48 +570,39 @@ def settings_menu(stdscr: object, interface: object) -> None:
pass # Leave it as-is pass # Leave it as-is
else: else:
new_value = new_value == "True" or new_value is True new_value = new_value == "True" or new_value is True
menu_state.start_index.pop() state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value) new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ") new_value = current_value if new_value is None else new_value.split(", ")
menu_state.start_index.pop() state.start_index.pop()
elif field.enum_type: # Enum field elif field.enum_type: # Enum field
enum_options = {v.name: v.number for v in field.enum_type.values} 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_name = get_list_input(human_readable_name, current_value, list(enum_options.keys()))
new_value = enum_options.get(new_value_name, current_value) new_value = enum_options.get(new_value_name, current_value)
menu_state.start_index.pop() state.start_index.pop()
elif field.type == 7: # Field type 7 corresponds to FIXED32 elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value) new_value = get_fixed32_input(current_value)
menu_state.start_index.pop() state.start_index.pop()
elif field.type == 13: # Field type 13 corresponds to UINT32 elif field.type == 13: # Field type 13 corresponds to UINT32
input_type = get_input_type_for_field(field) new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
new_value = current_value if new_value is None else int(new_value) new_value = current_value if new_value is None else int(new_value)
menu_state.start_index.pop() state.start_index.pop()
elif field.type == 2: # Field type 13 corresponds to INT64 elif field.type == 2: # Field type 13 corresponds to INT64
input_type = get_input_type_for_field(field) new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
new_value = current_value if new_value is None else float(new_value) new_value = current_value if new_value is None else float(new_value)
menu_state.start_index.pop() state.start_index.pop()
else: # Handle other field types else: # Handle other field types
input_type = get_input_type_for_field(field) new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
menu_state.start_index.pop() state.start_index.pop()
for key in menu_state.menu_path[3:]: # Skip "Main Menu" for key in state.menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {}) modified_settings = modified_settings.setdefault(key, {})
# Add the new value to the appropriate level # Add the new value to the appropriate level
@@ -523,78 +613,45 @@ def settings_menu(stdscr: object, interface: object) -> None:
enum_value_descriptor = field.enum_type.values_by_number.get(new_value) 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 new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
menu_state.current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
else: else:
menu_state.current_menu = menu_state.current_menu[selected_option] state.current_menu = state.current_menu[selected_option]
menu_state.menu_path.append(selected_option) state.menu_path.append(selected_option)
menu_state.menu_index.append(menu_state.selected_index) state.menu_index.append(state.selected_index)
menu_state.selected_index = 0 state.selected_index = 0
elif key == curses.KEY_LEFT: elif key == curses.KEY_LEFT:
need_redraw = True
# If we are at the main menu and there are unsaved changes, prompt to save
if len(menu_state.menu_path) == 3 and modified_settings:
current_section = menu_state.menu_path[-1]
save_prompt = get_list_input(
f"You have unsaved changes in {current_section}. Save before exiting?",
None,
["Yes", "No", "Cancel"],
mandatory=True,
)
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_changes(interface, modified_settings, menu_state)
logging.info("Changes Saved")
modified_settings.clear()
menu = rebuild_menu_at_current_path(interface, menu_state)
pass
menu_state.need_redraw = True
menu_win.erase() menu_win.erase()
if help_win: help_win.erase()
help_win.erase()
# max_help_lines = 4 # max_help_lines = 4
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.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() menu_win.refresh()
if help_win: help_win.refresh()
help_win.refresh()
# if len(menu_state.menu_path) < 2: if len(state.menu_path) < 2:
# modified_settings.clear() modified_settings.clear()
# Navigate back to the previous menu # Navigate back to the previous menu
if len(menu_state.menu_path) > 1: if len(state.menu_path) > 1:
menu_state.menu_path.pop() state.menu_path.pop()
menu_state.current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
for step in menu_state.menu_path[1:]: for step in state.menu_path[1:]:
menu_state.current_menu = menu_state.current_menu.get(step, {}) state.current_menu = state.current_menu.get(step, {})
menu_state.selected_index = menu_state.menu_index.pop() state.selected_index = state.menu_index.pop()
menu_state.start_index.pop() state.start_index.pop()
elif key == 27: # Escape key elif key == 27: # Escape key
menu_win.erase() menu_win.erase()
menu_win.refresh() menu_win.refresh()
break break
def set_region(interface):
def rebuild_menu_at_current_path(interface, menu_state): node = interface.getNode('^local')
"""Rebuild menus from the device and re-point current_menu to the same path."""
new_menu = generate_menu_from_protobuf(interface)
cur = new_menu["Main Menu"]
for step in menu_state.menu_path[1:]:
cur = cur.get(step, {})
menu_state.current_menu = cur
return new_menu
def set_region(interface: object) -> None:
node = interface.getNode("^local")
device_config = node.localConfig device_config = node.localConfig
lora_descriptor = device_config.lora.DESCRIPTOR lora_descriptor = device_config.lora.DESCRIPTOR
@@ -604,10 +661,10 @@ def set_region(interface: object) -> None:
regions = list(region_name_to_number.keys()) regions = list(region_name_to_number.keys())
new_region_name = get_list_input("Select your region:", "UNSET", regions) new_region_name = get_list_input('Select your region:', 'UNSET', regions)
# Convert region name to corresponding enum number # 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 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.localConfig.lora.region = new_region_number
node.writeConfig("lora") node.writeConfig("lora")

View File

@@ -1,82 +1,27 @@
import json
import logging import logging
import json
import os import os
from typing import Dict
from contact.ui.colors import setup_colors
# Get the parent directory of the script # Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# To test writting to a non-writable directory, you can uncomment the following lines: # Paths
# mkdir /tmp/test_nonwritable json_file_path = os.path.join(parent_dir, "config.json")
# chmod -w /tmp/test_nonwritable log_file_path = os.path.join(parent_dir, "client.log")
# parent_dir = "/tmp/test_nonwritable" db_file_path = os.path.join(parent_dir, "client.db")
def format_json_single_line_arrays(data, indent=4):
def reload_config() -> None:
loaded_config = initialize_config()
assign_config_variables(loaded_config)
setup_colors(reinit=True)
def _is_writable_dir(path: str) -> bool:
"""
Return True if we can create & delete a temp file in `path`.
"""
if not os.path.isdir(path):
return False
test_path = os.path.join(path, ".perm_test_tmp")
try:
with open(test_path, "w", encoding="utf-8") as _tmp:
_tmp.write("ok")
os.remove(test_path)
return True
except OSError:
return False
def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client") -> str:
"""
Choose a writable directory for config artifacts.
"""
if _is_writable_dir(preferred_dir):
return preferred_dir
home = os.path.expanduser("~")
fallback_dir = os.path.join(home, fallback_name)
# Ensure the fallback exists.
os.makedirs(fallback_dir, exist_ok=True)
# If *that* still isn't writable, last-ditch: use a system temp dir.
if not _is_writable_dir(fallback_dir):
import tempfile
fallback_dir = tempfile.mkdtemp(prefix="contact_client_")
return fallback_dir
# Pick the root now.
config_root = _get_config_root(parent_dir)
# Paths (derived from the chosen root)
json_file_path = os.path.join(config_root, "config.json")
log_file_path = os.path.join(config_root, "client.log")
db_file_path = os.path.join(config_root, "client.db")
node_configs_file_path = os.path.join(config_root, "node-configs/")
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
""" """
Formats JSON with arrays on a single line while keeping other elements properly indented. Formats JSON with arrays on a single line while keeping other elements properly indented.
""" """
def format_value(value, current_indent):
def format_value(value: object, current_indent: int) -> str:
if isinstance(value, dict): if isinstance(value, dict):
items = [] items = []
for key, val in value.items(): for key, val in value.items():
items.append(f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}') items.append(
f'{" " * current_indent}"{key}": {format_value(val, current_indent + indent)}'
)
return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}" return "{\n" + ",\n".join(items) + f"\n{' ' * (current_indent - indent)}}}"
elif isinstance(value, list): elif isinstance(value, list):
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]" return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
@@ -85,9 +30,8 @@ def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) ->
return format_value(data, indent) return format_value(data, indent)
# Recursive function to check and update nested dictionaries # Recursive function to check and update nested dictionaries
def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool: def update_dict(default, actual):
updated = False updated = False
for key, value in default.items(): for key, value in default.items():
if key not in actual: if key not in actual:
@@ -98,8 +42,7 @@ def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
updated = update_dict(value, actual[key]) or updated updated = update_dict(value, actual[key]) or updated
return updated return updated
def initialize_config():
def initialize_config() -> Dict[str, object]:
COLOR_CONFIG_DARK = { COLOR_CONFIG_DARK = {
"default": ["white", "black"], "default": ["white", "black"],
"background": [" ", "black"], "background": [" ", "black"],
@@ -124,7 +67,7 @@ def initialize_config() -> Dict[str, object]:
"settings_warning": ["red", "black"], "settings_warning": ["red", "black"],
"settings_note": ["green", "black"], "settings_note": ["green", "black"],
"node_favorite": ["green", "black"], "node_favorite": ["green", "black"],
"node_ignored": ["red", "black"], "node_ignored": ["red", "black"]
} }
COLOR_CONFIG_LIGHT = { COLOR_CONFIG_LIGHT = {
"default": ["black", "white"], "default": ["black", "white"],
@@ -150,7 +93,7 @@ def initialize_config() -> Dict[str, object]:
"settings_warning": ["red", "white"], "settings_warning": ["red", "white"],
"settings_note": ["green", "white"], "settings_note": ["green", "white"],
"node_favorite": ["green", "white"], "node_favorite": ["green", "white"],
"node_ignored": ["red", "white"], "node_ignored": ["red", "white"]
} }
COLOR_CONFIG_GREEN = { COLOR_CONFIG_GREEN = {
"default": ["green", "black"], "default": ["green", "black"],
@@ -177,20 +120,15 @@ def initialize_config() -> Dict[str, object]:
"settings_breadcrumbs": ["green", "black"], "settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"], "settings_warning": ["green", "black"],
"settings_note": ["green", "black"], "settings_note": ["green", "black"],
"node_favorite": ["cyan", "green"], "node_favorite": ["cyan", "white"],
"node_ignored": ["red", "black"], "node_ignored": ["red", "white"]
} }
default_config_variables = { default_config_variables = {
"channel_list_16ths": "3",
"node_list_16ths": "5",
"single_pane_mode": "False",
"db_file_path": db_file_path, "db_file_path": db_file_path,
"log_file_path": log_file_path, "log_file_path": log_file_path,
"node_configs_file_path": node_configs_file_path,
"message_prefix": ">>", "message_prefix": ">>",
"sent_message_prefix": ">> Sent", "sent_message_prefix": ">> Sent",
"notification_symbol": "*", "notification_symbol": "*",
"notification_sound": "True",
"ack_implicit_str": "[◌]", "ack_implicit_str": "[◌]",
"ack_str": "[✓]", "ack_str": "[✓]",
"nak_str": "[x]", "nak_str": "[x]",
@@ -199,7 +137,7 @@ def initialize_config() -> Dict[str, object]:
"theme": "dark", "theme": "dark",
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK, "COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT, "COLOR_CONFIG_LIGHT": COLOR_CONFIG_LIGHT,
"COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN, "COLOR_CONFIG_GREEN": COLOR_CONFIG_GREEN
} }
if not os.path.exists(json_file_path): if not os.path.exists(json_file_path):
@@ -216,38 +154,30 @@ def initialize_config() -> Dict[str, object]:
# Update the JSON file if any variables were missing # Update the JSON file if any variables were missing
if updated: if updated:
formatted_json = format_json_single_line_arrays(loaded_config) formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file: with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json) json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.") logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
return loaded_config return loaded_config
def assign_config_variables(loaded_config):
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
# Assign values to local variables # Assign values to local variables
global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix global db_file_path, log_file_path, message_prefix, sent_message_prefix
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global node_list_16ths, channel_list_16ths, single_pane_mode
global theme, COLOR_CONFIG global theme, COLOR_CONFIG
global node_sort, notification_sound global node_sort
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
single_pane_mode = loaded_config["single_pane_mode"]
db_file_path = loaded_config["db_file_path"] db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"] log_file_path = loaded_config["log_file_path"]
node_configs_file_path = loaded_config.get("node_configs_file_path")
message_prefix = loaded_config["message_prefix"] message_prefix = loaded_config["message_prefix"]
sent_message_prefix = loaded_config["sent_message_prefix"] sent_message_prefix = loaded_config["sent_message_prefix"]
notification_symbol = loaded_config["notification_symbol"] notification_symbol = loaded_config["notification_symbol"]
notification_sound = loaded_config["notification_sound"]
ack_implicit_str = loaded_config["ack_implicit_str"] ack_implicit_str = loaded_config["ack_implicit_str"]
ack_str = loaded_config["ack_str"] ack_str = loaded_config["ack_str"]
nak_str = loaded_config["nak_str"] nak_str = loaded_config["nak_str"]
ack_unknown_str = loaded_config["ack_unknown_str"] ack_unknown_str = loaded_config["ack_unknown_str"]
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"] theme = loaded_config["theme"]
if theme == "dark": if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"] COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
@@ -255,6 +185,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"] COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
elif theme == "green": elif theme == "green":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"] COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
node_sort = loaded_config["node_sort"]
# Call the function when the script is imported # Call the function when the script is imported
@@ -263,14 +194,13 @@ assign_config_variables(loaded_config)
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig( logging.basicConfig(
filename="default_config.log", filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL) level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s"
) )
print("\nLoaded Configuration:") print("\nLoaded Configuration:")
print(f"Database File Path: {db_file_path}") print(f"Database File Path: {db_file_path}")
print(f"Log File Path: {log_file_path}") print(f"Log File Path: {log_file_path}")
print(f"Configs File Path: {node_configs_file_path}")
print(f"Message Prefix: {message_prefix}") print(f"Message Prefix: {message_prefix}")
print(f"Sent Message Prefix: {sent_message_prefix}") print(f"Sent Message Prefix: {sent_message_prefix}")
print(f"Notification Symbol: {notification_symbol}") print(f"Notification Symbol: {notification_symbol}")
@@ -278,4 +208,4 @@ if __name__ == "__main__":
print(f"ACK String: {ack_str}") print(f"ACK String: {ack_str}")
print(f"NAK String: {nak_str}") print(f"NAK String: {nak_str}")
print(f"ACK Unknown String: {ack_unknown_str}") print(f"ACK Unknown String: {ack_unknown_str}")
print(f"Color Config: {COLOR_CONFIG}") print(f"Color Config: {COLOR_CONFIG}")

View File

@@ -1,63 +1,43 @@
import curses import curses
from contact.ui.colors import get_color from contact.ui.colors import get_color
from contact.utilities.singleton import menu_state, ui_state
def dialog(stdscr, title, message):
height, width = stdscr.getmaxyx()
def dialog(title: str, message: str) -> None: # Calculate dialog dimensions
"""Display a dialog with a title and message.""" max_line_lengh = 0
previous_window = ui_state.current_window
ui_state.current_window = 4
curses.update_lines_cols()
height, width = curses.LINES, curses.COLS
# Parse message into lines and calculate dimensions
message_lines = message.splitlines() message_lines = message.splitlines()
max_line_length = max(len(l) for l in message_lines) for l in message_lines:
max_line_length = max(len(l), max_line_lengh)
dialog_width = max(len(title) + 4, max_line_length + 4) dialog_width = max(len(title) + 4, max_line_length + 4)
dialog_height = len(message_lines) + 4 dialog_height = len(message_lines) + 4
x = (width - dialog_width) // 2 x = (width - dialog_width) // 2
y = (height - dialog_height) // 2 y = (height - dialog_height) // 2
def draw_window(): # Create dialog window
win.erase()
win.bkgd(get_color("background"))
win.attrset(get_color("window_frame"))
win.border(0)
win.addstr(0, 2, title, get_color("settings_default"))
for i, line in enumerate(message_lines):
msg_x = (dialog_width - len(line)) // 2
win.addstr(2 + i, msg_x, line, get_color("settings_default"))
ok_text = " Ok "
win.addstr(
dialog_height - 2,
(dialog_width - len(ok_text)) // 2,
ok_text,
get_color("settings_default", reverse=True),
)
win.refresh()
win = curses.newwin(dialog_height, dialog_width, y, x) win = curses.newwin(dialog_height, dialog_width, y, x)
draw_window() win.bkgd(get_color("background"))
win.attrset(get_color("window_frame"))
win.border(0)
# Add title
win.addstr(0, 2, title, get_color("settings_default"))
# Add message
for i, l in enumerate(message_lines):
win.addstr(2 + i, 2, l, get_color("settings_default"))
# Add button
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", get_color("settings_default", reverse=True))
# Refresh dialog window
win.refresh()
# Get user input
while True: while True:
win.timeout(200)
char = win.getch() char = win.getch()
# Close dialog with enter, space, or esc
if menu_state.need_redraw: if char in(curses.KEY_ENTER, 10, 13, 32, 27):
menu_state.need_redraw = False
draw_window()
if char in (curses.KEY_ENTER, 10, 13, 32, 27): # Enter, space, Esc
win.erase() win.erase()
win.refresh() win.refresh()
ui_state.current_window = previous_window
return return
if char == -1:
continue

View File

@@ -1,44 +1,29 @@
import base64
import logging
import os
from collections import OrderedDict from collections import OrderedDict
from meshtastic.protobuf import config_pb2, module_config_pb2, channel_pb2
from typing import Any, Union, Dict import logging
import base64
from google.protobuf.message import Message import os
from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2
locals_dir = os.path.dirname(os.path.abspath(__file__)) locals_dir = os.path.dirname(os.path.abspath(__file__))
translation_file = os.path.join(locals_dir, "localisations", "en.ini") translation_file = os.path.join(locals_dir, "localisations", "en.ini")
def encode_if_bytes(value):
def encode_if_bytes(value: Any) -> str:
"""Encode byte values to base64 string.""" """Encode byte values to base64 string."""
if isinstance(value, bytes): if isinstance(value, bytes):
return base64.b64encode(value).decode("utf-8") return base64.b64encode(value).decode('utf-8')
return value return value
def extract_fields(message_instance, current_config=None):
def extract_fields(
message_instance: Message, current_config: Union[Message, Dict[str, Any], None] = None
) -> Dict[str, Any]:
if isinstance(current_config, dict): # Handle dictionaries if isinstance(current_config, dict): # Handle dictionaries
return {key: (None, encode_if_bytes(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"): if not hasattr(message_instance, "DESCRIPTOR"):
return {} return {}
menu = {} menu = {}
fields = message_instance.DESCRIPTOR.fields fields = message_instance.DESCRIPTOR.fields
for field in fields: for field in fields:
skip_fields = [ skip_fields = ["sessionkey", "ChannelSettings.channel_num", "ChannelSettings.id", "LoRaConfig.ignore_incoming", "DeviceUIConfig.version"]
"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): if any(skip_field in field.full_name for skip_field in skip_fields):
continue continue
@@ -62,11 +47,7 @@ def extract_fields(
menu[field.name] = (field, encode_if_bytes(current_value)) menu[field.name] = (field, encode_if_bytes(current_value))
return menu return menu
def generate_menu_from_protobuf(interface):
def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
"""
Builds the full settings menu structure from the protobuf definitions.
"""
menu_structure = {"Main Menu": {}} menu_structure = {"Main Menu": {}}
# Add User Settings # Add User Settings
@@ -78,7 +59,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
menu_structure["Main Menu"]["User Settings"] = { menu_structure["Main Menu"]["User Settings"] = {
"longName": (None, current_user_config.get("longName", "Not Set")), "longName": (None, current_user_config.get("longName", "Not Set")),
"shortName": (None, current_user_config.get("shortName", "Not Set")), "shortName": (None, current_user_config.get("shortName", "Not Set")),
"isLicensed": (None, current_user_config.get("isLicensed", "False")), "isLicensed": (None, current_user_config.get("isLicensed", "False"))
} }
else: else:
logging.info("User settings not found in Node Info") logging.info("User settings not found in Node Info")
@@ -106,7 +87,7 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
position_data = { position_data = {
"latitude": (None, current_node_info["position"].get("latitude", 0.0)), "latitude": (None, current_node_info["position"].get("latitude", 0.0)),
"longitude": (None, current_node_info["position"].get("longitude", 0.0)), "longitude": (None, current_node_info["position"].get("longitude", 0.0)),
"altitude": (None, current_node_info["position"].get("altitude", 0)), "altitude": (None, current_node_info["position"].get("altitude", 0))
} }
existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {}) existing_position_menu = menu_structure["Main Menu"]["Radio Settings"].get("position", {})
@@ -125,22 +106,20 @@ def generate_menu_from_protobuf(interface: object) -> Dict[str, Any]:
module = module_config_pb2.ModuleConfig() module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config) menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
# Add App Settings # Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"} menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Additional settings options # Additional settings options
menu_structure["Main Menu"].update( menu_structure["Main Menu"].update({
{ "Export Config File": None,
"Export Config File": None, "Load Config File": None,
"Load Config File": None, "Config URL": None,
"Config URL": None, "Reboot": None,
"Reboot": None, "Reset Node DB": None,
"Reset Node DB": None, "Shutdown": None,
"Shutdown": None, "Factory Reset": None,
"Factory Reset": None, "Exit": None
"Exit": None, })
}
)
return menu_structure return menu_structure

View File

@@ -1,452 +0,0 @@
import curses
import re
from unicodedata import east_asian_width
from contact.ui.colors import get_color
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state
def get_node_color(node_index: int, reverse: bool = False):
node_num = ui_state.node_list[node_index]
node = interface_state.interface.nodesByNum.get(node_num, {})
if node.get("isFavorite"):
return get_color("node_favorite", reverse=reverse)
elif node.get("isIgnored"):
return get_color("node_ignored", reverse=reverse)
return get_color("settings_default", reverse=reverse)
# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
MIN_HEIGHT_FOR_HELP = 20
def move_highlight(
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
) -> None:
show_save_option = None
start_index = [0]
help_text = None
max_help_lines = 0
help_win = None
if "help_win" in kwargs:
help_win = kwargs["help_win"]
if "menu_state" in kwargs:
new_idx = kwargs["menu_state"].selected_index
show_save_option = kwargs["menu_state"].show_save_option
start_index = kwargs["menu_state"].start_index
transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
else:
new_idx = kwargs["selected_index"]
transformed_path = []
if "help_text" in kwargs:
help_text = kwargs["help_text"]
if "max_help_lines" in kwargs:
max_help_lines = kwargs["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)
# Adjust menu_state.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
# Ensure menu_state.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:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(win_h - 2, (win_w - 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:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(
win_h - 2,
(win_w - 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()
# 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 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
# Update help window only if help_text is populated
selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win:
win_h, win_w = menu_win.getmaxyx()
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
win_w,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
def draw_arrows(
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:
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 update_help_window(
help_win: object, # curses window or None
help_text: Dict[str, str],
transformed_path: List[str],
selected_option: Optional[str],
max_help_lines: int,
width: int,
help_y: int,
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
if curses.LINES < MIN_HEIGHT_FOR_HELP:
return None
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)
# Ensure requested width fits on screen from help_x
max_w_from_x = max(1, curses.COLS - help_x)
safe_width = min(width, max_w_from_x)
# Always leave a minimal border area; enforce a minimum usable width of 3
safe_width = max(3, safe_width)
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_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)
# Re-clamp Y to keep the window visible
if help_y + help_height > curses.LINES:
help_y = max(0, curses.LINES - help_height)
# If width would overflow the screen, shrink it
if help_x + safe_width > curses.COLS:
safe_width = max(3, curses.COLS - help_x)
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, safe_width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, safe_width)
try:
help_win.mvwin(help_y, help_x)
except curses.error:
# If moving fails due to edge conditions, pin to (0,0) as a fallback
help_y = 0
help_x = 0
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: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
) -> List[WrappedLine]:
"""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: str) -> List[Segment]:
"""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: List[Segment], wrap_width: int) -> List[WrappedLine]:
"""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 text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
whitespace = "\t\n\x0b\x0c\r "
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" "))
text = text.translate(whitespace_trans)
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 = text_width(word)
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer.strip())
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.strip())
line_buffer = ""
line_length = 0
line_buffer += word
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer.strip())
return wrapped_lines
def move_main_highlight(
old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
) -> None:
if old_idx == new_idx: # No-op
return
max_index = len(options) - 1
visible_height = menu_win.getmaxyx()[0] - 2
if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = new_idx
elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)
if ui_state.current_window == 0: # hack to fix max_index
max_index += 1
draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
menu_win.refresh()
def highlight_line(
menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
) -> None:
if ui_state.current_window == 0:
color_old = (
get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
)
color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
elif ui_state.current_window == 2:
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
ui_state.start_index[ui_state.current_window],
0,
menu_win.getbegyx()[0] + 1,
menu_win.getbegyx()[1] + 1,
menu_win.getbegyx()[0] + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
)
def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:
height, width = win.getmaxyx()
usable_height = height - 2
usable_width = width - 2
if window == 1 and ui_state.display_log:
if log_height := kwargs.get("log_height"):
usable_height -= log_height - 1
if usable_height < max_index:
if ui_state.start_index[window] > 0:
win.addstr(1, usable_width, "", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
if max_index - ui_state.start_index[window] - 1 >= usable_height:
win.addstr(usable_height, usable_width, "", get_color("settings_default"))
else:
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
def get_msg_window_lines(messages_win, packetlog_win) -> None:
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
return messages_win.getmaxyx()[0] - 2 - packetlog_height

View File

@@ -1,9 +1,7 @@
import curses import curses
from contact.ui.colors import get_color from contact.ui.colors import get_color
def draw_splash(stdscr):
def draw_splash(stdscr: object) -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0) curses.curs_set(0)
stdscr.clear() stdscr.clear()
@@ -19,11 +17,11 @@ def draw_splash(stdscr: object) -> None:
start_x2 = width // 2 - len(message_4) // 2 start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1 start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True)) 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+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+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.addstr(start_y+4, start_x2, message_4, get_color("splash_text"))
stdscr.attrset(get_color("window_frame")) stdscr.attrset(get_color("window_frame"))
stdscr.box() stdscr.box()
stdscr.refresh() stdscr.refresh()
curses.napms(500) curses.napms(500)

View File

@@ -1,45 +1,8 @@
from typing import Any, Union, List, Dict
from dataclasses import dataclass, field
@dataclass
class MenuState: class MenuState:
menu_index: List[int] = field(default_factory=list) def __init__(self):
start_index: List[int] = field(default_factory=lambda: [0]) self.menu_index = [] # Row we left the previous menus
selected_index: int = 0 self.start_index = [0] # Row to start the menu if it doesn't all fit
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict) self.selected_index = 0 # Selected Row
menu_path: List[str] = field(default_factory=list) self.current_menu = {} # Contents of the current menu
show_save_option: bool = False self.menu_path = [] # Menu Path
need_redraw: bool = False self.show_save_option = False
@dataclass
class ChatUIState:
display_log: bool = False
channel_list: List[str] = field(default_factory=list)
all_messages: Dict[str, List[str]] = field(default_factory=dict)
notifications: List[str] = field(default_factory=list)
packet_buffer: List[str] = field(default_factory=list)
node_list: List[str] = field(default_factory=list)
selected_channel: int = 0
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
last_sent_time: float = 0.0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
@dataclass
class InterfaceState:
interface: Any = None
myNodeNum: int = 0
@dataclass
class AppState:
lock: Any = None

View File

@@ -1,27 +1,16 @@
import os import os
import json import json
import curses import curses
from typing import Any, List, Dict
from contact.ui.colors import get_color, setup_colors, COLOR_MAP from contact.ui.colors import get_color, setup_colors, COLOR_MAP
import contact.ui.default_config as config from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.ui.nav_utils import move_highlight, draw_arrows
from contact.utilities.input_handlers import get_list_input from contact.utilities.input_handlers import get_list_input
from contact.utilities.singleton import menu_state
width = 80
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
save_option = "Save Changes" save_option = "Save Changes"
sensitive_settings = []
def edit_color_pair(key, current_value):
# Compute an effective width that fits the current terminal
def get_effective_width() -> int:
# Leave space for borders; ensure a sane minimum
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
""" """
Allows the user to select a foreground and background color for a key. Allows the user to select a foreground and background color for a key.
""" """
@@ -31,17 +20,15 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
return [fg_color, bg_color] return [fg_color, bg_color]
def edit_value(key, current_value, state):
def edit_value(key: str, current_value: str) -> str:
w = get_effective_width()
height = 10 height = 10
input_width = w - 16 # Allow space for "New Value: " input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2 start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - w) // 2) start_x = (curses.COLS - width) // 2
# Create a centered window # Create a centered window
edit_win = curses.newwin(height, w, start_y, start_x) edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background")) edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame")) edit_win.attrset(get_color("window_frame"))
edit_win.border() edit_win.border()
@@ -50,8 +37,8 @@ def edit_value(key: str, current_value: str) -> str:
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True)) edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default")) edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = w - 4 # Account for border and padding wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)] wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
edit_win.addstr(4 + i, 2, line, get_color("settings_default")) edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
@@ -61,23 +48,12 @@ def edit_value(key: str, current_value: str) -> str:
# Handle theme selection dynamically # Handle theme selection dynamically
if key == "theme": if key == "theme":
# Load theme names dynamically from the JSON # Load theme names dynamically from the JSON
theme_options = [ theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG")
]
return get_list_input("Select Theme", current_value, theme_options) return get_list_input("Select Theme", current_value, theme_options)
elif key == "node_sort": elif key == "node_sort":
sort_options = ["lastHeard", "name", "hops"] sort_options = ['lastHeard', 'name', 'hops']
return get_list_input("Sort By", current_value, sort_options) return get_list_input("Sort By", current_value, sort_options)
elif key == "notification_sound":
sound_options = ["True", "False"]
return get_list_input("Notification Sound", current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input("Single-Pane Mode", current_value, sound_options)
# Standard Input Mode (Scrollable) # Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default")) edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1) curses.curs_set(1)
@@ -86,87 +62,63 @@ def edit_value(key: str, current_value: str) -> str:
user_input = "" user_input = ""
input_position = (7, 13) # Tuple for row and column input_position = (7, 13) # Tuple for row and column
row, col = input_position # Unpack tuple row, col = input_position # Unpack tuple
while True: while True:
if menu_state.need_redraw: visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
curses.update_lines_cols() edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
menu_state.need_redraw = False edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
# Re-create the window to fully reset state
edit_win = curses.newwin(height, w, start_y, start_x)
edit_win.timeout(200)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Redraw static content
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
for i, line in enumerate(wrapped_lines[:4]):
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
visible_text = user_input[scroll_offset : scroll_offset + input_width]
edit_win.addstr(row, col, " " * input_width, get_color("settings_default"))
edit_win.addstr(row, col, visible_text, get_color("settings_default"))
edit_win.refresh() edit_win.refresh()
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
try: if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
key = edit_win.get_wch()
except curses.error:
continue # window not ready — skip this loop
if key in (chr(27), curses.KEY_LEFT):
curses.curs_set(0) curses.curs_set(0)
return current_value return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break break
elif key in (curses.KEY_BACKSPACE, chr(127)): elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: if user_input: # Only process if there's something to delete
user_input = user_input[:-1] user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width: if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1 scroll_offset -= 1 # Move back if text is shorter than scrolled area
else: else:
if isinstance(key, str): if isinstance(key, str):
user_input += key user_input += key
else: else:
user_input += chr(key) user_input += chr(key)
if len(user_input) > input_width: if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1 scroll_offset += 1
curses.curs_set(0) curses.curs_set(0)
return user_input if user_input else current_value return user_input if user_input else current_value
def display_menu() -> tuple[Any, Any, List[str]]: def display_menu(state):
""" """
Render the configuration menu with a Save button directly added to the window. Render the configuration menu with a Save button directly added to the window.
""" """
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Determine menu items based on the type of current_menu # Determine menu items based on the type of current_menu
if isinstance(menu_state.current_menu, dict): if isinstance(state.current_menu, dict):
options = list(menu_state.current_menu.keys()) options = list(state.current_menu.keys())
elif isinstance(menu_state.current_menu, list): elif isinstance(state.current_menu, list):
options = [f"[{i}]" for i in range(len(menu_state.current_menu))] options = [f"[{i}]" for i in range(len(state.current_menu))]
else: else:
options = [] # Fallback in case of unexpected data types options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu # Calculate dynamic dimensions for the menu
max_menu_height = curses.LINES max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5) menu_height = min(max_menu_height, num_items + 5)
num_items = len(options) num_items = len(options)
w = get_effective_width()
start_y = (curses.LINES - menu_height) // 2 start_y = (curses.LINES - menu_height) // 2
start_x = max(0, (curses.COLS - w) // 2) start_x = (curses.COLS - width) // 2
# Create the window # Create the window
menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase() menu_win.erase()
menu_win.bkgd(get_color("background")) menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame")) menu_win.attrset(get_color("window_frame"))
@@ -174,68 +126,114 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_win.keypad(True) menu_win.keypad(True)
# Create the pad for scrolling # Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, w - 8) menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background")) menu_pad.bkgd(get_color("background"))
# Display the menu path # Display the menu path
header = " > ".join(menu_state.menu_path) header = " > ".join(state.menu_path)
if len(header) > w - 4: if len(header) > width - 4:
header = header[: w - 7] + "..." header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options # Populate the pad with menu options
for idx, key in enumerate(options): for idx, key in enumerate(options):
value = ( value = state.current_menu[key] if isinstance(state.current_menu, dict) else state.current_menu[int(key.strip("[]"))]
menu_state.current_menu[key] display_key = f"{key}"[:width // 2 - 2]
if isinstance(menu_state.current_menu, dict) display_value = (
else menu_state.current_menu[int(key.strip("[]"))] f"{value}"[:width // 2 - 8]
) )
display_key = f"{key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index)) color = get_color("settings_default", reverse=(idx == state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color) menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window # Add Save button to the main window
if menu_state.show_save_option: if state.show_save_option:
save_position = menu_height - 2 save_position = menu_height - 2
menu_win.addstr( menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu))))
save_position,
(w - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
menu_win.refresh() menu_win.refresh()
menu_pad.refresh( menu_pad.refresh(
menu_state.start_index[-1], state.start_index[-1], 0,
0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
) )
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option) draw_arrows(menu_win, visible_height, max_index, state)
return menu_win, menu_pad, options return menu_win, menu_pad, options
def json_editor(stdscr: curses.window, menu_state: Any) -> None: def move_highlight(old_idx, options, menu_win, menu_pad, state):
if old_idx == state.selected_index: # No-op
return
menu_state.selected_index = 0 # Track the selected option max_index = len(options) + (1 if state.show_save_option else 0) - 1
made_changes = False # Track if any changes were made visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
# 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
# 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_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# 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(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(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 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__)) script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
file_path = os.path.join(parent_dir, "config.json") file_path = os.path.join(parent_dir, "config.json")
menu_state.show_save_option = True # Always show the Save button state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# Ensure the file exists # Ensure the file exists
if not os.path.exists(file_path): if not os.path.exists(file_path):
@@ -243,167 +241,133 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
json.dump({}, f) json.dump({}, f)
# Load JSON data # Load JSON data
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, "r") as f:
original_data = json.load(f) original_data = json.load(f)
data = original_data # Reference to the original data data = original_data # Reference to the original data
menu_state.current_menu = data # Track the current level of the menu state.current_menu = data # Track the current level of the menu
# Render the menu # Render the menu
menu_win, menu_pad, options = display_menu() menu_win, menu_pad, options = display_menu(state)
menu_state.need_redraw = True need_redraw = True
while True: while True:
if menu_state.need_redraw: if(need_redraw):
menu_state.need_redraw = False menu_win, menu_pad, options = display_menu(state)
menu_win, menu_pad, options = display_menu()
menu_win.refresh() menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
max_index = len(options) + (1 if state.show_save_option else 0) - 1
menu_win.timeout(200)
key = menu_win.getch() key = menu_win.getch()
if key == curses.KEY_UP: if key == curses.KEY_UP:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
menu_state.help_win = move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, state)
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
menu_state.help_win = move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, state)
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key == ord("\t") and menu_state.show_save_option: elif key == ord("\t") and state.show_save_option:
old_selected_index = menu_state.selected_index old_selected_index = state.selected_index
menu_state.selected_index = max_index state.selected_index = max_index
menu_state.help_win = move_highlight( move_highlight(old_selected_index, options, menu_win, menu_pad, state)
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
menu_state.need_redraw = True need_redraw = True
menu_win.erase() menu_win.erase()
menu_win.refresh() menu_win.refresh()
if menu_state.selected_index < len(options): # Handle selection of a menu item if state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index] selected_key = options[state.selected_index]
menu_state.menu_path.append(str(selected_key)) state.menu_path.append(str(selected_key))
menu_state.start_index.append(0) state.start_index.append(0)
menu_state.menu_index.append(menu_state.selected_index) state.menu_index.append(state.selected_index)
# Handle nested data # Handle nested data
if isinstance(menu_state.current_menu, dict): if isinstance(state.current_menu, dict):
if selected_key in menu_state.current_menu: if selected_key in state.current_menu:
selected_data = menu_state.current_menu[selected_key] selected_data = state.current_menu[selected_key]
else: else:
continue # Skip invalid key continue # Skip invalid key
elif isinstance(menu_state.current_menu, list): elif isinstance(state.current_menu, list):
selected_data = menu_state.current_menu[int(selected_key.strip("[]"))] selected_data = state.current_menu[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2: if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair # Edit color pair
old = selected_data
new_value = edit_color_pair(selected_key, selected_data) new_value = edit_color_pair(selected_key, selected_data)
menu_state.menu_path.pop() state.menu_path.pop()
menu_state.start_index.pop() state.start_index.pop()
menu_state.menu_index.pop() state.menu_index.pop()
menu_state.current_menu[selected_key] = new_value state.current_menu[selected_key] = new_value
if new_value != old:
made_changes = True
elif isinstance(selected_data, (dict, list)): elif isinstance(selected_data, (dict, list)):
# Navigate into nested data # Navigate into nested data
menu_state.current_menu = selected_data state.current_menu = selected_data
menu_state.selected_index = 0 # Reset the selected index state.selected_index = 0 # Reset the selected index
else: else:
# General value editing # General value editing
old = selected_data new_value = edit_value(selected_key, selected_data, state)
new_value = edit_value(selected_key, selected_data) state.menu_path.pop()
menu_state.menu_path.pop() state.menu_index.pop()
menu_state.menu_index.pop() state.start_index.pop()
menu_state.start_index.pop() state.current_menu[selected_key] = new_value
menu_state.current_menu[selected_key] = new_value need_redraw = True
menu_state.need_redraw = True
if new_value != old:
made_changes = True
else: else:
# Save button selected # Save button selected
save_json(file_path, data) save_json(file_path, data)
made_changes = False
stdscr.refresh() stdscr.refresh()
# config.reload() # This isn't refreshing the file paths as expected continue
break
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
menu_state.need_redraw = True need_redraw = True
menu_win.erase() menu_win.erase()
menu_win.refresh() menu_win.refresh()
# menu_state.selected_index = menu_state.menu_index[-1] # state.selected_index = state.menu_index[-1]
# Navigate back in the menu # Navigate back in the menu
if len(menu_state.menu_path) > 2: if len(state.menu_path) > 2:
menu_state.menu_path.pop() state.menu_path.pop()
menu_state.start_index.pop() state.start_index.pop()
menu_state.current_menu = data state.current_menu = data
for path in menu_state.menu_path[2:]: for path in state.menu_path[2:]:
menu_state.current_menu = ( state.current_menu = state.current_menu[path] if isinstance(state.current_menu, dict) else state.current_menu[int(path.strip("[]"))]
menu_state.current_menu[path]
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(path.strip("[]"))]
)
else: else:
# Exit the editor # Exit the editor
if made_changes:
save_prompt = get_list_input(
"You have unsaved changes. Save before exiting?",
None,
["Yes", "No", "Cancel"],
mandatory=True,
)
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_json(file_path, data)
made_changes = False
menu_win.clear() menu_win.clear()
menu_win.refresh() menu_win.refresh()
break break
def save_json(file_path: str, data: Dict[str, Any]) -> None: def save_json(file_path, data):
formatted_json = config.format_json_single_line_arrays(data) formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json) f.write(formatted_json)
setup_colors(reinit=True) setup_colors(reinit=True)
def main(stdscr):
def main(stdscr: curses.window) -> None:
from contact.ui.ui_state import MenuState from contact.ui.ui_state import MenuState
if len(menu_state.menu_path) == 0: state = MenuState()
menu_state.menu_path = ["App Settings"] # Initialize if not set if len(state.menu_path) == 0:
state.menu_path = ["App Settings"] # Initialize if not set
curses.curs_set(0) curses.curs_set(0)
stdscr.keypad(True) stdscr.keypad(True)
setup_colors() setup_colors()
json_editor(stdscr, menu_state) json_editor(stdscr, state)
if __name__ == "__main__": if __name__ == "__main__":
curses.wrapper(main) curses.wrapper(main)

View File

@@ -1,15 +1,11 @@
from argparse import ArgumentParser import argparse
def setup_parser():
parser = argparse.ArgumentParser(
add_help=True,
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.")
def setup_parser() -> ArgumentParser: connOuter = parser.add_argument_group('Connection', 'Optional arguments to specify a device to connect to and how.')
parser = ArgumentParser(
add_help=True,
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.",
)
connOuter = parser.add_argument_group(
"Connection", "Optional arguments to specify a device to connect to and how."
)
conn = connOuter.add_mutually_exclusive_group() conn = connOuter.add_mutually_exclusive_group()
conn.add_argument( conn.add_argument(
"--port", "--port",
@@ -30,10 +26,21 @@ def setup_parser() -> ArgumentParser:
const="localhost", const="localhost",
) )
conn.add_argument( conn.add_argument(
"--ble", "-b", help="The BLE device MAC address or name to connect to.", nargs="?", default=None, const="any" "--ble",
"-b",
help="The BLE device MAC address or name to connect to.",
nargs="?",
default=None,
const="any"
) )
parser.add_argument( parser.add_argument(
"--settings", "--set", "--control", "-c", help="Launch directly into the settings", action="store_true" "--settings",
"--set",
"--control",
"-c",
help="Launch directly into the settings",
action="store_true"
) )
return parser
return parser

View File

@@ -1,14 +1,13 @@
import yaml import yaml
import logging import logging
import time
from typing import List from typing import List
from google.protobuf.json_format import MessageToDict from google.protobuf.json_format import MessageToDict
from meshtastic import mt_config from meshtastic import BROADCAST_ADDR, mt_config
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main # defs are from meshtastic/python/main
def traverseConfig(config_root, config, interface_config) -> bool: def traverseConfig(config_root, config, interface_config) -> bool:
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference""" """Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
snake_name = camel_to_snake(config_root) snake_name = camel_to_snake(config_root)
@@ -21,7 +20,6 @@ def traverseConfig(config_root, config, interface_config) -> bool:
return True return True
def splitCompoundName(comp_name: str) -> List[str]: def splitCompoundName(comp_name: str) -> List[str]:
"""Split compound (dot separated) preference name into parts""" """Split compound (dot separated) preference name into parts"""
name: List[str] = comp_name.split(".") name: List[str] = comp_name.split(".")
@@ -30,7 +28,6 @@ def splitCompoundName(comp_name: str) -> List[str]:
name.append(comp_name) name.append(comp_name)
return name return name
def setPref(config, comp_name, raw_val) -> bool: def setPref(config, comp_name, raw_val) -> bool:
"""Set a channel or preferences value""" """Set a channel or preferences value"""
@@ -78,7 +75,9 @@ def setPref(config, comp_name, raw_val) -> bool:
if e: if e:
val = e.number val = e.number
else: else:
logging.info(f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it.") logging.info(
f"{name[0]}.{uni_name} does not have an enum called {val}, so you can not set it."
)
logging.info(f"Choices in sorted order are:") logging.info(f"Choices in sorted order are:")
names = [] names = []
for f in enumType.values: for f in enumType.values:
@@ -123,40 +122,44 @@ def setPref(config, comp_name, raw_val) -> bool:
return True return True
def config_import(interface, filename): def config_import(interface, filename):
with open(filename, encoding="utf8") as file: with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file) configuration = yaml.safe_load(file)
closeNow = True closeNow = True
interface.getNode("^local", False).beginSettingsTransaction() interface.getNode('^local', False).beginSettingsTransaction()
if "owner" in configuration: if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}") logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True waitForAckNak = True
interface.getNode("^local", False).setOwner(configuration["owner"]) interface.getNode('^local', False).setOwner(configuration["owner"])
time.sleep(0.5)
if "owner_short" in configuration: if "owner_short" in configuration:
logging.info(f"Setting device owner short to {configuration['owner_short']}") logging.info(
f"Setting device owner short to {configuration['owner_short']}"
)
waitForAckNak = True waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"]) interface.getNode('^local', False).setOwner(
time.sleep(0.5) long_name=None, short_name=configuration["owner_short"]
)
if "ownerShort" in configuration: if "ownerShort" in configuration:
logging.info(f"Setting device owner short to {configuration['ownerShort']}") logging.info(
f"Setting device owner short to {configuration['ownerShort']}"
)
waitForAckNak = True waitForAckNak = True
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"]) interface.getNode('^local', False).setOwner(
time.sleep(0.5) long_name=None, short_name=configuration["ownerShort"]
)
if "channel_url" in configuration: if "channel_url" in configuration:
logging.info(f"Setting channel url to {configuration['channel_url']}") logging.info(f"Setting channel url to {configuration['channel_url']}")
interface.getNode("^local").setURL(configuration["channel_url"]) interface.getNode('^local').setURL(configuration["channel_url"])
time.sleep(0.5)
if "channelUrl" in configuration: if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}") logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode("^local").setURL(configuration["channelUrl"]) interface.getNode('^local').setURL(configuration["channelUrl"])
time.sleep(0.5)
if "location" in configuration: if "location" in configuration:
alt = 0 alt = 0
@@ -175,30 +178,34 @@ def config_import(interface, filename):
logging.info(f"Fixing longitude at {lon} degrees") logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position") logging.info("Setting device position")
interface.localNode.setFixedPosition(lat, lon, alt) interface.localNode.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
if "config" in configuration: if "config" in configuration:
localConfig = interface.getNode("^local").localConfig localConfig = interface.getNode('^local').localConfig
for section in configuration["config"]: for section in configuration["config"]:
traverseConfig(section, configuration["config"][section], localConfig) traverseConfig(
interface.getNode("^local").writeConfig(camel_to_snake(section)) section, configuration["config"][section], localConfig
time.sleep(0.5) )
interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
if "module_config" in configuration: if "module_config" in configuration:
moduleConfig = interface.getNode("^local").moduleConfig moduleConfig = interface.getNode('^local').moduleConfig
for section in configuration["module_config"]: for section in configuration["module_config"]:
traverseConfig( traverseConfig(
section, section,
configuration["module_config"][section], configuration["module_config"][section],
moduleConfig, moduleConfig,
) )
interface.getNode("^local").writeConfig(camel_to_snake(section)) interface.getNode('^local').writeConfig(
time.sleep(0.5) camel_to_snake(section)
)
interface.getNode("^local", False).commitSettingsTransaction() interface.getNode('^local', False).commitSettingsTransaction()
logging.info("Writing modified configuration to device") logging.info("Writing modified configuration to device")
def config_export(interface) -> str: def config_export(interface) -> str:
"""used in --export-config""" """used in --export-config"""
configObj = {} configObj = {}
@@ -231,7 +238,7 @@ def config_export(interface) -> str:
if alt: if alt:
configObj["location"]["alt"] = alt configObj["location"]["alt"] = alt
config = MessageToDict(interface.localNode.localConfig) # checkme - Used as a dictionary here and a string below config = MessageToDict(interface.localNode.localConfig) #checkme - Used as a dictionary here and a string below
if config: if config:
# Convert inner keys to correct snake/camelCase # Convert inner keys to correct snake/camelCase
prefs = {} prefs = {}
@@ -242,15 +249,15 @@ def config_export(interface) -> str:
prefs[pref] = config[pref] prefs[pref] = config[pref]
# mark base64 encoded fields as such # mark base64 encoded fields as such
if pref == "security": if pref == "security":
if "privateKey" in prefs[pref]: if 'privateKey' in prefs[pref]:
prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"] prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
if "publicKey" in prefs[pref]: if 'publicKey' in prefs[pref]:
prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"] prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
if "adminKey" in prefs[pref]: if 'adminKey' in prefs[pref]:
for i in range(len(prefs[pref]["adminKey"])): for i in range(len(prefs[pref]['adminKey'])):
prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i] prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
if mt_config.camel_case: if mt_config.camel_case:
configObj["config"] = config # Identical command here and 2 lines below? configObj["config"] = config #Identical command here and 2 lines below?
else: else:
configObj["config"] = config configObj["config"] = config
@@ -266,9 +273,9 @@ def config_export(interface) -> str:
else: else:
configObj["module_config"] = prefs configObj["module_config"] = prefs
config_txt = "# start of Meshtastic configure yaml\n" # checkme - "config" (now changed to config_out) config_txt = "# start of Meshtastic configure yaml\n" #checkme - "config" (now changed to config_out)
# was used as a string here and a Dictionary above #was used as a string here and a Dictionary above
config_txt += yaml.dump(configObj) config_txt += yaml.dump(configObj)
# logging.info(config_txt) # logging.info(config_txt)
return config_txt return config_txt

View File

@@ -1,34 +1,31 @@
from typing import Optional, Tuple, Dict, List
import re import re
def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]: def parse_ini_file(ini_file_path):
"""Parses an INI file and returns a mapping of keys to human-readable names and help text.""" field_mapping = {}
help_text = {}
current_section = None
field_mapping: Dict[str, str] = {} with open(ini_file_path, 'r', encoding='utf-8') as f:
help_text: Dict[str, str] = {}
current_section: Optional[str] = None
with open(ini_file_path, "r", encoding="utf-8") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
# Skip empty lines and comments # Skip empty lines and comments
if not line or line.startswith(";") or line.startswith("#"): if not line or line.startswith(';') or line.startswith('#'):
continue continue
# Handle sections like [config.device] # Handle sections like [config.device]
if line.startswith("[") and line.endswith("]"): if line.startswith('[') and line.endswith(']'):
current_section = line[1:-1] current_section = line[1:-1]
continue continue
# Parse lines like: key, "Human-readable name", "helptext" # Parse lines like: key, "Human-readable name", "helptext"
parts = [p.strip().strip('"') for p in line.split(",", 2)] parts = [p.strip().strip('"') for p in line.split(',', 2)]
if len(parts) >= 2: if len(parts) >= 2:
key = parts[0] key = parts[0]
# If key is 'title', map directly to the section # If key is 'title', map directly to the section
if key == "title": if key == 'title':
full_key = current_section full_key = current_section
else: else:
full_key = f"{current_section}.{key}" if current_section else key full_key = f"{current_section}.{key}" if current_section else key
@@ -49,18 +46,20 @@ def parse_ini_file(ini_file_path: str) -> Tuple[Dict[str, str], Dict[str, str]]:
return field_mapping, help_text return field_mapping, help_text
def transform_menu_path(menu_path):
def transform_menu_path(menu_path: List[str]) -> List[str]:
"""Applies path replacements and normalizes entries in the menu path.""" """Applies path replacements and normalizes entries in the menu path."""
path_replacements = {"Radio Settings": "config", "Module Settings": "module"} path_replacements = {
"Radio Settings": "config",
"Module Settings": "module"
}
transformed_path: List[str] = [] transformed_path = []
for part in menu_path[1:]: # Skip 'Main Menu' for part in menu_path[1:]: # Skip 'Main Menu'
# Apply fixed replacements # Apply fixed replacements
part = path_replacements.get(part, part) part = path_replacements.get(part, part)
# Normalize entries like "Channel 1", "Channel 2", etc. # Normalize entries like "Channel 1", "Channel 2", etc.
if re.match(r"Channel\s+\d+", part, re.IGNORECASE): if re.match(r'Channel\s+\d+', part, re.IGNORECASE):
part = "channel" part = "channel"
transformed_path.append(part) transformed_path.append(part)

View File

@@ -2,33 +2,29 @@ import sqlite3
import time import time
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import decimal_to_hex from contact.utilities.utils import decimal_to_hex
import contact.ui.default_config as config import contact.ui.default_config as config
import contact.globals as globals
def get_table_name(channel):
from contact.utilities.singleton import ui_state, interface_state
def get_table_name(channel: str) -> str:
# Construct the table name # Construct the table name
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages" table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
return quoted_table_name return quoted_table_name
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]: def save_message_to_db(channel, user_id, message_text):
"""Save messages to the database, ensuring the table exists.""" """Save messages to the database, ensuring the table exists."""
try: try:
quoted_table_name = get_table_name(channel) quoted_table_name = get_table_name(channel)
schema = """ schema = '''
user_id TEXT, user_id TEXT,
message_text TEXT, message_text TEXT,
timestamp INTEGER, timestamp INTEGER,
ack_type TEXT ack_type TEXT
""" '''
ensure_table_exists(quoted_table_name, schema) ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
@@ -36,10 +32,10 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
timestamp = int(time.time()) timestamp = int(time.time())
# Insert the message # Insert the message
insert_query = f""" insert_query = f'''
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type) INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
""" '''
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None)) db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_connection.commit() db_connection.commit()
@@ -51,7 +47,7 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optiona
logging.error(f"Unexpected error in save_message_to_db: {e}") logging.error(f"Unexpected error in save_message_to_db: {e}")
def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None: def update_ack_nak(channel, timestamp, message, ack):
try: try:
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
@@ -63,7 +59,7 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
message_text = ? message_text = ?
""" """
db_cursor.execute(update_query, (ack, str(interface_state.myNodeNum), timestamp, message)) db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
db_connection.commit() db_connection.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
@@ -73,61 +69,52 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
logging.error(f"Unexpected error in update_ack_nak: {e}") logging.error(f"Unexpected error in update_ack_nak: {e}")
def load_messages_from_db() -> None: def load_messages_from_db():
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list.""" """Load messages from the database for all channels and update globals.all_messages and globals.channel_list."""
try: try:
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?" query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",)) db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()] tables = [row[0] for row in db_cursor.fetchall()]
# Iterate through each table and fetch its messages # Iterate through each table and fetch its messages
for table_name in tables: for table_name in tables:
quoted_table_name = ( quoted_table_name = f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces
f'"{table_name}"' # Quote the table name because we begin with numerics and contain spaces table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({quoted_table_name})')]
)
table_columns = [i[1] for i in db_cursor.execute(f"PRAGMA table_info({quoted_table_name})")]
if "ack_type" not in table_columns: if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT" update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
db_cursor.execute(update_table_query) db_cursor.execute(update_table_query)
query = f"SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}" query = f'SELECT user_id, message_text, timestamp, ack_type FROM {quoted_table_name}'
try: try:
# Fetch all messages from the table # Fetch all messages from the table
db_cursor.execute(query) db_cursor.execute(query)
db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples db_messages = [(row[0], row[1], row[2], row[3]) for row in db_cursor.fetchall()] # Save as tuples
# Extract the channel name from the table name # Extract the channel name from the table name
channel = table_name.split("_")[1] channel = table_name.split("_")[1]
# Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name) # Convert the channel to an integer if it's numeric, otherwise keep it as a string (nodenum vs channel name)
channel = int(channel) if channel.isdigit() else channel channel = int(channel) if channel.isdigit() else channel
# Add the channel to globals.channel_list if not already present
if channel not in globals.channel_list and not is_chat_archived(channel):
globals.channel_list.append(channel)
# Add the channel to ui_state.channel_list if not already present # Ensure the channel exists in globals.all_messages
if channel not in ui_state.channel_list and not is_chat_archived(channel): if channel not in globals.all_messages:
ui_state.channel_list.append(channel) globals.all_messages[channel] = []
# Ensure the channel exists in ui_state.all_messages # Add messages to globals.all_messages grouped by hourly timestamp
if channel not in ui_state.all_messages:
ui_state.all_messages[channel] = []
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {} hourly_messages = {}
for row in db_messages: for user_id, message, timestamp, ack_type in db_messages:
user_id, message, timestamp, ack_type = row hour = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:00')
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages: if hour not in hourly_messages:
hourly_messages[hour] = [] hourly_messages[hour] = []
ack_str = config.ack_unknown_str ack_str = config.ack_unknown_str
if ack_type == "Implicit": if ack_type == "Implicit":
ack_str = config.ack_implicit_str ack_str = config.ack_implicit_str
@@ -136,22 +123,17 @@ def load_messages_from_db() -> None:
elif ack_type == "Nak": elif ack_type == "Nak":
ack_str = config.nak_str ack_str = config.nak_str
if user_id == str(interface_state.myNodeNum): if user_id == str(globals.myNodeNum):
sanitized_message = message.replace("\x00", "") formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", sanitized_message)
else: else:
sanitized_message = message.replace("\x00", "") formatted_message = (f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ", message)
formatted_message = (
f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
)
hourly_messages[hour].append(formatted_message) hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into ui_state.all_messages[channel] # Flatten the hourly messages into globals.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()): for hour, messages in sorted(hourly_messages.items()):
ui_state.all_messages[channel].append((f"-- {hour} --", "")) globals.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages) globals.all_messages[channel].extend(messages)
except sqlite3.Error as e: except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}") logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -160,26 +142,26 @@ def load_messages_from_db() -> None:
logging.error(f"SQLite error in load_messages_from_db: {e}") logging.error(f"SQLite error in load_messages_from_db: {e}")
def init_nodedb() -> None: def init_nodedb():
"""Initialize the node database and update it with nodes from the interface.""" """Initialize the node database and update it with nodes from the interface."""
try: try:
if not interface_state.interface.nodes: if not globals.interface.nodes:
return # No nodes to initialize return # No nodes to initialize
ensure_node_table_exists() # Ensure the table exists before insertion ensure_node_table_exists() # Ensure the table exists before insertion
nodes_snapshot = list(interface_state.interface.nodes.values()) nodes_snapshot = list(globals.interface.nodes.values())
# Insert or update all nodes # Insert or update all nodes
for node in nodes_snapshot: for node in nodes_snapshot:
update_node_info_in_db( update_node_info_in_db(
user_id=node["num"], user_id=node['num'],
long_name=node["user"].get("longName", ""), long_name=node['user'].get('longName', ''),
short_name=node["user"].get("shortName", ""), short_name=node['user'].get('shortName', ''),
hw_model=node["user"].get("hwModel", ""), hw_model=node['user'].get('hwModel', ''),
is_licensed=node["user"].get("isLicensed", "0"), is_licensed=node['user'].get('isLicensed', '0'),
role=node["user"].get("role", "CLIENT"), role=node['user'].get('role', 'CLIENT'),
public_key=node["user"].get("publicKey", ""), public_key=node['user'].get('publicKey', '')
) )
logging.info("Node database initialized successfully.") logging.info("Node database initialized successfully.")
@@ -190,16 +172,16 @@ def init_nodedb() -> None:
logging.error(f"Unexpected error in init_nodedb: {e}") logging.error(f"Unexpected error in init_nodedb: {e}")
def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None: def maybe_store_nodeinfo_in_db(packet):
"""Save nodeinfo unless that record is already there, updating if necessary.""" """Save nodeinfo unless that record is already there, updating if necessary."""
try: try:
user_id = packet["from"] user_id = packet['from']
long_name = packet["decoded"]["user"]["longName"] long_name = packet['decoded']['user']['longName']
short_name = packet["decoded"]["user"]["shortName"] short_name = packet['decoded']['user']['shortName']
hw_model = packet["decoded"]["user"]["hwModel"] hw_model = packet['decoded']['user']['hwModel']
is_licensed = packet["decoded"]["user"].get("isLicensed", "0") is_licensed = packet['decoded']['user'].get('isLicensed', '0')
role = packet["decoded"]["user"].get("role", "CLIENT") role = packet['decoded']['user'].get('role', 'CLIENT')
public_key = packet["decoded"]["user"].get("publicKey", "") public_key = packet['decoded']['user'].get('publicKey', '')
update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key) update_node_info_in_db(user_id, long_name, short_name, hw_model, is_licensed, role, public_key)
@@ -208,44 +190,26 @@ def maybe_store_nodeinfo_in_db(packet: Dict[str, object]) -> None:
except Exception as e: except Exception as e:
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {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: Union[int, str],
long_name: Optional[str] = None,
short_name: Optional[str] = None,
hw_model: Optional[str] = None,
is_licensed: Optional[Union[str, int]] = None,
role: Optional[str] = None,
public_key: Optional[str] = None,
chat_archived: Optional[int] = None,
) -> None:
"""Update or insert node information into the database, preserving unchanged fields.""" """Update or insert node information into the database, preserving unchanged fields."""
try: try:
ensure_node_table_exists() # Ensure the table exists before any operation ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote in case of numeric names 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})")] table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')]
if "chat_archived" not in table_columns: if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER" update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query) db_cursor.execute(update_table_query)
# Fetch existing values to preserve unchanged fields # Fetch existing values to preserve unchanged fields
db_cursor.execute(f"SELECT * FROM {table_name} WHERE user_id = ?", (user_id,)) db_cursor.execute(f'SELECT * FROM {table_name} WHERE user_id = ?', (user_id,))
existing_record = db_cursor.fetchone() existing_record = db_cursor.fetchone()
if existing_record: if existing_record:
( existing_long_name, existing_short_name, existing_hw_model, existing_is_licensed, existing_role, existing_public_key, existing_chat_archived = existing_record[1:]
existing_long_name,
existing_short_name,
existing_hw_model,
existing_is_licensed,
existing_role,
existing_public_key,
existing_chat_archived,
) = existing_record[1:]
long_name = long_name if long_name is not None else existing_long_name long_name = long_name if long_name is not None else existing_long_name
short_name = short_name if short_name is not None else existing_short_name short_name = short_name if short_name is not None else existing_short_name
@@ -264,7 +228,7 @@ def update_node_info_in_db(
chat_archived = chat_archived if chat_archived is not None else 0 chat_archived = chat_archived if chat_archived is not None else 0
# Upsert logic # Upsert logic
upsert_query = f""" upsert_query = f'''
INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived) INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET
@@ -275,10 +239,8 @@ def update_node_info_in_db(
role = excluded.role, role = excluded.role,
public_key = excluded.public_key, public_key = excluded.public_key,
chat_archived = excluded.chat_archived chat_archived = excluded.chat_archived
""" '''
db_cursor.execute( db_cursor.execute(upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived))
upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
)
db_connection.commit() db_connection.commit()
except sqlite3.Error as e: except sqlite3.Error as e:
@@ -287,10 +249,10 @@ def update_node_info_in_db(
logging.error(f"Unexpected error in update_node_info_in_db: {e}") logging.error(f"Unexpected error in update_node_info_in_db: {e}")
def ensure_node_table_exists() -> None: def ensure_node_table_exists():
"""Ensure the node database table exists.""" """Ensure the node database table exists."""
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote for safety table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
schema = """ schema = '''
user_id TEXT PRIMARY KEY, user_id TEXT PRIMARY KEY,
long_name TEXT, long_name TEXT,
short_name TEXT, short_name TEXT,
@@ -299,11 +261,11 @@ def ensure_node_table_exists() -> None:
role TEXT, role TEXT,
public_key TEXT, public_key TEXT,
chat_archived INTEGER chat_archived INTEGER
""" '''
ensure_table_exists(table_name, schema) ensure_table_exists(table_name, schema)
def ensure_table_exists(table_name: str, schema: str) -> None: def ensure_table_exists(table_name, schema):
"""Ensure the given table exists in the database.""" """Ensure the given table exists in the database."""
try: try:
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
@@ -317,10 +279,10 @@ def ensure_table_exists(table_name: str, schema: str) -> None:
logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}") logging.error(f"Unexpected error in ensure_table_exists({table_name}): {e}")
def get_name_from_database(user_id: int, type: str = "long") -> str: def get_name_from_database(user_id, type="long"):
""" """
Retrieve a user's name (long or short) from the node database. Retrieve a user's name (long or short) from the node database.
:param user_id: The user ID to look up. :param user_id: The user ID to look up.
:param type: "long" for long name, "short" for short name. :param type: "long" for long name, "short" for short name.
:return: The retrieved name or the hex of the user id :return: The retrieved name or the hex of the user id
@@ -330,9 +292,9 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
# Construct table name # Construct table name
table_name = f"{str(interface_state.myNodeNum)}_nodedb" table_name = f"{str(globals.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"' # Quote table name for safety nodeinfo_table = f'"{table_name}"' # Quote table name for safety
# Determine the correct column to fetch # Determine the correct column to fetch
column_name = "long_name" if type == "long" else "short_name" column_name = "long_name" if type == "long" else "short_name"
@@ -351,12 +313,11 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
logging.error(f"Unexpected error in get_name_from_database: {e}") logging.error(f"Unexpected error in get_name_from_database: {e}")
return "Unknown" return "Unknown"
def is_chat_archived(user_id):
def is_chat_archived(user_id: int) -> int:
try: try:
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
table_name = f"{str(interface_state.myNodeNum)}_nodedb" table_name = f"{str(globals.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"' nodeinfo_table = f'"{table_name}"'
query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?" query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?"
db_cursor.execute(query, (user_id,)) db_cursor.execute(query, (user_id,))
@@ -371,3 +332,4 @@ def is_chat_archived(user_id: int) -> int:
except Exception as e: except Exception as e:
logging.error(f"Unexpected error in is_chat_archived: {e}") logging.error(f"Unexpected error in is_chat_archived: {e}")
return "Unknown" return "Unknown"

View File

@@ -2,73 +2,56 @@ import base64
import binascii import binascii
import curses import curses
import ipaddress import ipaddress
from typing import Any, Optional, List import re
# Dialogs should be at most 80 cols, but shrink on small terminals
MAX_DIALOG_WIDTH = 80
MIN_DIALOG_WIDTH = 20
def get_dialog_width() -> int:
# Leave 2 columns for borders and clamp to a sane minimum
try:
return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2))
except Exception:
# Fallback if curses not ready yet
return MAX_DIALOG_WIDTH
from contact.ui.colors import get_color from contact.ui.colors import get_color
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
from contact.ui.dialog import dialog
from contact.utilities.validation_rules import get_validation_for
from contact.utilities.singleton import menu_state
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
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None: for word in words:
"""Displays an invalid input message in the given window and redraws if needed.""" word_length = len(word)
cursor_y, cursor_x = window.getyx()
curses.curs_set(0)
dialog("Invalid Input", message)
if redraw_func:
redraw_func() # Redraw the original window content that got obscured
else:
window.refresh()
window.move(cursor_y, cursor_x)
curses.curs_set(1)
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
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]: 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.""" """Handles user input with wrapped text for long prompts."""
def redraw_input_win():
"""Redraw the input window with the current prompt and user input."""
input_win.erase()
input_win.border()
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:
break
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
input_win.addstr(row + 1, col_start, user_input[:first_line_width], get_color("settings_default"))
for i, line in enumerate(wrap_text(user_input[first_line_width:], wrap_width=input_width)):
if row + 2 + i < height - 1:
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
input_win.refresh()
height = 8 height = 8
width = get_dialog_width() width = 80
margin = 2 # Left and right margin margin = 2 # Left and right margin
input_width = width - (2 * margin) # Space available for text input_width = width - (2 * margin) # Space available for text
max_input_rows = height - 4 # Space for input max_input_rows = height - 4 # Space for input
start_y = max(0, (curses.LINES - height) // 2) start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - width) // 2) start_x = (curses.COLS - width) // 2
input_win = curses.newwin(height, width, start_y, start_x) input_win = curses.newwin(height, width, start_y, start_x)
input_win.timeout(200)
input_win.bkgd(get_color("background")) input_win.bkgd(get_color("background"))
input_win.attrset(get_color("window_frame")) input_win.attrset(get_color("window_frame"))
input_win.border() input_win.border()
@@ -76,7 +59,6 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
# Wrap the prompt text # Wrap the prompt text
wrapped_prompt = wrap_text(prompt, wrap_width=input_width) wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
row = 1 row = 1
for line in wrapped_prompt: for line in wrapped_prompt:
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True)) input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
row += 1 row += 1
@@ -85,140 +67,49 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
prompt_text = "Enter new value: " prompt_text = "Enter new value: "
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
input_win.refresh() input_win.refresh()
curses.curs_set(1) curses.curs_set(1)
min_value = 0 max_length = 4 if "shortName" in prompt else None
max_value = 4294967295
min_length = 0
max_length = None
if selected_config is not None:
validation = get_validation_for(selected_config) or {}
min_value = validation.get("min_value", 0)
max_value = validation.get("max_value", 4294967295)
min_length = validation.get("min_length", 0)
max_length = validation.get("max_length")
user_input = "" user_input = ""
# Start user input after the prompt text
col_start = margin + len(prompt_text) col_start = margin + len(prompt_text)
first_line_width = input_width - len(prompt_text) first_line_width = input_width - len(prompt_text) # Available space for first line
while True: while True:
if menu_state.need_redraw: key = input_win.get_wch() # Waits for user input
menu_state.need_redraw = False
redraw_input_win()
try: if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
key = input_win.get_wch()
except curses.error:
continue
if key == chr(27) or key == curses.KEY_LEFT:
input_win.erase() input_win.erase()
input_win.refresh() input_win.refresh()
curses.curs_set(0) curses.curs_set(0)
menu_state.need_redraw = True return None # Exit without saving
return None
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key
menu_state.need_redraw = True break
if not user_input.strip():
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
continue
length = len(user_input)
if min_length == max_length and max_length is not None:
if length != min_length:
invalid_input(
input_win, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
)
continue
else:
if length < min_length:
invalid_input(
input_win,
f"Value must be at least {min_length} characters long.",
redraw_func=redraw_input_win,
)
continue
if max_length is not None and length > max_length:
invalid_input(
input_win,
f"Value must be no more than {max_length} characters long.",
redraw_func=redraw_input_win,
)
continue
if input_type is int:
if not user_input.isdigit():
invalid_input(input_win, "Only numeric digits (09) allowed.", redraw_func=redraw_input_win)
continue
int_val = int(user_input)
if not (min_value <= int_val <= max_value):
invalid_input(
input_win, f"Enter a number between {min_value} and {max_value}.", redraw_func=redraw_input_win
)
continue
curses.curs_set(0)
return int_val
elif input_type is float:
try:
float_val = float(user_input)
if not (min_value <= float_val <= max_value):
invalid_input(
input_win,
f"Enter a number between {min_value} and {max_value}.",
redraw_func=redraw_input_win,
)
continue
except ValueError:
invalid_input(input_win, "Must be a valid floating point number.", redraw_func=redraw_input_win)
continue
else:
curses.curs_set(0)
return float_val
else:
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
if user_input: if user_input:
user_input = user_input[:-1] # Remove last character user_input = user_input[:-1] # Remove last character
elif max_length is None or len(user_input) < max_length: elif max_length is None or len(user_input) < max_length: # Enforce max length
try: if isinstance(key, str):
char = chr(key) if not isinstance(key, str) else key user_input += key
if input_type is int: else:
if char.isdigit() or (char == "-" and len(user_input) == 0): user_input += chr(key)
user_input += char
elif input_type is float:
if (
char.isdigit()
or (char == "." and "." not in user_input)
or (char == "-" and len(user_input) == 0)
):
user_input += char
else:
user_input += char
except ValueError:
pass # Ignore invalid input
# First line must be manually handled before using wrap_text() # First line must be manually handled before using wrap_text()
first_line = user_input[:first_line_width] # Cut to max first line width first_line = user_input[:first_line_width] # Cut to max first line width
remaining_text = user_input[first_line_width:] # Remaining text for wrapping 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 [] wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
# Clear only the input area (without touching prompt text) # Clear only the input area (without touching prompt text)
for i in range(max_input_rows): for i in range(max_input_rows):
if row + 1 + i < height - 1: if row + 1 + i < height - 1:
input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default")) 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 # Redraw the prompt text so it never disappears
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
@@ -234,37 +125,32 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
curses.curs_set(0) curses.curs_set(0)
input_win.erase() input_win.erase()
input_win.refresh() input_win.refresh()
return user_input.strip() return user_input
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]: def get_admin_key_input(current_value):
"""Handles user input for editing up to 3 Admin Keys in Base64 format."""
def to_base64(byte_strings): def to_base64(byte_strings):
"""Convert byte values to Base64-encoded strings.""" """Convert byte values to Base64-encoded strings."""
return [base64.b64encode(b).decode() for b in byte_strings] return [base64.b64encode(b).decode() for b in byte_strings]
def is_valid_base64(s): def is_valid_base64(s):
"""Check if a string is valid Base64 or blank.""" """Check if a string is valid Base64."""
if s == "":
return True
try: try:
decoded = base64.b64decode(s, validate=True) decoded = base64.b64decode(s, validate=True)
return len(decoded) == 32 # Ensure it's exactly 32 bytes return len(decoded) == 32 # Ensure it's exactly 32 bytes
except (binascii.Error, ValueError): except binascii.Error:
return False return False
cvalue = to_base64(current_value) # Convert current values to Base64 cvalue = to_base64(current_value) # Convert current values to Base64
height = 9 height = 9
width = get_dialog_width() width = 80
start_y = max(0, (curses.LINES - height) // 2) start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - width) // 2) start_x = (curses.COLS - width) // 2
admin_key_win = curses.newwin(height, width, start_y, start_x) repeated_win = curses.newwin(height, width, start_y, start_x)
admin_key_win.timeout(200) repeated_win.bkgd(get_color("background"))
admin_key_win.bkgd(get_color("background")) repeated_win.attrset(get_color("window_frame"))
admin_key_win.attrset(get_color("window_frame")) repeated_win.keypad(True) # Enable keypad for special keys
admin_key_win.keypad(True) # Enable keypad for special keys
curses.echo() curses.echo()
curses.curs_set(1) curses.curs_set(1)
@@ -272,48 +158,44 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
# Editable list of values (max 3 values) # Editable list of values (max 3 values)
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
cursor_pos = 0 # Track which value is being edited cursor_pos = 0 # Track which value is being edited
invalid_input = "" error_message = ""
while True: while True:
admin_key_win.erase() repeated_win.erase()
admin_key_win.border() repeated_win.border()
admin_key_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True)) repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
# Display current values, allowing editing # Display current values, allowing editing
for i, line in enumerate(user_values): for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line prefix = "" if i == cursor_pos else " " # Highlight the current line
admin_key_win.addstr( repeated_win.addstr(3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
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
)
admin_key_win.addstr(3 + i, 18, line) # Align text for easier editing
# Move cursor to the correct position inside the field # Move cursor to the correct position inside the field
curses.curs_set(1) curses.curs_set(1)
admin_key_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
# Show error message if needed # Show error message if needed
if invalid_input: if error_message:
admin_key_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True)) repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
admin_key_win.refresh() repeated_win.refresh()
key = admin_key_win.getch() key = repeated_win.getch()
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
admin_key_win.erase() repeated_win.erase()
admin_key_win.refresh() repeated_win.refresh()
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
menu_state.need_redraw = True
return None return None
elif key == ord("\n"): # Enter key to save and return elif key == ord('\n'): # Enter key to save and return
menu_state.need_redraw = True
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
return user_values # Return the edited Base64 values return user_values # Return the edited Base64 values
else: else:
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!" error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
elif key == curses.KEY_UP: # Move cursor up elif key == curses.KEY_UP: # Move cursor up
cursor_pos = (cursor_pos - 1) % len(user_values) cursor_pos = (cursor_pos - 1) % len(user_values)
elif key == curses.KEY_DOWN: # Move cursor down elif key == curses.KEY_DOWN: # Move cursor down
@@ -324,252 +206,249 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
else: else:
try: try:
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
invalid_input = "" # Clear error if user starts fixing input error_message = "" # Clear error if user starts fixing input
except ValueError: except ValueError:
pass # Ignore invalid character inputs pass # Ignore invalid character inputs
from contact.utilities.singleton import menu_state # Required if not already imported
def get_repeated_input(current_value):
def get_repeated_input(current_value: List[str]) -> Optional[str]:
height = 9 height = 9
width = get_dialog_width() width = 80
start_y = max(0, (curses.LINES - height) // 2) start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - width) // 2) start_x = (curses.COLS - width) // 2
repeated_win = curses.newwin(height, width, start_y, start_x) repeated_win = curses.newwin(height, width, start_y, start_x)
repeated_win.timeout(200)
repeated_win.bkgd(get_color("background")) repeated_win.bkgd(get_color("background"))
repeated_win.attrset(get_color("window_frame")) repeated_win.attrset(get_color("window_frame"))
repeated_win.keypad(True) repeated_win.keypad(True) # Enable keypad for special keys
curses.echo() curses.echo()
curses.curs_set(1) curses.curs_set(1) # Show the cursor
user_values = current_value[:3] + [""] * (3 - len(current_value)) # Always 3 fields # Editable list of values (max 3 values)
cursor_pos = 0 user_values = current_value[:3]
invalid_input = "" cursor_pos = 0 # Track which value is being edited
error_message = ""
def redraw(): while True:
repeated_win.erase() repeated_win.erase()
repeated_win.border() repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True)) repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
win_h, win_w = repeated_win.getmaxyx() # Display current values, allowing editing
for i, line in enumerate(user_values): for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr( repeated_win.addstr(3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)))
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)) repeated_win.addstr(3 + i, 18, line)
)
repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow
if invalid_input: # Move cursor to the correct position inside the field
win_h, win_w = repeated_win.getmaxyx() curses.curs_set(1)
repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], get_color("settings_default", bold=True)) 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.move(3 + cursor_pos, 18 + len(user_values[cursor_pos]))
repeated_win.refresh() repeated_win.refresh()
key = repeated_win.getch()
while True: if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
if menu_state.need_redraw:
menu_state.need_redraw = False
redraw()
redraw()
try:
key = repeated_win.get_wch()
except curses.error:
continue # ignore timeout or input issues
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow
repeated_win.erase() repeated_win.erase()
repeated_win.refresh() repeated_win.refresh()
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
menu_state.need_redraw = True
return None return None
elif key in ("\n", curses.KEY_ENTER):
elif key == ord('\n'): # Enter key to save and return
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
menu_state.need_redraw = True return ", ".join(user_values)
return ", ".join(user_values).strip() elif key == curses.KEY_UP: # Move cursor up
elif key == curses.KEY_UP: cursor_pos = (cursor_pos - 1) % len(user_values)
cursor_pos = (cursor_pos - 1) % 3 elif key == curses.KEY_DOWN: # Move cursor down
elif key == curses.KEY_DOWN: cursor_pos = (cursor_pos + 1) % len(user_values)
cursor_pos = (cursor_pos + 1) % 3 elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
elif key in (curses.KEY_BACKSPACE, 127): if len(user_values[cursor_pos]) > 0:
user_values[cursor_pos] = user_values[cursor_pos][:-1] user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
else: else:
try: try:
ch = chr(key) if isinstance(key, int) else key user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
if ch.isprintable(): error_message = "" # Clear error if user starts fixing input
user_values[cursor_pos] += ch except ValueError:
invalid_input = "" pass # Ignore invalid character inputs
except Exception:
pass
from contact.utilities.singleton import menu_state # Ensure this is imported def get_fixed32_input(current_value):
cvalue = current_value
current_value = str(ipaddress.IPv4Address(current_value))
def get_fixed32_input(current_value: int) -> int:
original_value = current_value
ip_string = str(ipaddress.IPv4Address(current_value))
height = 10 height = 10
width = get_dialog_width() width = 80
start_y = max(0, (curses.LINES - height) // 2) start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - width) // 2) start_x = (curses.COLS - width) // 2
fixed32_win = curses.newwin(height, width, start_y, start_x) fixed32_win = curses.newwin(height, width, start_y, start_x)
fixed32_win.bkgd(get_color("background")) fixed32_win.bkgd(get_color("background"))
fixed32_win.attrset(get_color("window_frame")) fixed32_win.attrset(get_color("window_frame"))
fixed32_win.keypad(True) fixed32_win.keypad(True)
fixed32_win.timeout(200)
curses.echo() curses.echo()
curses.curs_set(1) curses.curs_set(1)
user_input = "" user_input = ""
def redraw(): while True:
fixed32_win.erase() fixed32_win.erase()
fixed32_win.border() fixed32_win.border()
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", get_color("settings_default", bold=True)) fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
fixed32_win.addstr(3, 2, f"Current: {ip_string}", get_color("settings_default")) fixed32_win.addstr(3, 2, f"Current: {current_value}")
fixed32_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default")) fixed32_win.addstr(5, 2, f"New value: {user_input}")
fixed32_win.refresh() fixed32_win.refresh()
while True: key = fixed32_win.getch()
if menu_state.need_redraw:
menu_state.need_redraw = False
redraw()
redraw() if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
try:
key = fixed32_win.get_wch()
except curses.error:
continue # ignore timeout
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow to cancel
fixed32_win.erase() fixed32_win.erase()
fixed32_win.refresh() fixed32_win.refresh()
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
menu_state.need_redraw = True return cvalue # Return the current value unchanged
return original_value elif key == ord('\n'): # Enter key to validate and save
# Validate IP address
elif key in ("\n", curses.KEY_ENTER):
octets = user_input.split(".") octets = user_input.split(".")
menu_state.need_redraw = True
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets): if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho() curses.noecho()
curses.curs_set(0) curses.curs_set(0)
return int(ipaddress.ip_address(user_input)) fixed32_address = ipaddress.ip_address(user_input)
return int(fixed32_address) # Return the valid IP address
else: else:
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True)) fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
fixed32_win.refresh() fixed32_win.refresh()
curses.napms(1500) curses.napms(1500) # Wait for 1.5 seconds before refreshing
user_input = "" user_input = "" # Clear invalid input
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
elif key in (curses.KEY_BACKSPACE, 127):
user_input = user_input[:-1] user_input = user_input[:-1]
else: else:
try: try:
ch = chr(key) if isinstance(key, int) else key char = chr(key)
if ch.isdigit() or ch == ".": if char.isdigit() or char == ".":
user_input += ch user_input += char # Append only valid characters (digits or dots)
except Exception: except ValueError:
pass # Ignore unprintable inputs pass # Ignore invalid inputs
from typing import List, Optional # ensure Optional is imported def get_list_input(prompt, current_option, list_options):
def get_list_input(
prompt: str, current_option: Optional[str], list_options: List[str], mandatory: bool = False
) -> Optional[str]:
""" """
List selector. 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 selected_index = list_options.index(current_option) if current_option in list_options else 0
height = min(len(list_options) + 5, curses.LINES) height = min(len(list_options) + 5, curses.LINES)
width = get_dialog_width() width = 80
start_y = max(0, (curses.LINES - height) // 2) start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - width) // 2) start_x = (curses.COLS - width) // 2
list_win = curses.newwin(height, width, start_y, start_x) list_win = curses.newwin(height, width, start_y, start_x)
list_win.timeout(200)
list_win.bkgd(get_color("background")) list_win.bkgd(get_color("background"))
list_win.attrset(get_color("window_frame")) list_win.attrset(get_color("window_frame"))
list_win.keypad(True) list_win.keypad(True)
list_pad = curses.newpad(len(list_options) + 1, max(1, width - 8)) list_pad = curses.newpad(len(list_options) + 1, width - 8)
list_pad.bkgd(get_color("background")) 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)
max_index = len(list_options) - 1 max_index = len(list_options) - 1
visible_height = list_win.getmaxyx()[0] - 5 visible_height = list_win.getmaxyx()[0] - 5
def redraw_list_ui(): draw_arrows(list_win, visible_height, max_index, 0)
list_win.erase()
list_win.border()
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
win_h, win_w = list_win.getmaxyx()
pad_w = max(1, win_w - 8)
for idx, item in enumerate(list_options):
color = get_color("settings_default", reverse=(idx == selected_index))
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color)
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,
)
# Recompute visible height each draw in case of resize
vis_h = list_win.getmaxyx()[0] - 5
draw_arrows(list_win, vis_h, max_index, [0], show_save_option=False)
# Initial draw
redraw_list_ui()
while True: while True:
if menu_state.need_redraw: key = list_win.getch()
menu_state.need_redraw = False
redraw_list_ui()
try:
key = list_win.getch()
except curses.error:
continue
if key == curses.KEY_UP: if key == curses.KEY_UP:
old_selected_index = selected_index old_selected_index = selected_index
selected_index = max(0, selected_index - 1) selected_index = max(0, selected_index - 1)
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
old_selected_index = selected_index old_selected_index = selected_index
selected_index = min(len(list_options) - 1, selected_index + 1) selected_index = min(len(list_options) - 1, selected_index + 1)
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad)
elif key == ord('\n'): # Enter key
elif key == ord("\n"): # Enter
list_win.clear() list_win.clear()
list_win.refresh() list_win.refresh()
menu_state.need_redraw = True
return list_options[selected_index] return list_options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left
if mandatory:
continue
list_win.clear() list_win.clear()
list_win.refresh() list_win.refresh()
menu_state.need_redraw = True
return current_option 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
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"))
# Highlight new selection
list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse=True))
list_win.refresh()
# 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] + 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"))

View File

@@ -1,43 +1,24 @@
import logging import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
import contact.globals as globals
def initialize_interface(args): def initialize_interface(args):
try: try:
if args.ble: if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None) return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
elif args.host: elif args.host:
try: return meshtastic.tcp_interface.TCPInterface(args.host)
if ":" in args.host:
tcp_hostname, tcp_port = args.host.split(":")
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
return meshtastic.tcp_interface.TCPInterface(tcp_hostname, portNumber=tcp_port)
except Exception as ex:
logging.error(f"Error connecting to {args.host}. {ex}")
else: else:
try: try:
client = meshtastic.serial_interface.SerialInterface(args.port) client = meshtastic.serial_interface.SerialInterface(args.port)
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex: except PermissionError as ex:
logging.error( logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}"
)
except Exception as ex: except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}") logging.error(f"Unexpected error initializing interface: {ex}")
except OSError as ex:
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
if client.devPath is None: if client.devPath is None:
try: client = meshtastic.tcp_interface.TCPInterface("localhost")
client = meshtastic.tcp_interface.TCPInterface("localhost")
except Exception as ex:
logging.error(f"Error connecting to localhost:{ex}")
return client return client
except Exception as ex: except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}") logging.critical(f"Fatal error initializing interface: {ex}")

View File

@@ -4,8 +4,7 @@ import logging
import base64 import base64
import time import time
def save_changes(interface, modified_settings, state):
def save_changes(interface, modified_settings, menu_state):
""" """
Save changes to the device based on modified settings. Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance :param interface: Meshtastic interface instance
@@ -16,16 +15,16 @@ def save_changes(interface, modified_settings, menu_state):
if not modified_settings: if not modified_settings:
logging.info("No changes to save. modified_settings is empty.") logging.info("No changes to save. modified_settings is empty.")
return return
node = interface.getNode("^local") node = interface.getNode('^local')
admin_key_backup = None admin_key_backup = None
if "admin_key" in modified_settings: if 'admin_key' in modified_settings:
# Get reference to security config # Get reference to security config
security_config = node.localConfig.security security_config = node.localConfig.security
admin_keys = modified_settings["admin_key"] admin_keys = modified_settings['admin_key']
# Filter out empty keys # Filter out empty keys
valid_keys = [key for key in admin_keys if key and key.strip() and key != b""] valid_keys = [key for key in admin_keys if key and key.strip() and key != b'']
if not valid_keys: if not valid_keys:
logging.warning("No valid admin keys provided. Skipping admin key update.") logging.warning("No valid admin keys provided. Skipping admin key update.")
@@ -43,29 +42,29 @@ def save_changes(interface, modified_settings, menu_state):
security_config.admin_key.append(key) security_config.admin_key.append(key)
node.writeConfig("security") node.writeConfig("security")
logging.info("Admin keys updated successfully!") logging.info("Admin keys updated successfully!")
# Backup 'admin_key' before removing it # Backup 'admin_key' before removing it
admin_key_backup = modified_settings.get("admin_key", None) admin_key_backup = modified_settings.get('admin_key', None)
# Remove 'admin_key' from modified_settings to prevent interference # Remove 'admin_key' from modified_settings to prevent interference
del modified_settings["admin_key"] del modified_settings['admin_key']
# Return early if there are no other settings left to process # Return early if there are no other settings left to process
if not modified_settings: if not modified_settings:
return return
if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings": if state.menu_path[1] == "Radio Settings" or state.menu_path[1] == "Module Settings":
config_category = menu_state.menu_path[2].lower() # for radio and module configs config_category = state.menu_path[2].lower() # for radio and module configs
if {"latitude", "longitude", "altitude"} & modified_settings.keys(): if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
lat = float(modified_settings.get("latitude", 0.0)) lat = float(modified_settings.get('latitude', 0.0))
lon = float(modified_settings.get("longitude", 0.0)) lon = float(modified_settings.get('longitude', 0.0))
alt = int(modified_settings.get("altitude", 0)) alt = int(modified_settings.get('altitude', 0))
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}") logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return return
elif menu_state.menu_path[1] == "User Settings": # for user configs elif state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings" config_category = "User Settings"
long_name = modified_settings.get("longName") long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName") short_name = modified_settings.get("shortName")
@@ -74,26 +73,24 @@ def save_changes(interface, modified_settings, menu_state):
node.setOwner(long_name, short_name, is_licensed) node.setOwner(long_name, short_name, is_licensed)
logging.info( logging.info(f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}")
f"Updated {config_category} with Long Name: {long_name}, Short Name: {short_name}, Licensed Mode: {is_licensed}"
)
return return
elif menu_state.menu_path[1] == "Channels": # for channel configs elif state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels" config_category = "Channels"
try: try:
channel = menu_state.menu_path[-1] channel = state.menu_path[-1]
channel_num = int(channel.split()[-1]) - 1 channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e: except (IndexError, ValueError) as e:
channel_num = None channel_num = None
channel = node.channels[channel_num] channel = node.channels[channel_num]
for key, value in modified_settings.items(): for key, value in modified_settings.items():
if key == "psk": # Special case: decode Base64 for psk if key == 'psk': # Special case: decode Base64 for psk
channel.settings.psk = base64.b64decode(value) channel.settings.psk = base64.b64decode(value)
elif key == "position_precision": # Special case: module_settings elif key == 'position_precision': # Special case: module_settings
channel.settings.module_settings.position_precision = value channel.settings.module_settings.position_precision = value
else: else:
setattr(channel.settings, key, value) # Use setattr for other fields setattr(channel.settings, key, value) # Use setattr for other fields
@@ -138,9 +135,7 @@ def save_changes(interface, modified_settings, menu_state):
setattr(field, sub_field, sub_value) setattr(field, sub_field, sub_value)
logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}") logging.info(f"Updated {config_category}.{config_item}.{sub_field} to {sub_value}")
else: else:
logging.warning( logging.warning(f"Sub-field '{sub_field}' not found in {config_category}.{config_item}")
f"Sub-field '{sub_field}' not found in {config_category}.{config_item}"
)
else: else:
logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.") logging.warning(f"Invalid value for {config_category}.{config_item}. Expected dict.")
else: else:
@@ -156,9 +151,9 @@ def save_changes(interface, modified_settings, menu_state):
logging.info(f"Changes written to config category: {config_category}") logging.info(f"Changes written to config category: {config_category}")
if admin_key_backup is not None: if admin_key_backup is not None:
modified_settings["admin_key"] = admin_key_backup modified_settings['admin_key'] = admin_key_backup
except Exception as e: except Exception as e:
logging.error(f"Failed to write configuration for category '{config_category}': {e}") logging.error(f"Failed to write configuration for category '{config_category}': {e}")
except Exception as e: except Exception as e:
logging.error(f"Error saving changes: {e}") logging.error(f"Error saving changes: {e}")

View File

@@ -1,6 +0,0 @@
from contact.ui.ui_state import ChatUIState, InterfaceState, AppState, MenuState
ui_state = ChatUIState()
interface_state = InterfaceState()
app_state = AppState()
menu_state = MenuState()

View File

@@ -1,18 +1,15 @@
import contact.globals as globals
import datetime import datetime
import time
from meshtastic.protobuf import config_pb2 from meshtastic.protobuf import config_pb2
import contact.ui.default_config as config import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
def get_channels(): def get_channels():
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages.""" """Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
node = interface_state.interface.getNode("^local") node = globals.interface.getNode('^local')
device_channels = node.channels device_channels = node.channels
# Clear and rebuild channel list # Clear and rebuild channel list
# ui_state.channel_list = [] # globals.channel_list = []
for device_channel in device_channels: for device_channel in device_channels:
if device_channel.role: if device_channel.role:
@@ -23,75 +20,66 @@ def get_channels():
# If channel name is blank, use the modem preset # If channel name is blank, use the modem preset
lora_config = node.localConfig.lora lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[ modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_enum
].name
channel_name = convert_to_camel_case(modem_preset_string) channel_name = convert_to_camel_case(modem_preset_string)
# Add channel to ui_state.channel_list if not already present # Add channel to globals.channel_list if not already present
if channel_name not in ui_state.channel_list: if channel_name not in globals.channel_list:
ui_state.channel_list.append(channel_name) globals.channel_list.append(channel_name)
# Initialize ui_state.all_messages[channel_name] if it doesn't exist # Initialize globals.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages: if channel_name not in globals.all_messages:
ui_state.all_messages[channel_name] = [] globals.all_messages[channel_name] = []
return ui_state.channel_list
return globals.channel_list
def get_node_list(): def get_node_list():
if interface_state.interface.nodes: if globals.interface.nodes:
my_node_num = interface_state.myNodeNum my_node_num = globals.myNodeNum
def node_sort(node): def node_sort(node):
if config.node_sort == "lastHeard": if(config.node_sort == 'lastHeard'):
return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0 return -node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0
elif config.node_sort == "name": elif(config.node_sort == "name"):
return node["user"]["longName"] return node['user']['longName']
elif config.node_sort == "hops": elif(config.node_sort == "hops"):
return node["hopsAway"] if "hopsAway" in node else 100 return node['hopsAway'] if 'hopsAway' in node else 100
else: else:
return node return node
sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort) sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
# Move favorite nodes to the beginning # Move favorite nodes to the beginning
sorted_nodes = sorted( sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True
)
# Move ignored nodes to the end # Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key=lambda node: node["isIgnored"] if "isIgnored" in node else False) 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] 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 [my_node_num] + node_list # Ensuring your node is always first
return [] return []
def refresh_node_list(): def refresh_node_list():
new_node_list = get_node_list() new_node_list = get_node_list()
if new_node_list != ui_state.node_list: if new_node_list != globals.node_list:
ui_state.node_list = new_node_list globals.node_list = new_node_list
return True return True
return False return False
def get_nodeNum(): def get_nodeNum():
myinfo = interface_state.interface.getMyNodeInfo() myinfo = globals.interface.getMyNodeInfo()
myNodeNum = myinfo["num"] myNodeNum = myinfo['num']
return myNodeNum return myNodeNum
def decimal_to_hex(decimal_number): def decimal_to_hex(decimal_number):
return f"!{decimal_number:08x}" return f"!{decimal_number:08x}"
def convert_to_camel_case(string): def convert_to_camel_case(string):
words = string.split("_") words = string.split('_')
camel_case_string = "".join(word.capitalize() for word in words) camel_case_string = ''.join(word.capitalize() for word in words)
return camel_case_string return camel_case_string
def get_time_val_units(time_delta): def get_time_val_units(time_delta):
value = 0 value = 0
unit = "" unit = ""
@@ -119,13 +107,11 @@ def get_time_val_units(time_delta):
unit = "s" unit = "s"
return (value, unit) return (value, unit)
def get_readable_duration(seconds): def get_readable_duration(seconds):
delta = datetime.timedelta(seconds=seconds) delta = datetime.timedelta(seconds = seconds)
val, units = get_time_val_units(delta) val, units = get_time_val_units(delta)
return f"{val} {units}" return f"{val} {units}"
def get_time_ago(timestamp): def get_time_ago(timestamp):
now = datetime.datetime.now() now = datetime.datetime.now()
dt = datetime.datetime.fromtimestamp(timestamp) dt = datetime.datetime.fromtimestamp(timestamp)
@@ -136,30 +122,3 @@ def get_time_ago(timestamp):
return f"{value} {unit} ago" return f"{value} {unit} ago"
return "now" return "now"
def add_new_message(channel_id, prefix, message):
if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = []
# Timestamp handling
current_timestamp = time.time()
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
# Retrieve the last timestamp if available
channel_messages = ui_state.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:
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
# Add the message
ui_state.all_messages[channel_id].append((prefix,message))

View File

@@ -1,23 +0,0 @@
validation_rules = {
"shortName": {"max_length": 4},
"longName": {"max_length": 32},
"fixed_pin": {"min_length": 6, "max_length": 6},
"position_flags": {"max_length": 3},
"enabled_protocols": {"max_value": 2},
"hop_limit": {"max_value": 7},
"latitude": {"min_value": -90, "max_value": 90},
"longitude": {"min_value": -180, "max_value": 180},
"altitude": {"min_value": -4294967295, "max_value": 4294967295},
"red": {"max_value": 255},
"green": {"max_value": 255},
"blue": {"max_value": 255},
"current": {"max_value": 255},
"position_precision": {"max_value": 32},
}
def get_validation_for(key: str) -> dict:
for rule_key, config in validation_rules.items():
if rule_key in key:
return config
return {}

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "contact" name = "contact"
version = "1.3.17" 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." 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 = [ authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"} {name = "Ben Lipsey",email = "ben@pdxlocations.com"}