Compare commits

..

51 Commits

Author SHA1 Message Date
pdxlocations
056db12911 maybe fix aplay 2025-07-16 18:46:51 -07:00
pdxlocations
685a2d4bf8 bump version 2025-07-14 08:15:16 -07:00
pdxlocations
6ed0cc8c9f Merge pull request #199 from rfschmid/add-traceroute-sent-message-to-history
Add traceroute sent message to history
2025-07-03 10:46:07 -07:00
Russell Schmidt
fc208a9258 Add "Traceroute Sent" to message history 2025-07-03 12:43:18 -05:00
Russell Schmidt
eaf9381bca Refactor message saving
Add common function for saving a message to history, removing some
duplicate code and making traceroutes add timestamps like other messages
do.
2025-07-03 12:43:18 -05:00
pdxlocations
367af9044c Merge pull request #198 from rfschmid/add-node-name-to-traceroute-confirm-dialog
Add node name to traceroute confirm dialog
2025-07-03 10:32:41 -07:00
Russell Schmidt
d8183d9009 Make capitalization consistent 2025-07-03 12:19:24 -05:00
Russell Schmidt
3fb1335be3 Add node name to traceroute confirm dialog 2025-07-03 12:10:56 -05:00
pdxlocations
8b05072786 bump version 2025-06-13 15:33:57 -07:00
pdxlocations
4455781e6c fix types and returns 2025-06-12 16:38:05 -07:00
pdxlocations
0c8aaee415 Merge pull request #197 from rfschmid/redirect-sound-player-output-to-dev-null
Redirect sound player output to dev null
2025-06-12 16:10:26 -07:00
pdxlocations
b97d9f4649 Merge pull request #196 from rfschmid/only-clear-input-text-on-enter-if-sending-message
Only clear input on enter when sending message
2025-06-12 16:09:44 -07:00
Russell Schmidt
4152fb6a21 Redirect sound player output to dev null
On my linux system, the sound playing code goes to aplay. When called,
aplay outputs a message about the file it is playing to stderr, which
causes it to be printed on the input line, which can't be easily
cleared. Redirect output from the audio player executable to dev/null.
Deduplicate sound playing code a bit so we only need one call to
subprocess.run, so I don't have to make this change in three places.
2025-06-12 17:28:30 -05:00
Russell Schmidt
384e36dac2 Only clear input on enter when sending message
We should only clear the input field when the user presses enter if the
user actually sent the message. If selecting a different node to send
to, don't clear input.
2025-06-12 17:23:03 -05:00
pdxlocations
65bca84fe6 minor refactor 2025-06-10 23:24:11 -07:00
pdxlocations
16fa2830fd bump version 2025-06-10 22:29:31 -07:00
pdxlocations
c8f1da99e3 Merge pull request #194 from rfschmid:fix-crash-with-newlines
Fix crash with newlines, message spacing
2025-06-10 22:28:41 -07:00
Russell Schmidt
702250c329 Fix crash with newlines, message spacing 2025-06-10 17:42:14 -05:00
pdxlocations
6291082405 Merge pull request #192 from rfschmid/fix-wrapping-with-wide-chars
Fix crash when wrapping with wide characters
2025-06-10 12:19:02 -07:00
pdxlocations
4fa5148664 Merge pull request #193 from rfschmid/fix-backspace
Fix enter not clearing input
2025-06-10 11:57:06 -07:00
Russell Schmidt
d62ec09eea Fix enter not clearing input
Similar to 981d72e, pressing enter wasn't clearing the input field.
2025-06-10 12:19:03 -05:00
Russell Schmidt
61026dcc73 Fix crash when wrapping with wide characters
Update contact_ui.py to use already-existing custom wrap function
implemented in nav_utils instead of textwrap library. Update custom
wrap_text function to use east_asian_width to determine characters that
can use two columns of width.
2025-06-10 12:17:23 -05:00
pdxlocations
1362d3a219 bump version 2025-06-10 10:02:04 -07:00
pdxlocations
981d72e688 fix backspace 2025-06-10 10:01:44 -07:00
pdxlocations
0b5ec0b3d7 Merge pull request #191 from pdxlocations:refactor-ui-functions
Refactor keypress handling
2025-06-09 23:20:42 -07:00
pdxlocations
cbb4ef9e34 break out key functions 2025-06-09 23:19:28 -07:00
pdxlocations
fecd71f4b7 refactor window sizes 2025-06-09 22:37:51 -07:00
pdxlocations
59edfab451 add notif sound prefs (#190) 2025-06-09 22:15:53 -07:00
pdxlocations
39159099e1 change prints to logging 2025-06-09 19:01:40 -07:00
pdxlocations
02e5368c61 waits in configio 2025-06-09 07:40:07 -07:00
pdxlocations
9d234a75d8 change default configs order 2025-06-06 22:45:06 -07:00
pdxlocations
c7edd602ec Make widths configurable (#189) 2025-06-06 22:37:10 -07:00
pdxlocations
00226c5b4d don't use white in green config (#188) 2025-06-06 22:19:58 -07:00
pdxlocations
243079f8eb Error Handling for play_sound (#187)
* add sound for mac and linux

* add error handling for sounds

* use subprocess
2025-06-06 22:04:18 -07:00
pdxlocations
1e0432642c add sound for mac and linux (#183) 2025-05-29 10:08:12 -07:00
pdxlocations
71f37065bf bump version 2025-05-29 10:03:23 -07:00
pdxlocations
ee6aad5d0a allow blank key (#178) 2025-05-18 16:57:49 -07:00
pdxlocations
478f017de1 bump version 2025-05-18 14:43:47 -07:00
pdxlocations
c96c4edb01 Add Arrows to Main UI (#177)
* init

* convert globals to dataclass

* move lock to app state

* Almost working changes

* more almost working changes

* so close

* mostly working changes

* closer changes

* I think it works!

* working changes

* hack fix

* Merge branch 'main' into refactor-chat-ui

* clean-up
2025-05-18 12:28:03 -07:00
pdxlocations
cc416476f5 Merge pull request #174 from rfschmid/patch-1 2025-04-25 21:20:28 -07:00
Russell Schmidt
7fc1cbc3a9 Fix error "No module named 'ui.ui_state'"
Was unable to run locally at tips due to an import not including the package name.
2025-04-23 17:02:47 -05:00
pdxlocations
78f0775ad5 Convert Globals to Class (#173)
* init

* convert globals to dataclass

* move lock to app state
2025-04-19 21:37:54 -07:00
pdxlocations
43f0929247 fix down arrow in user settings 2025-04-19 16:23:17 -07:00
pdxlocations
941e081e90 bump version 2025-04-19 16:17:10 -07:00
pdxlocations
2e8af740be fix always showing down arrow in settings 2025-04-19 16:16:12 -07:00
pdxlocations
a95f128d8e bump version 2025-04-16 21:50:41 -07:00
pdxlocations
3361e4d2ce working changes (#172) 2025-04-16 21:19:46 -07:00
pdxlocations
3959f0768b add help option (#171) 2025-04-16 20:49:17 -07:00
pdxlocations
99839a8075 cleanup 2025-04-16 20:34:46 -07:00
pdxlocations
792cd3c259 Formatting with Black (#170) 2025-04-16 18:48:11 -07:00
pdxlocations
b0a84b3ef3 Refactor Navigation Functions (#169)
* working changes

* working changes

* working changes

* not working changes

* working changes

* cleanup
2025-04-16 18:33:51 -07:00
27 changed files with 1794 additions and 1810 deletions

View File

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

View File

@@ -24,7 +24,6 @@ import traceback
from pubsub import pub
# Local application
import contact.globals as globals
import contact.ui.default_config as config
from contact.message_handlers.rx_handler import on_receive
from contact.settings import set_region
@@ -36,7 +35,7 @@ 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.utils import get_channels, get_nodeNum, get_node_list
from contact.utilities.singleton import ui_state, interface_state, app_state
# ------------------------------------------------------------------------------
# Environment & Logging Setup
@@ -49,33 +48,35 @@ if os.environ.get("COLORTERM") == "gnome-terminal":
os.environ["TERM"] = "xterm-256color"
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, format="%(asctime)s - %(levelname)s - %(message)s"
)
globals.lock = threading.Lock()
app_state.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(args) -> None:
def initialize_globals(args: object) -> None:
"""Initializes interface and shared globals."""
globals.interface = initialize_interface(args)
interface_state.interface = initialize_interface(args)
# Prompt for region if unset
if globals.interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(globals.interface)
globals.interface.close()
globals.interface = initialize_interface(args)
if interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args)
globals.myNodeNum = get_nodeNum()
globals.channel_list = get_channels()
globals.node_list = get_node_list()
pub.subscribe(on_receive, 'meshtastic.receive')
interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
load_messages_from_db()
@@ -92,12 +93,12 @@ def main(stdscr: curses.window) -> None:
args = setup_parser().parse_args()
if getattr(args, 'settings', False):
if getattr(args, "settings", False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface...")
with globals.lock:
with app_state.lock:
initialize_globals(args)
logging.info("Starting main UI")
@@ -113,6 +114,11 @@ def main(stdscr: curses.window) -> None:
def start() -> None:
"""Launch curses wrapper and redirect logs to file."""
if "--help" in sys.argv or "-h" in sys.argv:
setup_parser().print_help()
sys.exit(0)
with open(config.log_file_path, "a", buffering=1) as log_f:
sys.stdout = log_f
sys.stderr = log_f

View File

@@ -1,13 +0,0 @@
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,9 +1,14 @@
import logging
import time
from datetime import datetime
from typing import Any
import os
import platform
import shutil
import subprocess
from typing import Any, Dict
from contact.utilities.utils import refresh_node_list
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
)
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
@@ -18,10 +23,51 @@ from contact.utilities.db_handler import (
update_node_info_in_db,
)
import contact.ui.default_config as config
import contact.globals as globals
from contact.utilities.singleton import ui_state, interface_state, app_state
def on_receive(packet: dict[str, Any], interface: Any) -> None:
def play_sound():
try:
system = platform.system()
sound_path = None
executable = None
if system == "Darwin": # macOS
sound_path = "/System/Library/Sounds/Ping.aiff"
executable = "afplay"
elif system == "Linux":
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.
@@ -29,94 +75,76 @@ def on_receive(packet: dict[str, Any], interface: Any) -> None:
packet: The received Meshtastic packet as a dictionary.
interface: The Meshtastic interface instance that received the packet.
"""
with globals.lock:
with app_state.lock:
# Update packet log
globals.packet_buffer.append(packet)
if len(globals.packet_buffer) > 20:
ui_state.packet_buffer.append(packet)
if len(ui_state.packet_buffer) > 20:
# Trim buffer to 20 packets
globals.packet_buffer = globals.packet_buffer[-20:]
if globals.display_log:
ui_state.packet_buffer = ui_state.packet_buffer[-20:]
if ui_state.display_log:
draw_packetlog_win()
try:
if 'decoded' not in packet:
if "decoded" not in packet:
return
# Assume any incoming packet could update the last seen time for a node
changed = refresh_node_list()
if(changed):
if changed:
draw_node_list()
if packet['decoded']['portnum'] == 'NODEINFO_APP':
if "user" in packet['decoded'] and "longName" in packet['decoded']["user"]:
if packet["decoded"]["portnum"] == "NODEINFO_APP":
if "user" in packet["decoded"] and "longName" in packet["decoded"]["user"]:
maybe_store_nodeinfo_in_db(packet)
elif packet['decoded']['portnum'] == 'TEXT_MESSAGE_APP':
message_bytes = packet['decoded']['payload']
message_string = message_bytes.decode('utf-8')
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
if config.notification_sound == "True":
play_sound()
message_bytes = packet["decoded"]["payload"]
message_string = message_bytes.decode("utf-8")
refresh_channels = False
refresh_messages = False
if packet.get('channel'):
channel_number = packet['channel']
if packet.get("channel"):
channel_number = packet["channel"]
else:
channel_number = 0
if packet['to'] == globals.myNodeNum:
if packet['from'] in globals.channel_list:
if packet["to"] == interface_state.myNodeNum:
if packet["from"] in ui_state.channel_list:
pass
else:
globals.channel_list.append(packet['from'])
if(packet['from'] not in globals.all_messages):
globals.all_messages[packet['from']] = []
update_node_info_in_db(packet['from'], chat_archived=False)
ui_state.channel_list.append(packet["from"])
if packet["from"] not in ui_state.all_messages:
ui_state.all_messages[packet["from"]] = []
update_node_info_in_db(packet["from"], chat_archived=False)
refresh_channels = True
channel_number = globals.channel_list.index(packet['from'])
channel_number = ui_state.channel_list.index(packet["from"])
if globals.channel_list[channel_number] != globals.channel_list[globals.selected_channel]:
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
refresh_messages = True
# Add received message to the messages list
message_from_id = packet['from']
message_from_string = get_name_from_database(message_from_id, type='short') + ":"
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
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))
add_new_message(channel_id, f"{config.message_prefix} {message_from_string} ", message_string)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], message_from_id, message_string)
save_message_to_db(channel_id, message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")

View File

@@ -1,5 +1,4 @@
from datetime import datetime
from typing import Any
from typing import Any, Dict
import google.protobuf.json_format
from meshtastic import BROADCAST_NUM
@@ -13,29 +12,33 @@ from contact.utilities.db_handler import (
update_node_info_in_db,
)
import contact.ui.default_config as config
import contact.globals as globals
ack_naks: dict[str, dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
from contact.utilities.singleton import ui_state, interface_state
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
# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
def onAckNak(packet: dict[str, Any]) -> None:
def onAckNak(packet: Dict[str, Any]) -> None:
"""
Handles incoming ACK/NAK response packets.
"""
from contact.ui.contact_ui import draw_messages_window
request = packet['decoded']['requestId']
if(request not in ack_naks):
request = packet["decoded"]["requestId"]
if request not in ack_naks:
return
acknak = ack_naks.pop(request)
message = globals.all_messages[acknak['channel']][acknak['messageIndex']][1]
message = ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]][1]
confirm_string = " "
ack_type = None
if(packet['decoded']['routing']['errorReason'] == "NONE"):
if(packet['from'] == globals.myNodeNum): # Ack "from" ourself means implicit ACK
if packet["decoded"]["routing"]["errorReason"] == "NONE":
if packet["from"] == interface_state.myNodeNum: # Ack "from" ourself means implicit ACK
confirm_string = config.ack_implicit_str
ack_type = "Implicit"
else:
@@ -45,15 +48,19 @@ def onAckNak(packet: dict[str, Any]) -> None:
confirm_string = config.nak_str
ack_type = "Nak"
globals.all_messages[acknak['channel']][acknak['messageIndex']] = (config.sent_message_prefix + confirm_string + ": ", message)
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
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 = globals.channel_list.index(acknak['channel'])
if globals.channel_list[channel_number] == globals.channel_list[globals.selected_channel]:
channel_number = ui_state.channel_list.index(acknak["channel"])
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
draw_messages_window()
def on_response_traceroute(packet: dict[str, Any]) -> None:
def on_response_traceroute(packet: Dict[str, Any]) -> None:
"""
Handle traceroute response packets and render the route visually in the UI.
"""
@@ -62,7 +69,7 @@ def on_response_traceroute(packet: dict[str, Any]) -> None:
refresh_channels = 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.ParseFromString(packet["decoded"]["payload"])
@@ -70,82 +77,109 @@ def on_response_traceroute(packet: dict[str, Any]) -> None:
msg_str = "Traceroute to:\n"
route_str = get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}" # Start with destination of response
route_str = (
get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}"
) # Start with destination of response
# SNR list should have one more entry than the route, as the final destination adds its SNR also
lenTowards = 0 if "route" not in msg_dict else len(msg_dict["route"])
snrTowardsValid = "snrTowards" in msg_dict and len(msg_dict["snrTowards"]) == lenTowards + 1
if lenTowards > 0: # Loop through hops in route and add SNR if available
if lenTowards > 0: # Loop through hops in route and add SNR if available
for idx, node_num in enumerate(msg_dict["route"]):
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrTowards"][idx] / 4) if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR else "?") + "dB)"
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (
str(msg_dict["snrTowards"][idx] / 4)
if snrTowardsValid and msg_dict["snrTowards"][idx] != UNK_SNR
else "?"
)
+ "dB)"
)
# End with origin of response
route_str += " --> " + (get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}") \
+ " (" + (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?") + "dB)"
route_str += (
" --> "
+ (get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}")
+ " ("
+ (str(msg_dict["snrTowards"][-1] / 4) if snrTowardsValid and msg_dict["snrTowards"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route towards destination
msg_str += route_str + "\n" # Print the route towards destination
# Only if hopStart is set and there is an SNR entry (for the origin) it's valid, even though route might be empty (direct connection)
lenBack = 0 if "routeBack" not in msg_dict else len(msg_dict["routeBack"])
backValid = "hopStart" in packet and "snrBack" in msg_dict and len(msg_dict["snrBack"]) == lenBack + 1
if backValid:
msg_str += "Back:\n"
route_str = get_name_from_database(packet["from"], 'short') or f"{packet['from']:08x}" # Start with origin of response
route_str = (
get_name_from_database(packet["from"], "short") or f"{packet['from']:08x}"
) # Start with origin of response
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
if lenBack > 0: # Loop through hops in routeBack and add SNR if available
for idx, node_num in enumerate(msg_dict["routeBack"]):
route_str += " --> " + (get_name_from_database(node_num, 'short') or f"{node_num:08x}") \
+ " (" + (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?") + "dB)"
route_str += (
" --> "
+ (get_name_from_database(node_num, "short") or f"{node_num:08x}")
+ " ("
+ (str(msg_dict["snrBack"][idx] / 4) if msg_dict["snrBack"][idx] != UNK_SNR else "?")
+ "dB)"
)
# End with destination of response (us)
route_str += " --> " + (get_name_from_database(packet["to"], 'short') or f"{packet['to']:08x}") \
+ " (" + (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?") + "dB)"
route_str += (
" --> "
+ (get_name_from_database(packet["to"], "short") or f"{packet['to']:08x}")
+ " ("
+ (str(msg_dict["snrBack"][-1] / 4) if msg_dict["snrBack"][-1] != UNK_SNR else "?")
+ "dB)"
)
msg_str += route_str + "\n" # Print the route back to us
msg_str += route_str + "\n" # Print the route back to us
if(packet['from'] not in globals.channel_list):
globals.channel_list.append(packet['from'])
if packet["from"] not in ui_state.channel_list:
ui_state.channel_list.append(packet["from"])
refresh_channels = True
if(is_chat_archived(packet['from'])):
update_node_info_in_db(packet['from'], chat_archived=False)
if is_chat_archived(packet["from"]):
update_node_info_in_db(packet["from"], chat_archived=False)
channel_number = globals.channel_list.index(packet['from'])
channel_number = ui_state.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]:
refresh_messages = True
else:
add_notification(channel_number)
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"
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))
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(globals.channel_list[channel_number], packet['from'], msg_str)
save_message_to_db(channel_id, packet["from"], msg_str)
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
"""
Sends a chat message using the selected channel.
"""
myid = globals.myNodeNum
myid = interface_state.myNodeNum
send_on_channel = 0
channel_id = globals.channel_list[channel]
channel_id = ui_state.channel_list[channel]
if isinstance(channel_id, int):
send_on_channel = 0
destination = channel_id
elif isinstance(channel_id, str):
send_on_channel = channel
sent_message_data = globals.interface.sendText(
sent_message_data = interface_state.interface.sendText(
text=message,
destinationId=destination,
wantAck=True,
@@ -154,45 +188,29 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
channelIndex=send_on_channel,
)
# Add sent message to the messages dictionary
if channel_id not in globals.all_messages:
globals.all_messages[channel_id] = []
# Handle timestamp logic
current_timestamp = int(datetime.now().timestamp()) # Get current timestamp
current_hour = datetime.fromtimestamp(current_timestamp).strftime('%Y-%m-%d %H:00')
# Retrieve the last timestamp if available
channel_messages = globals.all_messages[channel_id]
if channel_messages:
# Check the last entry for a timestamp
for entry in reversed(channel_messages):
if entry[0].startswith("--"):
last_hour = entry[0].strip("- ").strip()
break
else:
last_hour = None
else:
last_hour = None
# Add a new timestamp if it's a new hour
if last_hour != current_hour:
globals.all_messages[channel_id].append((f"-- {current_hour} --", ""))
globals.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
timestamp = save_message_to_db(channel_id, myid, message)
ack_naks[sent_message_data.id] = {'channel': channel_id, 'messageIndex': len(globals.all_messages[channel_id]) - 1, 'timestamp': timestamp}
ack_naks[sent_message_data.id] = {
"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", "")
r = mesh_pb2.RouteDiscovery()
globals.interface.sendData(
interface_state.interface.sendData(
r,
destinationId=globals.node_list[globals.selected_node],
destinationId=channel_id,
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
wantResponse=True,
onResponse=on_response_traceroute,

View File

@@ -17,7 +17,7 @@ from contact.utilities.interfaces import initialize_interface
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
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()
draw_splash(stdscr)
curses.curs_set(0)
@@ -28,7 +28,7 @@ def main(stdscr: curses.window) -> None:
interface = initialize_interface(args)
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":
set_region(interface)
interface.close()
@@ -45,10 +45,10 @@ def main(stdscr: curses.window) -> None:
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,
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__":
@@ -67,4 +67,4 @@ if __name__ == "__main__":
except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code
sys.exit(1) # Exit with an error code

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,11 +2,12 @@ import base64
import curses
import logging
import os
import re
import sys
from typing import List
from contact.utilities.save_to_radio import save_changes
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,
@@ -14,13 +15,12 @@ from contact.utilities.input_handlers import (
get_list_input,
get_admin_key_input,
)
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.colors import get_color
from contact.ui.dialog import dialog
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.ui.menus import generate_menu_from_protobuf
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
from contact.ui.user_config import json_editor
from contact.ui.ui_state import MenuState
from contact.ui.navigation_utils import move_highlight
menu_state = MenuState()
@@ -44,14 +44,8 @@ config_folder = os.path.join(locals_dir, "node-configs")
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = list[Segment]
def display_menu(
menu_state: MenuState,
) -> tuple[object, object]: # curses.window or pad types
def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.window or pad types
min_help_window_height = 6
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
@@ -95,19 +89,10 @@ def display_menu(
try:
color = get_color(
(
"settings_sensitive"
if option in sensitive_settings
else "settings_default"
),
"settings_sensitive" if option in sensitive_settings else "settings_default",
reverse=(idx == menu_state.selected_index),
)
menu_pad.addstr(
idx,
0,
f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8),
color,
)
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
@@ -117,16 +102,11 @@ def display_menu(
save_position,
(width - len(save_option)) // 2,
save_option,
get_color(
"settings_save",
reverse=(menu_state.selected_index == len(menu_state.current_menu)),
),
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(
start_y, start_x, menu_height, max_help_lines, transformed_path, menu_state
)
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, menu_state)
menu_win.refresh()
menu_pad.refresh(
@@ -134,20 +114,14 @@ def display_menu(
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0]
+ 3
+ menu_win.getmaxyx()[0]
- 5
- (2 if menu_state.show_save_option else 0),
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
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 menu_state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
return menu_win, menu_pad
@@ -157,7 +131,7 @@ def draw_help_window(
menu_start_x: int,
menu_height: int,
max_help_lines: int,
transformed_path: list[str],
transformed_path: List[str],
menu_state: MenuState,
) -> None:
@@ -167,261 +141,15 @@ def draw_help_window(
help_win = None # Initialize if it does not exist
selected_option = (
list(menu_state.current_menu.keys())[menu_state.selected_index]
if menu_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_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
width,
help_y,
menu_start_x,
help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x
)
def update_help_window(
help_win: object, # curses window or None
help_text: dict[str, str],
transformed_path: list[str],
selected_option: str | None,
max_help_lines: int,
width: int,
help_y: int,
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
wrapped_help = get_wrapped_help_text(
help_text, transformed_path, selected_option, width, max_help_lines
)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def get_wrapped_help_text(
help_text: dict[str, str],
transformed_path: list[str],
selected_option: str | None,
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 move_highlight(
# old_idx: int,
# options: list[str],
# menu_win: object,
# menu_pad: object,
# help_win: object,
# help_text: dict[str, str],
# max_help_lines: int,
# menu_state: MenuState
# ) -> None:
# if old_idx == menu_state.selected_index: # No-op
# return
# max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
# visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
# # Adjust menu_state.start_index only when moving out of visible range
# if menu_state.selected_index == max_index and menu_state.show_save_option:
# pass
# elif menu_state.selected_index < menu_state.start_index[-1]: # Moving above the visible area
# menu_state.start_index[-1] = menu_state.selected_index
# elif menu_state.selected_index >= menu_state.start_index[-1] + visible_height: # Moving below the visible area
# menu_state.start_index[-1] = menu_state.selected_index - visible_height
# pass
# # Ensure menu_state.start_index is within bounds
# menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1))
# # Clear old selection
# if menu_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 menu_state.show_save_option and menu_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(menu_state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[menu_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(menu_state.start_index[-1], 0,
# menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
# menu_win.getbegyx()[0] + 3 + visible_height,
# menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
# # Update help window
# transformed_path = transform_menu_path(menu_state.menu_path)
# selected_option = options[menu_state.selected_index] if menu_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, menu_state)
def draw_arrows(
win: object, visible_height: int, max_index: int, menu_state: MenuState
) -> None:
# vh = visible_height + (1 if show_save_option else 0)
mi = max_index - (2 if menu_state.show_save_option else 0)
if visible_height < mi:
if menu_state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - menu_state.start_index[-1] >= visible_height + (
0 if menu_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: object, interface: object) -> None:
curses.update_lines_cols()
@@ -441,19 +169,10 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.show_save_option = (
(
len(menu_state.menu_path) > 2
and (
"Radio Settings" in menu_state.menu_path
or "Module Settings" in menu_state.menu_path
)
)
or (
len(menu_state.menu_path) == 2
and "User Settings" in menu_state.menu_path
)
or (
len(menu_state.menu_path) == 3
and "Channels" in menu_state.menu_path
and ("Radio Settings" in menu_state.menu_path or "Module Settings" in menu_state.menu_path)
)
or (len(menu_state.menu_path) == 2 and "User Settings" in menu_state.menu_path)
or (len(menu_state.menu_path) == 3 and "Channels" in menu_state.menu_path)
)
# Display the menu
@@ -468,51 +187,31 @@ def settings_menu(stdscr: object, interface: object) -> None:
# max_help_lines = 4
if key == curses.KEY_UP:
old_idx = menu_state.selected_index
menu_state.selected_index = (
max_index
if menu_state.selected_index == 0
else menu_state.selected_index - 1
)
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
move_highlight(
old_idx,
menu_state.selected_index,
old_selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
menu_state=menu_state,
help_win=help_win,
help_updater=update_help_window,
field_mapping=help_text,
menu_path=transform_menu_path(menu_state.menu_path),
help_text=help_text,
max_help_lines=max_help_lines,
sensitive_mode=True,
)
elif key == curses.KEY_DOWN:
old_idx = menu_state.selected_index
menu_state.selected_index = (
0
if menu_state.selected_index == max_index
else menu_state.selected_index + 1
)
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
move_highlight(
old_idx,
menu_state.selected_index,
old_selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
menu_state=menu_state,
help_win=help_win,
help_updater=update_help_window,
field_mapping=help_text,
menu_path=transform_menu_path(menu_state.menu_path),
help_text=help_text,
max_help_lines=max_help_lines,
sensitive_mode=True,
)
elif key == curses.KEY_RESIZE:
@@ -526,24 +225,19 @@ def settings_menu(stdscr: object, interface: object) -> None:
help_win.refresh()
elif key == ord("\t") and menu_state.show_save_option:
old_idx = menu_state.selected_index
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index
move_highlight(
old_idx,
menu_state.selected_index,
old_selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
menu_state=menu_state,
help_win=help_win,
help_updater=update_help_window,
field_mapping=help_text,
menu_path=transform_menu_path(menu_state.menu_path),
help_text=help_text,
max_help_lines=max_help_lines,
sensitive_mode=True,
)
elif key == curses.KEY_RIGHT or key == ord("\n"):
need_redraw = True
menu_state.start_index.append(0)
@@ -555,9 +249,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_win.refresh()
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(
options
):
if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state)
modified_settings.clear()
logging.info("Changes Saved")
@@ -589,15 +281,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
yaml_file_path = os.path.join(config_folder, filename)
if os.path.exists(yaml_file_path):
overwrite = get_list_input(
f"{filename} already exists. Overwrite?",
None,
["Yes", "No"],
)
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "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()
continue # Return to menu
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
@@ -608,9 +294,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
continue
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}")
except OSError as e:
logging.error(f"OS error while saving config: {e}")
except Exception as e:
@@ -621,17 +305,11 @@ def settings_menu(stdscr: object, interface: object) -> None:
elif selected_option == "Load Config File":
# 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(stdscr, "", " No config files found. Export a config first.")
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
if not file_list:
@@ -641,11 +319,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
filename = get_list_input("Choose a config file", None, file_list)
if filename:
file_path = os.path.join(config_folder, filename)
overwrite = get_list_input(
f"Are you sure you want to load {filename}?",
None,
["Yes", "No"],
)
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes":
config_import(interface, file_path)
menu_state.start_index.pop()
@@ -656,11 +330,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
new_value = get_text_input(f"Config URL is currently: {current_value}")
if new_value is not None:
current_value = new_value
overwrite = get_list_input(
f"Are you sure you want to load this config?",
None,
["Yes", "No"],
)
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
if overwrite == "Yes":
interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node")
@@ -668,9 +338,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
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":
interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu")
@@ -678,9 +346,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
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":
interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu")
@@ -688,9 +354,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
elif selected_option == "Shutdown":
confirmation = get_list_input(
"Are you sure you want to Shutdown?", None, ["Yes", "No"]
)
confirmation = get_list_input("Are you sure you want to Shutdown?", None, ["Yes", "No"])
if confirmation == "Yes":
interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu")
@@ -698,9 +362,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
continue
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":
interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu")
@@ -733,9 +395,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
if selected_option in ["longName", "shortName", "isLicensed"]:
if selected_option in ["longName", "shortName"]:
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}"
)
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
@@ -754,53 +414,37 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
elif selected_option in ["latitude", "longitude", "altitude"]:
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}"
)
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value
menu_state.current_menu[selected_option] = (field, new_value)
for option in ["latitude", "longitude", "altitude"]:
if option in menu_state.current_menu:
modified_settings[option] = menu_state.current_menu[option][
1
]
modified_settings[option] = menu_state.current_menu[option][1]
menu_state.start_index.pop()
elif selected_option == "admin_key":
new_values = get_admin_key_input(current_value)
new_value = (
current_value
if new_values is None
else [base64.b64decode(key) for key in new_values]
)
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
menu_state.start_index.pop()
elif field.type == 8: # Handle boolean type
new_value = get_list_input(
human_readable_name, str(current_value), ["True", "False"]
)
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
if new_value == "Not Set":
pass # Leave it as-is
else:
new_value = new_value == "True" or new_value is True
menu_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 = (
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()
elif field.enum_type: # Enum field
enum_options = {v.name: v.number for v in field.enum_type.values}
new_value_name = get_list_input(
human_readable_name, current_value, list(enum_options.keys())
)
new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys()))
new_value = enum_options.get(new_value_name, current_value)
menu_state.start_index.pop()
@@ -809,23 +453,17 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
elif field.type == 13: # Field type 13 corresponds to UINT32
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}"
)
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else int(new_value)
menu_state.start_index.pop()
elif field.type == 2: # Field type 13 corresponds to INT64
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}"
)
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else float(new_value)
menu_state.start_index.pop()
else: # Handle other field types
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}")
new_value = current_value if new_value is None else new_value
menu_state.start_index.pop()
@@ -837,14 +475,8 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Convert enum string to int
if field and field.enum_type:
enum_value_descriptor = field.enum_type.values_by_number.get(
new_value
)
new_value = (
enum_value_descriptor.name
if enum_value_descriptor
else new_value
)
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
menu_state.current_menu[selected_option] = (field, new_value)
else:
@@ -897,9 +529,7 @@ def set_region(interface: object) -> None:
new_region_name = get_list_input("Select your region:", "UNSET", regions)
# Convert region name to corresponding enum number
new_region_number = region_name_to_number.get(
new_region_name, 0
) # Default to 0 if not found
new_region_number = region_name_to_number.get(new_region_name, 0) # Default to 0 if not found
node.localConfig.lora.region = new_region_number
node.writeConfig("lora")

View File

@@ -1,6 +1,7 @@
import json
import logging
import os
from typing import Dict
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -11,17 +12,17 @@ json_file_path = os.path.join(parent_dir, "config.json")
log_file_path = os.path.join(parent_dir, "client.log")
db_file_path = os.path.join(parent_dir, "client.db")
def format_json_single_line_arrays(data: dict[str, object], indent: int = 4) -> str:
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.
"""
def format_value(value: object, current_indent: int) -> str:
if isinstance(value, dict):
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)}}}"
elif isinstance(value, list):
return f"[{', '.join(json.dumps(el, ensure_ascii=False) for el in value)}]"
@@ -30,8 +31,9 @@ def format_json_single_line_arrays(data: dict[str, object], indent: int = 4) ->
return format_value(data, indent)
# Recursive function to check and update nested dictionaries
def update_dict(default: dict[str, object], actual: dict[str, object]) -> bool:
def update_dict(default: Dict[str, object], actual: Dict[str, object]) -> bool:
updated = False
for key, value in default.items():
if key not in actual:
@@ -42,7 +44,8 @@ def update_dict(default: dict[str, object], actual: dict[str, object]) -> bool:
updated = update_dict(value, actual[key]) or updated
return updated
def initialize_config() -> dict[str, object]:
def initialize_config() -> Dict[str, object]:
COLOR_CONFIG_DARK = {
"default": ["white", "black"],
"background": [" ", "black"],
@@ -67,7 +70,7 @@ def initialize_config() -> dict[str, object]:
"settings_warning": ["red", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["green", "black"],
"node_ignored": ["red", "black"]
"node_ignored": ["red", "black"],
}
COLOR_CONFIG_LIGHT = {
"default": ["black", "white"],
@@ -93,7 +96,7 @@ def initialize_config() -> dict[str, object]:
"settings_warning": ["red", "white"],
"settings_note": ["green", "white"],
"node_favorite": ["green", "white"],
"node_ignored": ["red", "white"]
"node_ignored": ["red", "white"],
}
COLOR_CONFIG_GREEN = {
"default": ["green", "black"],
@@ -120,15 +123,18 @@ def initialize_config() -> dict[str, object]:
"settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["cyan", "white"],
"node_ignored": ["red", "white"]
"node_favorite": ["cyan", "green"],
"node_ignored": ["red", "black"],
}
default_config_variables = {
"channel_list_16ths": "3",
"node_list_16ths": "5",
"db_file_path": db_file_path,
"log_file_path": log_file_path,
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"notification_sound": "True",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
@@ -137,7 +143,7 @@ def initialize_config() -> dict[str, object]:
"theme": "dark",
"COLOR_CONFIG_DARK": COLOR_CONFIG_DARK,
"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):
@@ -154,30 +160,36 @@ def initialize_config() -> dict[str, object]:
# Update the JSON file if any variables were missing
if updated:
formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
formatted_json = format_json_single_line_arrays(loaded_config)
with open(json_file_path, "w", encoding="utf-8") as json_file:
json_file.write(formatted_json)
logging.info(f"JSON file updated with missing default variables and COLOR_CONFIG items.")
return loaded_config
def assign_config_variables(loaded_config: dict[str, object]) -> None:
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
# Assign values to local variables
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 node_list_16ths, channel_list_16ths
global theme, COLOR_CONFIG
global node_sort
global node_sort, notification_sound
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"]
message_prefix = loaded_config["message_prefix"]
sent_message_prefix = loaded_config["sent_message_prefix"]
notification_symbol = loaded_config["notification_symbol"]
notification_sound = loaded_config["notification_sound"]
ack_implicit_str = loaded_config["ack_implicit_str"]
ack_str = loaded_config["ack_str"]
nak_str = loaded_config["nak_str"]
ack_unknown_str = loaded_config["ack_unknown_str"]
node_sort = loaded_config["node_sort"]
theme = loaded_config["theme"]
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
@@ -185,7 +197,6 @@ def assign_config_variables(loaded_config: dict[str, object]) -> None:
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
elif theme == "green":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
node_sort = loaded_config["node_sort"]
# Call the function when the script is imported
@@ -194,9 +205,9 @@ assign_config_variables(loaded_config)
if __name__ == "__main__":
logging.basicConfig(
filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s"
filename="default_config.log",
level=logging.INFO, # DEBUG, INFO, WARNING, ERROR, CRITICAL)
format="%(asctime)s - %(levelname)s - %(message)s",
)
print("\nLoaded Configuration:")
print(f"Database File Path: {db_file_path}")
@@ -208,4 +219,4 @@ if __name__ == "__main__":
print(f"ACK String: {ack_str}")
print(f"NAK String: {nak_str}")
print(f"ACK Unknown String: {ack_unknown_str}")
print(f"Color Config: {COLOR_CONFIG}")
print(f"Color Config: {COLOR_CONFIG}")

View File

@@ -1,6 +1,7 @@
import curses
from contact.ui.colors import get_color
def dialog(stdscr: curses.window, title: str, message: str) -> None:
height, width = stdscr.getmaxyx()
@@ -37,7 +38,7 @@ def dialog(stdscr: curses.window, title: str, message: str) -> None:
while True:
char = win.getch()
# Close dialog with enter, space, or esc
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
if char in (curses.KEY_ENTER, 10, 13, 32, 27):
win.erase()
win.refresh()
return

View File

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

426
contact/ui/nav_utils.py Normal file
View File

@@ -0,0 +1,426 @@
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]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
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:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")
)
else:
menu_pad.chgat(
old_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive")
if options[old_idx] in sensitive_settings
else get_color("settings_default")
),
)
# Highlight new selection
if show_save_option and new_idx == max_index:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
else:
menu_pad.chgat(
new_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive", reverse=True)
if options[new_idx] in sensitive_settings
else get_color("settings_default", reverse=True)
),
)
menu_win.refresh()
# 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:
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
width,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
def draw_arrows(
win: 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."""
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def get_wrapped_help_text(
help_text: 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,120 +0,0 @@
# contact/ui/navigation_utils.py
import curses
from contact.ui.colors import get_color
save_option = "Save Changes"
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
def move_highlight(
old_idx: int,
new_idx: int,
options: list[str],
win: curses.window,
pad: curses.window,
*,
start_index_ref: list[int] = None,
selected_index: int = None,
max_help_lines: int = 0,
show_save: bool = False,
help_win: curses.window = None,
help_updater=None,
field_mapping=None,
menu_path: list[str] = None,
width: int = 80,
sensitive_mode: bool = False,
):
if old_idx == new_idx:
return
max_index = len(options) + (1 if show_save else 0) - 1
visible_height = win.getmaxyx()[0] - 5 - (2 if show_save else 0)
# Scrolling logic
if start_index_ref is not None:
if new_idx == max_index and show_save:
pass
elif new_idx < start_index_ref[0]:
start_index_ref[0] = new_idx
elif new_idx >= start_index_ref[0] + visible_height:
start_index_ref[0] = new_idx - visible_height
start_index_ref[0] = max(
0, min(start_index_ref[0], max_index - visible_height + 1)
)
scroll = start_index_ref[0] if start_index_ref else 0
# Clear previous highlight
if show_save and old_idx == max_index:
win.chgat(
win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save"),
)
else:
color = (
"settings_sensitive"
if sensitive_mode and options[old_idx] in sensitive_settings
else "settings_default"
)
pad.chgat(old_idx, 0, pad.getmaxyx()[1], get_color(color))
# Apply new highlight
if show_save and new_idx == max_index:
win.chgat(
win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
else:
color = (
"settings_sensitive"
if sensitive_mode and options[new_idx] in sensitive_settings
else "settings_default"
)
pad.chgat(new_idx, 0, pad.getmaxyx()[1], get_color(color, reverse=True))
win.refresh()
pad.refresh(
scroll,
0,
win.getbegyx()[0] + 3,
win.getbegyx()[1] + 4,
win.getbegyx()[0] + 3 + visible_height,
win.getbegyx()[1] + win.getmaxyx()[1] - 4,
)
# Optional help update
if help_win and help_updater and menu_path and selected_index is not None:
selected_option = (
options[selected_index] if selected_index < len(options) else None
)
help_y = win.getbegyx()[0] + win.getmaxyx()[0]
help_updater(
help_win,
field_mapping,
menu_path,
selected_option,
max_help_lines,
width,
help_y,
win.getbegyx()[1],
)
def draw_arrows(
win: curses.window, visible_height: int, max_index: int, start_index: int
) -> None:
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,6 +1,7 @@
import curses
from contact.ui.colors import get_color
def draw_splash(stdscr: object) -> None:
"""Draw the splash screen with a logo and connecting message."""
curses.curs_set(0)
@@ -18,11 +19,11 @@ def draw_splash(stdscr: object) -> None:
start_x2 = width // 2 - len(message_4) // 2
start_y = height // 2 - 1
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+1, start_x-1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+2, start_x-2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y+4, start_x2, message_4, get_color("splash_text"))
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
stdscr.attrset(get_color("window_frame"))
stdscr.box()
stdscr.refresh()
curses.napms(500)
curses.napms(500)

View File

@@ -1,10 +1,42 @@
from typing import Any
from typing import Any, Union, List, Dict
from dataclasses import dataclass, field
@dataclass
class MenuState:
def __init__(self):
self.menu_index: list[int]= [] # Row we left the previous menus
self.start_index: list[int] = [0] # Row to start the menu if it doesn't all fit
self.selected_index: int = 0 # Selected Row
self.current_menu: dict[str, Any] | list[Any] | str | int = {} # Contents of the current menu
self.menu_path: list[str] = [] # Menu Path
self.show_save_option: bool = False # Display 'Save'
menu_index: List[int] = field(default_factory=list)
start_index: List[int] = field(default_factory=lambda: [0])
selected_index: int = 0
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict)
menu_path: List[str] = field(default_factory=list)
show_save_option: bool = 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
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)
@dataclass
class InterfaceState:
interface: Any = None
myNodeNum: int = 0
@dataclass
class AppState:
lock: Any = None

View File

@@ -1,28 +1,26 @@
import os
import json
import curses
from typing import Any
from typing import Any, List, Dict
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.ui.nav_utils import move_highlight, draw_arrows
from contact.utilities.input_handlers import get_list_input
from contact.ui.navigation_utils import move_highlight
width = 80
max_help_lines = 6
save_option = "Save Changes"
sensitive_settings = []
def edit_color_pair(key: str, current_value: list[str]) -> list[str]:
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.
"""
color_list = [" "] + list(COLOR_MAP.keys())
fg_color = get_list_input(
f"Select Foreground Color for {key}", current_value[0], color_list
)
bg_color = get_list_input(
f"Select Background Color for {key}", current_value[1], color_list
)
fg_color = get_list_input(f"Select Foreground Color for {key}", current_value[0], color_list)
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
return [fg_color, bg_color]
@@ -45,14 +43,9 @@ def edit_value(key: str, current_value: str) -> str:
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
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.refresh()
@@ -60,16 +53,17 @@ def edit_value(key: str, current_value: str) -> str:
# Handle theme selection dynamically
if key == "theme":
# Load theme names dynamically from the JSON
theme_options = [
k.split("_", 2)[2].lower()
for k in loaded_config.keys()
if k.startswith("COLOR_CONFIG")
]
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
return get_list_input("Select Theme", current_value, theme_options)
elif key == "node_sort":
sort_options = ["lastHeard", "name", "hops"]
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)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
@@ -79,20 +73,12 @@ def edit_value(key: str, current_value: str) -> str:
input_position = (7, 13) # Tuple for row and column
row, col = input_position # Unpack tuple
while True:
visible_text = user_input[
scroll_offset : scroll_offset + input_width
] # Only show what fits
edit_win.addstr(
row, col, " " * input_width, get_color("settings_default")
) # Clear previous text
edit_win.addstr(
row, col, visible_text, get_color("settings_default")
) # Display text
visible_text = user_input[scroll_offset : scroll_offset + input_width] # Only show what fits
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
edit_win.refresh()
edit_win.move(
row, col + min(len(user_input) - scroll_offset, input_width)
) # Adjust cursor position
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
@@ -106,9 +92,7 @@ def edit_value(key: str, current_value: str) -> str:
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= (
1 # Move back if text is shorter than scrolled area
)
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
@@ -122,7 +106,7 @@ def edit_value(key: str, current_value: str) -> str:
return user_input if user_input else current_value
def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]:
def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
"""
Render the configuration menu with a Save button directly added to the window.
"""
@@ -171,15 +155,8 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]:
display_key = f"{key}"[: width // 2 - 2]
display_value = f"{value}"[: width // 2 - 8]
color = get_color(
"settings_default", reverse=(idx == menu_state.selected_index)
)
menu_pad.addstr(
idx,
0,
f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8),
color,
)
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
if menu_state.show_save_option:
@@ -188,10 +165,7 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]:
save_position,
(width - len(save_option)) // 2,
save_option,
get_color(
"settings_save",
reverse=(menu_state.selected_index == len(menu_state.current_menu)),
),
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
menu_win.refresh()
@@ -200,93 +174,18 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]:
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0]
+ 3
+ menu_win.getmaxyx()[0]
- 5
- (2 if menu_state.show_save_option else 0),
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
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 menu_state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, menu_state)
draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, menu_state.show_save_option)
return menu_win, menu_pad, options
# def move_highlight(
# old_idx: int,
# options: list[str],
# menu_win: curses.window,
# menu_pad: curses.window,
# menu_state: Any
# ) -> None:
# if old_idx == menu_state.selected_index: # No-op
# return
# max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
# visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
# # Adjust menu_state.start_index only when moving out of visible range
# if menu_state.selected_index == max_index and menu_state.show_save_option:
# pass
# elif menu_state.selected_index < menu_state.start_index[-1]: # Moving above the visible area
# menu_state.start_index[-1] = menu_state.selected_index
# elif menu_state.selected_index >= menu_state.start_index[-1] + visible_height: # Moving below the visible area
# menu_state.start_index[-1] = menu_state.selected_index - visible_height
# pass
# # Ensure menu_state.start_index is within bounds
# menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1))
# # Clear old selection
# if menu_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 menu_state.show_save_option and menu_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(menu_state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[menu_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(menu_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, menu_state)
def draw_arrows(
win: curses.window, visible_height: int, max_index: int, menu_state: any
) -> None:
mi = max_index - (2 if menu_state.show_save_option else 0)
if visible_height < mi:
if menu_state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - menu_state.start_index[-1] >= visible_height + (
0 if menu_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: curses.window, menu_state: Any) -> None:
menu_state.selected_index = 0 # Track the selected option
@@ -296,6 +195,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
file_path = os.path.join(parent_dir, "config.json")
menu_state.show_save_option = True # Always show the Save button
menu_state.help_win = None
menu_state.help_text = {}
# Ensure the file exists
if not os.path.exists(file_path):
@@ -324,57 +225,25 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
if key == curses.KEY_UP:
old_idx = menu_state.selected_index
menu_state.selected_index = (
max_index
if menu_state.selected_index == 0
else menu_state.selected_index - 1
)
move_highlight(
old_idx,
menu_state.selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
sensitive_mode=True,
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1
menu_state.help_win = move_highlight(
old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines
)
elif key == curses.KEY_DOWN:
old_idx = menu_state.selected_index
menu_state.selected_index = (
0
if menu_state.selected_index == max_index
else menu_state.selected_index + 1
)
move_highlight(
old_idx,
menu_state.selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
sensitive_mode=True,
old_selected_index = menu_state.selected_index
menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1
menu_state.help_win = move_highlight(
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:
old_idx = menu_state.selected_index
old_selected_index = menu_state.selected_index
menu_state.selected_index = max_index
move_highlight(
old_idx,
menu_state.selected_index,
options,
menu_win,
menu_pad,
start_index_ref=menu_state.start_index[-1:],
selected_index=menu_state.selected_index,
show_save=menu_state.show_save_option,
sensitive_mode=True,
menu_state.help_win = move_highlight(
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
@@ -383,9 +252,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_win.erase()
menu_win.refresh()
if menu_state.selected_index < len(
options
): # Handle selection of a menu item
if menu_state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[menu_state.selected_index]
menu_state.menu_path.append(str(selected_key))
menu_state.start_index.append(0)
@@ -398,9 +265,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
else:
continue # Skip invalid key
elif isinstance(menu_state.current_menu, list):
selected_data = menu_state.current_menu[
int(selected_key.strip("[]"))
]
selected_data = menu_state.current_menu[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
@@ -459,7 +324,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
break
def save_json(file_path: str, data: dict[str, Any]) -> None:
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)

View File

@@ -1,11 +1,15 @@
from argparse import ArgumentParser
def setup_parser() -> ArgumentParser:
parser = ArgumentParser(
add_help=True,
epilog="If no connection arguments are specified, we attempt a serial connection and then a TCP connection to localhost.")
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.')
connOuter = parser.add_argument_group(
"Connection", "Optional arguments to specify a device to connect to and how."
)
conn = connOuter.add_mutually_exclusive_group()
conn.add_argument(
"--port",
@@ -26,21 +30,10 @@ def setup_parser() -> ArgumentParser:
const="localhost",
)
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(
"--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,12 +1,14 @@
import yaml
import logging
import time
from typing import List
from google.protobuf.json_format import MessageToDict
from meshtastic import mt_config
from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
# defs are from meshtastic/python/main
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"""
snake_name = camel_to_snake(config_root)
@@ -19,14 +21,16 @@ def traverseConfig(config_root, config, interface_config) -> bool:
return True
def splitCompoundName(comp_name: str) -> list[str]:
def splitCompoundName(comp_name: str) -> List[str]:
"""Split compound (dot separated) preference name into parts"""
name: list[str] = comp_name.split(".")
name: List[str] = comp_name.split(".")
if len(name) < 2:
name[0] = comp_name
name.append(comp_name)
return name
def setPref(config, comp_name, raw_val) -> bool:
"""Set a channel or preferences value"""
@@ -74,9 +78,7 @@ def setPref(config, comp_name, raw_val) -> bool:
if e:
val = e.number
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:")
names = []
for f in enumType.values:
@@ -121,44 +123,40 @@ def setPref(config, comp_name, raw_val) -> bool:
return True
def config_import(interface, filename):
with open(filename, encoding="utf8") as file:
configuration = yaml.safe_load(file)
closeNow = True
interface.getNode('^local', False).beginSettingsTransaction()
interface.getNode("^local", False).beginSettingsTransaction()
if "owner" in configuration:
logging.info(f"Setting device owner to {configuration['owner']}")
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:
logging.info(
f"Setting device owner short to {configuration['owner_short']}"
)
logging.info(f"Setting device owner short to {configuration['owner_short']}")
waitForAckNak = True
interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["owner_short"]
)
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
time.sleep(0.5)
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
interface.getNode('^local', False).setOwner(
long_name=None, short_name=configuration["ownerShort"]
)
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
time.sleep(0.5)
if "channel_url" in configuration:
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:
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:
alt = 0
@@ -177,34 +175,30 @@ def config_import(interface, filename):
logging.info(f"Fixing longitude at {lon} degrees")
logging.info("Setting device position")
interface.localNode.setFixedPosition(lat, lon, alt)
time.sleep(0.5)
if "config" in configuration:
localConfig = interface.getNode('^local').localConfig
localConfig = interface.getNode("^local").localConfig
for section in configuration["config"]:
traverseConfig(
section, configuration["config"][section], localConfig
)
interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
traverseConfig(section, configuration["config"][section], localConfig)
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
if "module_config" in configuration:
moduleConfig = interface.getNode('^local').moduleConfig
moduleConfig = interface.getNode("^local").moduleConfig
for section in configuration["module_config"]:
traverseConfig(
section,
configuration["module_config"][section],
moduleConfig,
)
interface.getNode('^local').writeConfig(
camel_to_snake(section)
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
interface.getNode('^local', False).commitSettingsTransaction()
interface.getNode("^local", False).commitSettingsTransaction()
logging.info("Writing modified configuration to device")
def config_export(interface) -> str:
"""used in --export-config"""
configObj = {}
@@ -237,7 +231,7 @@ def config_export(interface) -> str:
if 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:
# Convert inner keys to correct snake/camelCase
prefs = {}
@@ -248,15 +242,15 @@ def config_export(interface) -> str:
prefs[pref] = config[pref]
# mark base64 encoded fields as such
if pref == "security":
if 'privateKey' in prefs[pref]:
prefs[pref]['privateKey'] = 'base64:' + prefs[pref]['privateKey']
if 'publicKey' in prefs[pref]:
prefs[pref]['publicKey'] = 'base64:' + prefs[pref]['publicKey']
if 'adminKey' in prefs[pref]:
for i in range(len(prefs[pref]['adminKey'])):
prefs[pref]['adminKey'][i] = 'base64:' + prefs[pref]['adminKey'][i]
if "privateKey" in prefs[pref]:
prefs[pref]["privateKey"] = "base64:" + prefs[pref]["privateKey"]
if "publicKey" in prefs[pref]:
prefs[pref]["publicKey"] = "base64:" + prefs[pref]["publicKey"]
if "adminKey" in prefs[pref]:
for i in range(len(prefs[pref]["adminKey"])):
prefs[pref]["adminKey"][i] = "base64:" + prefs[pref]["adminKey"][i]
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:
configObj["config"] = config
@@ -272,9 +266,9 @@ def config_export(interface) -> str:
else:
configObj["module_config"] = prefs
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
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
config_txt += yaml.dump(configObj)
# logging.info(config_txt)
return config_txt
return config_txt

View File

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

View File

@@ -2,30 +2,33 @@ import sqlite3
import time
import logging
from datetime import datetime
from typing import Optional, Union, Dict
from contact.utilities.utils import decimal_to_hex
import contact.ui.default_config as config
import contact.globals as globals
from contact.utilities.singleton import ui_state, interface_state
def get_table_name(channel: str) -> str:
# Construct the table name
table_name = f"{str(globals.myNodeNum)}_{channel}_messages"
table_name = f"{str(interface_state.myNodeNum)}_{channel}_messages"
quoted_table_name = f'"{table_name}"' # Quote the table name becuase we begin with numerics and contain spaces
return quoted_table_name
def save_message_to_db(channel: str, user_id: str, message_text: str) -> int | None:
def save_message_to_db(channel: str, user_id: str, message_text: str) -> Optional[int]:
"""Save messages to the database, ensuring the table exists."""
try:
quoted_table_name = get_table_name(channel)
schema = '''
schema = """
user_id TEXT,
message_text TEXT,
timestamp INTEGER,
ack_type TEXT
'''
"""
ensure_table_exists(quoted_table_name, schema)
with sqlite3.connect(config.db_file_path) as db_connection:
@@ -33,10 +36,10 @@ def save_message_to_db(channel: str, user_id: str, message_text: str) -> int | N
timestamp = int(time.time())
# Insert the message
insert_query = f'''
insert_query = f"""
INSERT INTO {quoted_table_name} (user_id, message_text, timestamp, ack_type)
VALUES (?, ?, ?, ?)
'''
"""
db_cursor.execute(insert_query, (user_id, message_text, timestamp, None))
db_connection.commit()
@@ -60,7 +63,7 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
message_text = ?
"""
db_cursor.execute(update_query, (ack, str(globals.myNodeNum), timestamp, message))
db_cursor.execute(update_query, (ack, str(interface_state.myNodeNum), timestamp, message))
db_connection.commit()
except sqlite3.Error as e:
@@ -71,51 +74,53 @@ def update_ack_nak(channel: str, timestamp: int, message: str, ack: str) -> None
def load_messages_from_db() -> None:
"""Load messages from the database for all channels and update globals.all_messages and globals.channel_list."""
"""Load messages from the database for all channels and update ui_state.all_messages and ui_state.channel_list."""
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
query = "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ?"
db_cursor.execute(query, (f"{str(globals.myNodeNum)}_%_messages",))
db_cursor.execute(query, (f"{str(interface_state.myNodeNum)}_%_messages",))
tables = [row[0] for row in db_cursor.fetchall()]
# Iterate through each table and fetch its messages
for table_name in tables:
quoted_table_name = 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})')]
quoted_table_name = (
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})")]
if "ack_type" not in table_columns:
update_table_query = f"ALTER TABLE {quoted_table_name} ADD COLUMN ack_type TEXT"
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:
# Fetch all messages from the table
db_cursor.execute(query)
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
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)
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)
# Ensure the channel exists in globals.all_messages
if channel not in globals.all_messages:
globals.all_messages[channel] = []
# Add the channel to ui_state.channel_list if not already present
if channel not in ui_state.channel_list and not is_chat_archived(channel):
ui_state.channel_list.append(channel)
# Add messages to globals.all_messages grouped by hourly timestamp
# Ensure the channel exists in ui_state.all_messages
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 = {}
for user_id, message, timestamp, ack_type in db_messages:
hour = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:00')
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
ack_str = config.ack_unknown_str
if ack_type == "Implicit":
ack_str = config.ack_implicit_str
@@ -124,17 +129,20 @@ def load_messages_from_db() -> None:
elif ack_type == "Nak":
ack_str = config.nak_str
if user_id == str(globals.myNodeNum):
if user_id == str(interface_state.myNodeNum):
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
else:
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')}: ",
message,
)
hourly_messages[hour].append(formatted_message)
# Flatten the hourly messages into globals.all_messages[channel]
# Flatten the hourly messages into ui_state.all_messages[channel]
for hour, messages in sorted(hourly_messages.items()):
globals.all_messages[channel].append((f"-- {hour} --", ""))
globals.all_messages[channel].extend(messages)
ui_state.all_messages[channel].append((f"-- {hour} --", ""))
ui_state.all_messages[channel].extend(messages)
except sqlite3.Error as e:
logging.error(f"SQLite error while loading messages from table '{table_name}': {e}")
@@ -145,24 +153,24 @@ def load_messages_from_db() -> None:
def init_nodedb() -> None:
"""Initialize the node database and update it with nodes from the interface."""
try:
if not globals.interface.nodes:
if not interface_state.interface.nodes:
return # No nodes to initialize
ensure_node_table_exists() # Ensure the table exists before insertion
nodes_snapshot = list(globals.interface.nodes.values())
nodes_snapshot = list(interface_state.interface.nodes.values())
# Insert or update all nodes
for node in nodes_snapshot:
update_node_info_in_db(
user_id=node['num'],
long_name=node['user'].get('longName', ''),
short_name=node['user'].get('shortName', ''),
hw_model=node['user'].get('hwModel', ''),
is_licensed=node['user'].get('isLicensed', '0'),
role=node['user'].get('role', 'CLIENT'),
public_key=node['user'].get('publicKey', '')
user_id=node["num"],
long_name=node["user"].get("longName", ""),
short_name=node["user"].get("shortName", ""),
hw_model=node["user"].get("hwModel", ""),
is_licensed=node["user"].get("isLicensed", "0"),
role=node["user"].get("role", "CLIENT"),
public_key=node["user"].get("publicKey", ""),
)
logging.info("Node database initialized successfully.")
@@ -173,16 +181,16 @@ def init_nodedb() -> None:
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: Dict[str, object]) -> None:
"""Save nodeinfo unless that record is already there, updating if necessary."""
try:
user_id = packet['from']
long_name = packet['decoded']['user']['longName']
short_name = packet['decoded']['user']['shortName']
hw_model = packet['decoded']['user']['hwModel']
is_licensed = packet['decoded']['user'].get('isLicensed', '0')
role = packet['decoded']['user'].get('role', 'CLIENT')
public_key = packet['decoded']['user'].get('publicKey', '')
user_id = packet["from"]
long_name = packet["decoded"]["user"]["longName"]
short_name = packet["decoded"]["user"]["shortName"]
hw_model = packet["decoded"]["user"]["hwModel"]
is_licensed = packet["decoded"]["user"].get("isLicensed", "0")
role = packet["decoded"]["user"].get("role", "CLIENT")
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)
@@ -191,36 +199,44 @@ def maybe_store_nodeinfo_in_db(packet: dict[str, object]) -> None:
except Exception as e:
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
def update_node_info_in_db(
user_id: int | str,
long_name: str | None = None,
short_name: str | None = None,
hw_model: str | None = None,
is_licensed: str | int | None = None,
role: str | None = None,
public_key: str | None = None,
chat_archived: int | None = None
) -> 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."""
try:
ensure_node_table_exists() # Ensure the table exists before any operation
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names
table_name = f'"{interface_state.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:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
db_cursor.execute(update_table_query)
# 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()
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
short_name = short_name if short_name is not None else existing_short_name
@@ -239,7 +255,7 @@ def update_node_info_in_db(
chat_archived = chat_archived if chat_archived is not None else 0
# 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
@@ -250,8 +266,10 @@ def update_node_info_in_db(
role = excluded.role,
public_key = excluded.public_key,
chat_archived = excluded.chat_archived
'''
db_cursor.execute(upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived))
"""
db_cursor.execute(
upsert_query, (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)
)
db_connection.commit()
except sqlite3.Error as e:
@@ -262,8 +280,8 @@ def update_node_info_in_db(
def ensure_node_table_exists() -> None:
"""Ensure the node database table exists."""
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote for safety
schema = '''
table_name = f'"{interface_state.myNodeNum}_nodedb"' # Quote for safety
schema = """
user_id TEXT PRIMARY KEY,
long_name TEXT,
short_name TEXT,
@@ -272,7 +290,7 @@ def ensure_node_table_exists() -> None:
role TEXT,
public_key TEXT,
chat_archived INTEGER
'''
"""
ensure_table_exists(table_name, schema)
@@ -293,7 +311,7 @@ def ensure_table_exists(table_name: str, schema: str) -> None:
def get_name_from_database(user_id: int, type: str = "long") -> str:
"""
Retrieve a user's name (long or short) from the node database.
:param user_id: The user ID to look up.
:param type: "long" for long name, "short" for short name.
:return: The retrieved name or the hex of the user id
@@ -303,9 +321,9 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
db_cursor = db_connection.cursor()
# Construct table name
table_name = f"{str(globals.myNodeNum)}_nodedb"
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"' # Quote table name for safety
# Determine the correct column to fetch
column_name = "long_name" if type == "long" else "short_name"
@@ -324,11 +342,12 @@ def get_name_from_database(user_id: int, type: str = "long") -> str:
logging.error(f"Unexpected error in get_name_from_database: {e}")
return "Unknown"
def is_chat_archived(user_id: int) -> int:
try:
with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor()
table_name = f"{str(globals.myNodeNum)}_nodedb"
table_name = f"{str(interface_state.myNodeNum)}_nodedb"
nodeinfo_table = f'"{table_name}"'
query = f"SELECT chat_archived FROM {nodeinfo_table} WHERE user_id = ?"
db_cursor.execute(query, (user_id,))
@@ -343,4 +362,3 @@ def is_chat_archived(user_id: int) -> int:
except Exception as e:
logging.error(f"Unexpected error in is_chat_archived: {e}")
return "Unknown"

View File

@@ -2,45 +2,10 @@ import base64
import binascii
import curses
import ipaddress
import re
from typing import Any, Optional
from typing import Any, Optional, List
from contact.ui.colors import get_color
from contact.ui.navigation_utils import move_highlight
def wrap_text(text: str, wrap_width: int) -> list[str]:
"""Wraps text while preserving spaces and breaking long words."""
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
wrapped_lines = []
line_buffer = ""
line_length = 0
margin = 2 # Left and right margin
wrap_width -= margin
for word in words:
word_length = len(word)
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
continue
if line_length + word_length > wrap_width and word.strip():
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
line_buffer += word
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
def get_text_input(prompt: str) -> Optional[str]:
@@ -63,9 +28,7 @@ def get_text_input(prompt: str) -> Optional[str]:
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
row = 1
for line in wrapped_prompt:
input_win.addstr(
row, margin, line[:input_width], get_color("settings_default", bold=True)
)
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
row += 1
if row >= height - 3: # Prevent overflow
break
@@ -109,35 +72,23 @@ def get_text_input(prompt: str) -> Optional[str]:
first_line = user_input[:first_line_width] # Cut to max first line width
remaining_text = user_input[first_line_width:] # Remaining text for wrapping
wrapped_lines = (
wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
)
wrapped_lines = wrap_text(remaining_text, wrap_width=input_width) if remaining_text else []
# Clear only the input area (without touching prompt text)
for i in range(max_input_rows):
if row + 1 + i < height - 1:
input_win.addstr(
row + 1 + i,
margin,
" " * min(input_width, width - margin - 1),
get_color("settings_default"),
row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default")
)
# Redraw the prompt text so it never disappears
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
# Redraw wrapped input
input_win.addstr(
row + 1, col_start, first_line, get_color("settings_default")
) # First line next to prompt
input_win.addstr(row + 1, col_start, first_line, get_color("settings_default")) # First line next to prompt
for i, line in enumerate(wrapped_lines):
if row + 2 + i < height - 1:
input_win.addstr(
row + 2 + i,
margin,
line[:input_width],
get_color("settings_default"),
)
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
input_win.refresh()
@@ -147,17 +98,19 @@ def get_text_input(prompt: str) -> Optional[str]:
return user_input
def get_admin_key_input(current_value: list[bytes]) -> Optional[list[str]]:
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
def to_base64(byte_strings):
"""Convert byte values to Base64-encoded strings."""
return [base64.b64encode(b).decode() for b in byte_strings]
def is_valid_base64(s):
"""Check if a string is valid Base64."""
"""Check if a string is valid Base64 or blank."""
if s == "":
return True
try:
decoded = base64.b64decode(s, validate=True)
return len(decoded) == 32 # Ensure it's exactly 32 bytes
except binascii.Error:
except (binascii.Error, ValueError):
return False
cvalue = to_base64(current_value) # Convert current values to Base64
@@ -182,39 +135,28 @@ def get_admin_key_input(current_value: list[bytes]) -> Optional[list[str]]:
while True:
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(
1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True)
)
repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr(
3 + i,
2,
f"{prefix}Admin Key {i + 1}: ",
get_color("settings_default", bold=(i == cursor_pos)),
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
)
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
# Move cursor to the correct position inside the field
curses.curs_set(1)
repeated_win.move(
3 + cursor_pos, 18 + len(user_values[cursor_pos])
) # Position cursor at end of text
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.addstr(7, 2, error_message, get_color("settings_default", bold=True))
repeated_win.refresh()
key = repeated_win.getch()
if (
key == 27 or key == curses.KEY_LEFT
): # Escape or Left Arrow -> Cancel and return original
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
repeated_win.erase()
repeated_win.refresh()
curses.noecho()
@@ -222,36 +164,28 @@ def get_admin_key_input(current_value: list[bytes]) -> Optional[list[str]]:
return None
elif key == ord("\n"): # Enter key to save and return
if all(
is_valid_base64(val) for val in user_values
): # Ensure all values are valid Base64 and 32 bytes
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
curses.noecho()
curses.curs_set(0)
return user_values # Return the edited Base64 values
else:
error_message = (
"Error: Each key must be valid Base64 and 32 bytes long!"
)
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
elif key == curses.KEY_UP: # Move cursor up
cursor_pos = (cursor_pos - 1) % len(user_values)
elif key == curses.KEY_DOWN: # Move cursor down
cursor_pos = (cursor_pos + 1) % len(user_values)
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
if len(user_values[cursor_pos]) > 0:
user_values[cursor_pos] = user_values[cursor_pos][
:-1
] # Remove last character
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
else:
try:
user_values[cursor_pos] += chr(
key
) # Append valid character input to the selected field
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
error_message = "" # Clear error if user starts fixing input
except ValueError:
pass # Ignore invalid character inputs
def get_repeated_input(current_value: list[str]) -> Optional[str]:
def get_repeated_input(current_value: List[str]) -> Optional[str]:
height = 9
width = 80
start_y = (curses.LINES - height) // 2
@@ -273,39 +207,28 @@ def get_repeated_input(current_value: list[str]) -> Optional[str]:
while True:
repeated_win.erase()
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))
# Display current values, allowing editing
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " " # Highlight the current line
repeated_win.addstr(
3 + i,
2,
f"{prefix}Value{i + 1}: ",
get_color("settings_default", bold=(i == cursor_pos)),
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
)
repeated_win.addstr(3 + i, 18, line)
# Move cursor to the correct position inside the field
curses.curs_set(1)
repeated_win.move(
3 + cursor_pos, 18 + len(user_values[cursor_pos])
) # Position cursor at end of text
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.addstr(7, 2, error_message, get_color("settings_default", bold=True))
repeated_win.refresh()
key = repeated_win.getch()
if (
key == 27 or key == curses.KEY_LEFT
): # Escape or Left Arrow -> Cancel and return original
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
repeated_win.erase()
repeated_win.refresh()
curses.noecho()
@@ -322,14 +245,10 @@ def get_repeated_input(current_value: list[str]) -> Optional[str]:
cursor_pos = (cursor_pos + 1) % len(user_values)
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
if len(user_values[cursor_pos]) > 0:
user_values[cursor_pos] = user_values[cursor_pos][
:-1
] # Remove last character
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
else:
try:
user_values[cursor_pos] += chr(
key
) # Append valid character input to the selected field
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
error_message = "" # Clear error if user starts fixing input
except ValueError:
pass # Ignore invalid character inputs
@@ -355,9 +274,7 @@ def get_fixed32_input(current_value: int) -> int:
while True:
fixed32_win.erase()
fixed32_win.border()
fixed32_win.addstr(
1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD
)
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
fixed32_win.addstr(3, 2, f"Current: {current_value}")
fixed32_win.addstr(5, 2, f"New value: {user_input}")
fixed32_win.refresh()
@@ -373,20 +290,13 @@ def get_fixed32_input(current_value: int) -> int:
elif key == ord("\n"): # Enter key to validate and save
# Validate IP address
octets = user_input.split(".")
if len(octets) == 4 and all(
octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets
):
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
curses.noecho()
curses.curs_set(0)
fixed32_address = ipaddress.ip_address(user_input)
return int(fixed32_address) # Return the valid IP address
else:
fixed32_win.addstr(
7,
2,
"Invalid IP address. Try again.",
curses.A_BOLD | curses.color_pair(5),
)
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
fixed32_win.refresh()
curses.napms(1500) # Wait for 1.5 seconds before refreshing
user_input = "" # Clear invalid input
@@ -401,16 +311,11 @@ def get_fixed32_input(current_value: int) -> int:
pass # Ignore invalid inputs
def get_list_input(
prompt: str, current_option: Optional[str], list_options: list[str]
) -> Optional[str]:
def get_list_input(prompt: str, current_option: Optional[str], list_options: List[str]) -> Optional[str]:
"""
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
)
scroll_offset = 0
selected_index = list_options.index(current_option) if current_option in list_options else 0
height = min(len(list_options) + 5, curses.LINES)
width = 80
@@ -433,16 +338,9 @@ def get_list_input(
# 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),
)
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")
)
list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
# Initial refresh
list_win.refresh()
@@ -458,37 +356,19 @@ def get_list_input(
max_index = len(list_options) - 1
visible_height = list_win.getmaxyx()[0] - 5
draw_arrows(list_win, visible_height, max_index, 0)
draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False) # Initial call to draw arrows
while True:
key = list_win.getch()
if key == curses.KEY_UP:
old_idx = selected_index
old_selected_index = selected_index
selected_index = max(0, selected_index - 1)
scroll_ref = [scroll_offset]
move_highlight(
old_idx,
selected_index,
list_options,
list_win,
list_pad,
start_index_ref=scroll_ref,
)
scroll_offset = scroll_ref[0]
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
elif key == curses.KEY_DOWN:
old_idx = selected_index
old_selected_index = selected_index
selected_index = min(len(list_options) - 1, selected_index + 1)
scroll_ref = [scroll_offset]
move_highlight(
old_idx,
selected_index,
list_options,
list_win,
list_pad,
start_index_ref=scroll_ref,
)
scroll_offset = scroll_ref[0]
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
elif key == ord("\n"): # Enter key
list_win.clear()
list_win.refresh()
@@ -497,65 +377,3 @@ def get_list_input(
list_win.clear()
list_win.refresh()
return current_option
# def move_highlight(
# old_idx: int,
# new_idx: int,
# options: list[str],
# list_win: curses.window,
# list_pad: curses.window
# ) -> int:
# 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: curses.window, visible_height: int, max_index: int, start_index: int
) -> None:
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,16 +1,17 @@
import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
def initialize_interface(args):
try:
if args.ble:
return meshtastic.ble_interface.BLEInterface(args.ble if args.ble != "any" else None)
elif args.host:
try:
if ":" in args.host:
tcp_hostname, tcp_port = args.host.split(':')
tcp_hostname, tcp_port = args.host.split(":")
else:
tcp_hostname = args.host
tcp_port = meshtastic.tcp_interface.DEFAULT_TCP_PORT
@@ -23,7 +24,9 @@ def initialize_interface(args):
except FileNotFoundError as ex:
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
except PermissionError as ex:
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
logging.error(
f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}"
)
except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}")
except OSError as ex:
@@ -35,7 +38,6 @@ def initialize_interface(args):
logging.error(f"Error connecting to localhost:{ex}")
return client
except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}")

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
import contact.globals as globals
import datetime
import time
from meshtastic.protobuf import config_pb2
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
def get_channels():
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
node = globals.interface.getNode('^local')
"""Retrieve channels from the node and update ui_state.channel_list and ui_state.all_messages."""
node = interface_state.interface.getNode("^local")
device_channels = node.channels
# Clear and rebuild channel list
# globals.channel_list = []
# ui_state.channel_list = []
for device_channel in device_channels:
if device_channel.role:
@@ -20,66 +23,75 @@ def get_channels():
# If channel name is blank, use the modem preset
lora_config = node.localConfig.lora
modem_preset_enum = lora_config.modem_preset
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[modem_preset_enum].name
modem_preset_string = config_pb2._CONFIG_LORACONFIG_MODEMPRESET.values_by_number[
modem_preset_enum
].name
channel_name = convert_to_camel_case(modem_preset_string)
# Add channel to globals.channel_list if not already present
if channel_name not in globals.channel_list:
globals.channel_list.append(channel_name)
# Add channel to ui_state.channel_list if not already present
if channel_name not in ui_state.channel_list:
ui_state.channel_list.append(channel_name)
# Initialize globals.all_messages[channel_name] if it doesn't exist
if channel_name not in globals.all_messages:
globals.all_messages[channel_name] = []
# Initialize ui_state.all_messages[channel_name] if it doesn't exist
if channel_name not in ui_state.all_messages:
ui_state.all_messages[channel_name] = []
return ui_state.channel_list
return globals.channel_list
def get_node_list():
if globals.interface.nodes:
my_node_num = globals.myNodeNum
if interface_state.interface.nodes:
my_node_num = interface_state.myNodeNum
def node_sort(node):
if(config.node_sort == 'lastHeard'):
return -node['lastHeard'] if ('lastHeard' in node and isinstance(node['lastHeard'], int)) else 0
elif(config.node_sort == "name"):
return node['user']['longName']
elif(config.node_sort == "hops"):
return node['hopsAway'] if 'hopsAway' in node else 100
if config.node_sort == "lastHeard":
return -node["lastHeard"] if ("lastHeard" in node and isinstance(node["lastHeard"], int)) else 0
elif config.node_sort == "name":
return node["user"]["longName"]
elif config.node_sort == "hops":
return node["hopsAway"] if "hopsAway" in node else 100
else:
return node
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
sorted_nodes = sorted(interface_state.interface.nodes.values(), key=node_sort)
# Move favorite nodes to the beginning
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
sorted_nodes = sorted(
sorted_nodes, key=lambda node: node["isFavorite"] if "isFavorite" in node else False, reverse=True
)
# Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isIgnored'] if 'isIgnored' in node else False)
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 []
def refresh_node_list():
new_node_list = get_node_list()
if new_node_list != globals.node_list:
globals.node_list = new_node_list
if new_node_list != ui_state.node_list:
ui_state.node_list = new_node_list
return True
return False
def get_nodeNum():
myinfo = globals.interface.getMyNodeInfo()
myNodeNum = myinfo['num']
myinfo = interface_state.interface.getMyNodeInfo()
myNodeNum = myinfo["num"]
return myNodeNum
def decimal_to_hex(decimal_number):
return f"!{decimal_number:08x}"
def convert_to_camel_case(string):
words = string.split('_')
camel_case_string = ''.join(word.capitalize() for word in words)
words = string.split("_")
camel_case_string = "".join(word.capitalize() for word in words)
return camel_case_string
def get_time_val_units(time_delta):
value = 0
unit = ""
@@ -107,11 +119,13 @@ def get_time_val_units(time_delta):
unit = "s"
return (value, unit)
def get_readable_duration(seconds):
delta = datetime.timedelta(seconds = seconds)
delta = datetime.timedelta(seconds=seconds)
val, units = get_time_val_units(delta)
return f"{val} {units}"
def get_time_ago(timestamp):
now = datetime.datetime.now()
dt = datetime.datetime.fromtimestamp(timestamp)
@@ -122,3 +136,30 @@ def get_time_ago(timestamp):
return f"{value} {unit} ago"
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,6 +1,6 @@
[project]
name = "contact"
version = "1.3.6"
version = "1.3.15"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}