1
0
forked from iarv/contact

Compare commits

..

13 Commits

Author SHA1 Message Date
pdxlocations
74d19c5ca7 clean-up 2025-05-18 12:17:27 -07:00
pdxlocations
33f7db4cb9 Merge branch 'main' into refactor-chat-ui 2025-05-18 08:34:01 -07:00
pdxlocations
06d151e991 hack fix 2025-05-18 08:21:53 -07:00
pdxlocations
50efa50923 working changes 2025-05-17 23:27:19 -07:00
pdxlocations
cef86ab185 I think it works! 2025-05-17 23:14:37 -07:00
pdxlocations
3d305a22b9 closer changes 2025-05-17 22:33:44 -07:00
pdxlocations
6d45788a5b mostly working changes 2025-04-28 22:20:00 -07:00
pdxlocations
e0e09aeccf so close 2025-04-20 22:04:41 -07:00
pdxlocations
bfb7ed0278 more almost working changes 2025-04-20 20:59:10 -07:00
pdxlocations
b7a275f725 Almost working changes 2025-04-19 23:12:59 -07:00
pdxlocations
587d79cb93 move lock to app state 2025-04-19 21:22:47 -07:00
pdxlocations
eb79675be0 convert globals to dataclass 2025-04-19 21:17:19 -07:00
pdxlocations
61e1799199 init 2025-04-19 21:13:46 -07:00
23 changed files with 877 additions and 1910 deletions

3
.gitignore vendored
View File

@@ -8,5 +8,4 @@ client.log
settings.log
config.json
default_config.log
dist/
.vscode/launch.json
dist/

11
.vscode/launch.json vendored
View File

@@ -1,22 +1,13 @@
{
"version": "0.1.0",
"configurations": [
{
"name": "Python Debugger: main",
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": []
},
{
"name": "Python Debugger: tcp",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": ["--host","192.168.86.69"]
}
]
}

View File

@@ -6,13 +6,6 @@
```bash
pip install contact
```
> [!NOTE]
> Windows users must also install:
>
> ```powershell
> pip install windows-curses
> ```
> because the built-in curses module is not available on Windows.
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
@@ -32,24 +25,14 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
For smaller displays you may wish to enable `single_pane_mode`:
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
## Commands
- `CTRL` + `k` = display a list of commands.
- `↑→↓←` = Navigate around the UI.
- `F1/F2/F3` = Jump to Channel/Messages/Nodes
- `ENTER` = Send a message typed in the Input Window, or with the Node List highlighted, select a node to DM
- `` ` ` or F12` = Open the Settings dialogue
- `` ` `` = Open the Settings dialogue
- `CTRL` + `p` = Hide/show a log of raw received packets.
- `CTRL` + `t` or `F4` = With the Node List highlighted, send a traceroute to the selected node
- `F5` = Display a node's info
- `CTRL` + `f` = With the Node List highlighted, favorite the selected node
- `CTRL` + `g` = With the Node List highlighted, ignore the selected node
- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
### Search
@@ -79,17 +62,8 @@ If no connection arguments are specified, the client will attempt a serial conne
contact --port /dev/ttyUSB0
contact --host 192.168.1.1
contact --ble BlAddressOfDevice
contact --port COM3
```
To quickly connect to localhost, use:
```sh
contact -t
```
## Install in development (editable) mode:
```bash
git clone https://github.com/pdxlocations/contact.git
cd contact
python3 -m venv .venv
source .venv/bin/activate
pip install -e .
```

View File

@@ -53,26 +53,26 @@ logging.basicConfig(
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() -> None:
def initialize_globals(args) -> None:
"""Initializes interface and shared globals."""
interface_state.interface = initialize_interface(args)
# Prompt for region if unset
if interface_state.interface.localNode.localConfig.lora.region == 0:
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
if confirmation == "Yes":
set_region(interface_state.interface)
interface_state.interface.close()
interface_state.interface = initialize_interface(args)
interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list()
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb()
@@ -81,66 +81,55 @@ def initialize_globals() -> None:
def main(stdscr: curses.window) -> None:
"""Main entry point for the curses UI."""
output_capture = io.StringIO()
try:
setup_colors()
draw_splash(stdscr)
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
draw_splash(stdscr)
args = setup_parser().parse_args()
args = setup_parser().parse_args()
if getattr(args, "settings", False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
if getattr(args, "settings", False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface...")
with app_state.lock:
interface_state.interface = initialize_interface(args)
logging.info("Initializing interface...")
with app_state.lock:
initialize_globals(args)
logging.info("Starting main UI")
if interface_state.interface.localNode.localConfig.lora.region == 0:
prompt_region_if_unset(args)
main_ui(stdscr)
initialize_globals()
logging.info("Starting main UI")
stdscr.clear()
stdscr.refresh()
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
main_ui(stdscr)
except Exception:
console_output = output_capture.getvalue()
logging.error("Uncaught exception inside main_ui")
logging.error("Traceback:\n%s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
return
except Exception:
except Exception as e:
console_output = output_capture.getvalue()
logging.error("Uncaught exception: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output:\n%s", console_output)
raise
def start() -> None:
"""Entry point for the application."""
"""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)
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
sys.exit(0)
except Exception as e:
logging.critical("Fatal error", exc_info=True)
try:
curses.endwin()
except Exception:
pass
print("Fatal error:", e)
traceback.print_exc()
sys.exit(1)
with open(config.log_file_path, "a", buffering=1) as log_f:
sys.stdout = log_f
sys.stderr = log_f
with contextlib.redirect_stdout(log_f), contextlib.redirect_stderr(log_f):
try:
curses.wrapper(main)
except KeyboardInterrupt:
logging.info("User exited with Ctrl+C")
sys.exit(0)
except Exception as e:
logging.error("Fatal error: %s", e)
logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1)
if __name__ == "__main__":

View File

@@ -14,13 +14,13 @@ id, "", ""
uplink_enabled, "Uplink enabled", "Let this channel's data be sent to the MQTT server configured on this node."
downlink_enabled, "Downlink enabled", "Let data from the MQTT server configured on this node be sent to this channel."
module_settings, "Module settings", "Position precision and Client Mute."
module_settings.position_precision, "Position precision", "The precision level of location data sent on this channel."
module_settings.is_client_muted, "Is Client Muted", "Controls whether or not the phone / clients should mute the current channel. Useful for noisy public channels you don't necessarily want to disable."
position_precision, "Position precision", "The precision level of location data sent on this channel."
is_client_muted, "", ""
[config.device]
title, "Device"
role, "Role", "For the vast majority of users, the correct choice is CLIENT. See Meshtastic docs for more information."
serial_enabled, "Enable serial console", "Serial Console over the Stream API."
serial_enabled, "Enable serial console", ""
button_gpio, "Button GPIO", "GPIO pin for user button."
buzzer_gpio, "Buzzer GPIO", "GPIO pin for user buzzer."
rebroadcast_mode, "Rebroadcast mode", "This setting defines the device's behavior for how messages are rebroadcast."
@@ -30,7 +30,6 @@ is_managed, "Enable managed mode", "Enabling Managed Mode blocks smartphone apps
disable_triple_click, "Disable triple button press", ""
tzdef, "Timezone", "Uses the TZ Database format to display the correct local time on the device display and in its logs."
led_heartbeat_disabled, "Disable LED heartbeat", "On certain hardware models, this disables the blinking heartbeat LED."
buzzer_mode, "Buzzer Mode", "Controls buzzer behavior for audio feedback."
[config.position]
title, "Position"
@@ -78,7 +77,6 @@ subnet, "IPv4 subnet", ""
dns, "IPv4 DNS server", ""
rsyslog_server, "RSyslog server", ""
enabled_protocols, "Enabled protocols", ""
ipv6_enabled, "IPv6 enabled", "Enables or Disables IPv6 networking."
[config.network.ipv4_config]
title, "IPv4 Config", ""
@@ -160,7 +158,7 @@ channel_num, "Frequency slot", "Determines the exact frequency the radio transmi
override_duty_cycle, "Override duty cycle", "Override the legal transmit time limit to allow unlimited transmit time. [warning]May have legal ramifications.[/warning]"
sx126x_rx_boosted_gain, "Enable SX126X RX boosted gain", "This is an option specific to the SX126x chip series which allows the chip to consume a small amount of additional power to increase RX (receive) sensitivity."
override_frequency, "Override frequency in MHz", "Overrides frequency slot. May have legal ramifications."
pa_fan_disabled, "PA Fan Disabled", "If true, disable the build-in PA FAN using pin define in RF95_FAN_EN"
pa_fan_disabled, "", ""
ignore_mqtt, "Ignore MQTT", "Ignores any messages it receives via LoRa that came via MQTT somewhere along the path towards the device."
config_ok_to_mqtt, "OK to MQTT", "Indicates that the user approves their packets to be uplinked to MQTT brokers."
@@ -192,10 +190,8 @@ tls_enabled, "TLS enabled", "If true, we attempt to establish a secure connectio
root, "Root topic", "The root topic to use for MQTT messages. This is useful if you want to use a single MQTT server for multiple meshtastic networks and separate them via ACLs."
proxy_to_client_enabled, "Client proxy enabled", "If true, let the device use the client's (e.g. your phone's) network connection to connect to the MQTT server. If false, it uses the device's network connection which you have to enable via the network settings."
map_reporting_enabled, "Map reporting enabled", "Available from firmware version 2.3.2 on. If true, your node will periodically send an unencrypted map report to the MQTT server to be displayed by online maps that support this packet."
map_report_settings, "Map report settings", "Settings for the map report module."
map_report_settings.publish_interval_secs, "Map report publish interval", "How often we should publish the map report to the MQTT server in seconds. Defaults to 900 seconds (15 minutes)."
map_report_settings.position_precision, "Map report position precision", "The precision to use for the position in the map report. Defaults to a maximum deviation of around 1459m."
map_report_settings.should_report_location, "Should report location", "Whether we have opted-in to report our location to the map."
[module.serial]
title, "Serial"
@@ -212,9 +208,9 @@ override_console_serial_port, "Override console serial port", "If set to true, t
title, "External Notification"
enabled, "Enabled", "Enables the module."
output_ms, "Length", "Specifies how long in milliseconds you would like your GPIOs to be active. In case of the repeat option, this is the duration of every tone and pause."
output, "Output GPIO", "Define the output pin GPIO setting Defaults to EXT_NOTIFY_OUT if set for the board. In standalone devices this pin should drive the LED to match the UI."
output_vibra, "Vibra GPIO", "Optional: Define a secondary output pin for a vibra motor. This is used in standalone devices to match the UI."
output_buzzer, "Buzzer GPIO", "Optional: Define a tertiary output pin for an active buzze. This is used in standalone devices to to match the UI."
output, "", ""
output_vibra, "", ""
output_buzzer, "", ""
active, "Active (general / LED only)", "Specifies whether the external circuit is active when the device's GPIO is low or high. If this is set true, the pin will be pulled active high, false means active low."
alert_message, "Alert when receiving a message (general)", "Specifies if an alert should be triggered when receiving an incoming message."
alert_message_vibra, "Alert vibration on message", "Specifies if a vibration alert should be triggered when receiving an incoming message."
@@ -284,8 +280,7 @@ i2s_sck, "I2S clock", "The GPIO to use for the SCK signal in the I2S interface."
[module.remote_hardware]
title, "Remote Hardware"
enabled, "Enabled", "Enables the module."
allow_undefined_pin_access, "Allow undefined pin access", "Whether the Module allows consumers to read / write to pins not defined in available_pins"
available_pins, "Available pins", "Exposes the available pins to the mesh for reading and writing."
allow_undefined_pin_access, "Allow undefined pin access", ""
[module.neighbor_info]
title, "Neighbor Info"
@@ -316,5 +311,5 @@ use_pullup, "Use pull-up", "Whether or not use INPUT_PULLUP mode for GPIO pin. O
title, "Paxcounter"
enabled, "Enabled", "Enables the module."
paxcounter_update_interval, "Update interval", "The interval in seconds of how often we can send a message to the mesh when a state change is detected."
Wi-Fi_threshold, "Wi-Fi Threshold", "WiFi RSSI threshold. Defaults to -80"
ble_threshold, "BLE Threshold", "BLE RSSI threshold. Defaults to -80"
Wi-Fi_threshold, "", ""
ble_threshold, "", ""

View File

@@ -1,14 +1,9 @@
import logging
import os
import platform
import shutil
import subprocess
import time
from datetime import datetime
from typing import Any, Dict
from contact.utilities.utils import (
refresh_node_list,
add_new_message,
)
from contact.utilities.utils import refresh_node_list
from contact.ui.contact_ui import (
draw_packetlog_win,
draw_node_list,
@@ -24,47 +19,7 @@ from contact.utilities.db_handler import (
)
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
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}")
from contact.utilities.singleton import ui_state, interface_state, app_state
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
@@ -84,9 +39,6 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
if ui_state.display_log:
draw_packetlog_win()
if ui_state.current_window == 4:
menu_state.need_redraw = True
try:
if "decoded" not in packet:
return
@@ -101,15 +53,6 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
maybe_store_nodeinfo_in_db(packet)
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
hop_start = packet.get('hopStart', 0)
hop_limit = packet.get('hopLimit', 0)
hops = hop_start - hop_limit
if config.notification_sound == "True":
play_sound()
message_bytes = packet["decoded"]["payload"]
message_string = message_bytes.decode("utf-8")
@@ -133,9 +76,7 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
if ui_state.channel_list[channel_number] != ui_state.channel_list[ui_state.selected_channel]:
add_notification(channel_number)
refresh_channels = True
else:
@@ -145,14 +86,40 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
message_from_id = packet["from"]
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
add_new_message(channel_id, f"{config.message_prefix} [{hops}] {message_from_string} ", message_string)
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
ui_state.all_messages[ui_state.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 = ui_state.all_messages[ui_state.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:
ui_state.all_messages[ui_state.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
ui_state.all_messages[ui_state.channel_list[channel_number]].append(
(f"{config.message_prefix} {message_from_string} ", message_string)
)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(channel_id, message_from_id, message_string)
save_message_to_db(ui_state.channel_list[channel_number], message_from_id, message_string)
except KeyError as e:
logging.error(f"Error processing packet: {e}")

View File

@@ -1,5 +1,4 @@
import time
from datetime import datetime
from typing import Any, Dict
import google.protobuf.json_format
@@ -17,8 +16,6 @@ import contact.ui.default_config as config
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}
@@ -51,7 +48,7 @@ def onAckNak(packet: Dict[str, Any]) -> None:
ack_type = "Nak"
ui_state.all_messages[acknak["channel"]][acknak["messageIndex"]] = (
time.strftime("[%H:%M:%S] ") + config.sent_message_prefix + confirm_string + ": ",
config.sent_message_prefix + confirm_string + ": ",
message,
)
@@ -149,9 +146,8 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
update_node_info_in_db(packet["from"], chat_archived=False)
channel_number = ui_state.channel_list.index(packet["from"])
channel_id = ui_state.channel_list[channel_number]
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
refresh_messages = True
else:
add_notification(channel_number)
@@ -159,14 +155,18 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
ui_state.all_messages[ui_state.channel_list[channel_number]] = []
ui_state.all_messages[ui_state.channel_list[channel_number]].append(
(f"{config.message_prefix} {message_from_string}", msg_str)
)
if refresh_channels:
draw_channel_list()
if refresh_messages:
draw_messages_window(True)
save_message_to_db(ui_state.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:
"""
@@ -190,7 +190,32 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
channelIndex=send_on_channel,
)
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
# Add sent message to the messages dictionary
if channel_id not in ui_state.all_messages:
ui_state.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 = 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} --", ""))
ui_state.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
timestamp = save_message_to_db(channel_id, myid, message)
@@ -205,14 +230,10 @@ 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()
interface_state.interface.sendData(
r,
destinationId=channel_id,
destinationId=ui_state.node_list[ui_state.selected_node],
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
wantResponse=True,
onResponse=on_response_traceroute,

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,6 @@ import sys
from typing import List
from contact.utilities.save_to_radio import save_changes
import contact.ui.default_config as config
from contact.utilities.config_io import config_export, config_import
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from contact.utilities.input_handlers import (
@@ -21,74 +20,60 @@ from contact.ui.dialog import dialog
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.utilities.singleton import menu_state
from contact.ui.ui_state import MenuState
# Setup Variables
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
menu_state = MenuState()
# Constants
width = 80
save_option = "Save Changes"
max_help_lines = 0
help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Compute the effective menu width for the current terminal
def get_menu_width() -> int:
# Leave at least 2 columns for borders; clamp to >= 20 for usability
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# Paths
# locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
# config_folder = os.path.join(locals_dir, "node-configs")
config_folder = os.path.abspath(config.node_configs_file_path)
config_folder = os.path.join(locals_dir, "node-configs")
# Load translations
field_mapping, help_text = parse_ini_file(translation_file)
def display_menu() -> tuple[object, object]:
# if help_win:
# min_help_window_height = 6
# else:
# min_help_window_height = 0
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)
# Determine the available height for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
w = get_menu_width()
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = (curses.COLS - w) // 2
start_x = (curses.COLS - width) // 2
# Calculate remaining space for help window
global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding
max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_state.menu_path)
if len(header) > w - 4:
header = header[: w - 7] + "..."
if len(header) > width - 4:
header = header[: width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path)
@@ -99,15 +84,15 @@ def display_menu() -> tuple[object, object]:
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
display_option = f"{display_name}"[: w // 2 - 2]
display_value = f"{current_value}"[: w // 2 - 4]
display_option = f"{display_name}"[: width // 2 - 2]
display_value = f"{current_value}"[: width // 2 - 4]
try:
color = get_color(
"settings_sensitive" if option in sensitive_settings else "settings_default",
reverse=(idx == menu_state.selected_index),
)
menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
@@ -115,13 +100,13 @@ def display_menu() -> tuple[object, object]:
save_position = menu_height - 2
menu_win.addstr(
save_position,
(w - len(save_option)) // 2,
(width - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
# Draw help window with dynamically updated max_help_lines
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path)
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, menu_state)
menu_win.refresh()
menu_pad.refresh(
@@ -132,7 +117,6 @@ def display_menu() -> tuple[object, object]:
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
curses.curs_set(0)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
@@ -148,7 +132,9 @@ def draw_help_window(
menu_height: int,
max_help_lines: int,
transformed_path: List[str],
menu_state: MenuState,
) -> None:
global help_win
if "help_win" not in globals():
@@ -159,21 +145,11 @@ def draw_help_window(
)
help_y = menu_start_y + menu_height
# Use current terminal width for the help window width calculation
help_win = update_help_window(
help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x
help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x
)
def get_input_type_for_field(field) -> type:
if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64):
return int
elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE):
return float
else:
return str
def settings_menu(stdscr: object, interface: object) -> None:
curses.update_lines_cols()
@@ -183,33 +159,29 @@ def settings_menu(stdscr: object, interface: object) -> None:
modified_settings = {}
menu_state.need_redraw = True
need_redraw = True
menu_state.show_save_option = False
new_value_name = None
while True:
if menu_state.need_redraw:
menu_state.need_redraw = False
if need_redraw:
options = list(menu_state.current_menu.keys())
# Determine if save option should be shown
path = menu_state.menu_path
menu_state.show_save_option = (
(len(path) > 2 and ("Radio Settings" in path or "Module Settings" in path))
or (len(path) == 2 and "User Settings" in path)
or (len(path) == 3 and "Channels" in path)
(
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)
)
# Display the menu
menu_win, menu_pad = display_menu()
menu_win, menu_pad = display_menu(menu_state)
if menu_win is None:
continue # Skip if menu_win is not initialized
need_redraw = False
menu_win.timeout(200) # wait up to 200 ms for a keypress (or less if key is pressed)
# Capture user input
key = menu_win.getch()
if key == -1:
continue
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
# max_help_lines = 4
@@ -243,16 +215,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
)
elif key == curses.KEY_RESIZE:
menu_state.need_redraw = True
need_redraw = True
curses.update_lines_cols()
menu_win.erase()
if help_win:
help_win.erase()
help_win.erase()
menu_win.refresh()
if help_win:
help_win.refresh()
help_win.refresh()
elif key == ord("\t") and menu_state.show_save_option:
old_selected_index = menu_state.selected_index
@@ -269,17 +239,15 @@ def settings_menu(stdscr: object, interface: object) -> None:
)
elif key == curses.KEY_RIGHT or key == ord("\n"):
menu_state.need_redraw = True
need_redraw = True
menu_state.start_index.append(0)
menu_win.erase()
if help_win:
help_win.erase()
help_win.erase()
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh()
if help_win:
help_win.refresh()
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state)
@@ -300,8 +268,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
break
elif selected_option == "Export Config File":
filename = get_text_input("Enter a filename for the config file", None, None)
filename = get_text_input("Enter a filename for the config file")
if not filename:
logging.info("Export aborted: No filename provided.")
menu_state.start_index.pop()
@@ -323,8 +290,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}")
dialog("Config File Saved:", yaml_file_path)
menu_state.need_redraw = True
dialog(stdscr, "Config File Saved:", yaml_file_path)
menu_state.start_index.pop()
continue
except PermissionError:
@@ -340,16 +306,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
# Check if folder exists and is not empty
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
dialog("", " No config files found. Export a config first.")
menu_state.need_redraw = True
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))]
# Ensure file_list is not empty before proceeding
if not file_list:
dialog("", " No config files found. Export a config first.")
menu_state.need_redraw = True
dialog(stdscr, "", " No config files found. Export a config first.")
continue
filename = get_list_input("Choose a config file", None, file_list)
@@ -363,7 +327,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
elif selected_option == "Config URL":
current_value = interface.localNode.getURL()
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
new_value = get_text_input(f"Config URL is currently: {current_value}")
if new_value is not None:
current_value = new_value
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
@@ -416,6 +380,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
menu_state.selected_index = 4
continue
# need_redraw = True
field_info = menu_state.current_menu.get(selected_option)
if isinstance(field_info, tuple):
@@ -430,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}", selected_option, None
)
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)
@@ -451,9 +414,7 @@ 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}", selected_option, float
)
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)
@@ -492,42 +453,25 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.start_index.pop()
elif field.type == 13: # Field type 13 corresponds to UINT32
input_type = get_input_type_for_field(field)
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
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
input_type = get_input_type_for_field(field)
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
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
input_type = get_input_type_for_field(field)
new_value = get_text_input(
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
)
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()
for key in menu_state.menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {})
# For comparison, normalize enum numbers to names
compare_value = new_value
if field and field.enum_type and isinstance(new_value, int):
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
if enum_value_descriptor:
compare_value = enum_value_descriptor.name
if compare_value != current_value:
# Save the raw protobuf number, not the name
modified_settings[selected_option] = new_value
# Add the new value to the appropriate level
modified_settings[selected_option] = new_value
# Convert enum string to int
if field and field.enum_type:
@@ -542,42 +486,19 @@ def settings_menu(stdscr: object, interface: object) -> None:
menu_state.selected_index = 0
elif key == curses.KEY_LEFT:
# If we are at the main menu and there are unsaved changes, prompt to save
if len(menu_state.menu_path) == 3 and modified_settings:
current_section = menu_state.menu_path[-1]
save_prompt = get_list_input(
f"You have unsaved changes in {current_section}. Save before exiting?",
None,
["Yes", "No", "Cancel"],
mandatory=True,
)
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_changes(interface, modified_settings, menu_state)
logging.info("Changes Saved")
modified_settings.clear()
menu = rebuild_menu_at_current_path(interface, menu_state)
pass
menu_state.need_redraw = True
need_redraw = True
menu_win.erase()
if help_win:
help_win.erase()
help_win.erase()
# max_help_lines = 4
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh()
if help_win:
help_win.refresh()
help_win.refresh()
# if len(menu_state.menu_path) < 2:
# modified_settings.clear()
if len(menu_state.menu_path) < 2:
modified_settings.clear()
# Navigate back to the previous menu
if len(menu_state.menu_path) > 1:
@@ -594,16 +515,6 @@ def settings_menu(stdscr: object, interface: object) -> None:
break
def rebuild_menu_at_current_path(interface, menu_state):
"""Rebuild menus from the device and re-point current_menu to the same path."""
new_menu = generate_menu_from_protobuf(interface)
cur = new_menu["Main Menu"]
for step in menu_state.menu_path[1:]:
cur = cur.get(step, {})
menu_state.current_menu = cur
return new_menu
def set_region(interface: object) -> None:
node = interface.getNode("^local")
device_config = node.localConfig

View File

@@ -2,69 +2,15 @@ import json
import logging
import os
from typing import Dict
from contact.ui.colors import setup_colors
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
# To test writting to a non-writable directory, you can uncomment the following lines:
# mkdir /tmp/test_nonwritable
# chmod -w /tmp/test_nonwritable
# parent_dir = "/tmp/test_nonwritable"
def reload_config() -> None:
loaded_config = initialize_config()
assign_config_variables(loaded_config)
setup_colors(reinit=True)
def _is_writable_dir(path: str) -> bool:
"""
Return True if we can create & delete a temp file in `path`.
"""
if not os.path.isdir(path):
return False
test_path = os.path.join(path, ".perm_test_tmp")
try:
with open(test_path, "w", encoding="utf-8") as _tmp:
_tmp.write("ok")
os.remove(test_path)
return True
except OSError:
return False
def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client") -> str:
"""
Choose a writable directory for config artifacts.
"""
if _is_writable_dir(preferred_dir):
return preferred_dir
home = os.path.expanduser("~")
fallback_dir = os.path.join(home, fallback_name)
# Ensure the fallback exists.
os.makedirs(fallback_dir, exist_ok=True)
# If *that* still isn't writable, last-ditch: use a system temp dir.
if not _is_writable_dir(fallback_dir):
import tempfile
fallback_dir = tempfile.mkdtemp(prefix="contact_client_")
return fallback_dir
# Pick the root now.
config_root = _get_config_root(parent_dir)
# Paths (derived from the chosen root)
json_file_path = os.path.join(config_root, "config.json")
log_file_path = os.path.join(config_root, "client.log")
db_file_path = os.path.join(config_root, "client.db")
node_configs_file_path = os.path.join(config_root, "node-configs/")
# Paths
json_file_path = os.path.join(parent_dir, "config.json")
log_file_path = os.path.join(parent_dir, "client.log")
db_file_path = os.path.join(parent_dir, "client.db")
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
@@ -177,20 +123,15 @@ def initialize_config() -> Dict[str, object]:
"settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"],
"settings_note": ["green", "black"],
"node_favorite": ["cyan", "green"],
"node_ignored": ["red", "black"],
"node_favorite": ["cyan", "white"],
"node_ignored": ["red", "white"],
}
default_config_variables = {
"channel_list_16ths": "3",
"node_list_16ths": "5",
"single_pane_mode": "False",
"db_file_path": db_file_path,
"log_file_path": log_file_path,
"node_configs_file_path": node_configs_file_path,
"message_prefix": ">>",
"sent_message_prefix": ">> Sent",
"notification_symbol": "*",
"notification_sound": "True",
"ack_implicit_str": "[◌]",
"ack_str": "[✓]",
"nak_str": "[x]",
@@ -227,27 +168,20 @@ def initialize_config() -> Dict[str, object]:
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
# Assign values to local variables
global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix
global db_file_path, log_file_path, message_prefix, sent_message_prefix
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global node_list_16ths, channel_list_16ths, single_pane_mode
global theme, COLOR_CONFIG
global node_sort, notification_sound
global node_sort
channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"]
single_pane_mode = loaded_config["single_pane_mode"]
db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"]
node_configs_file_path = loaded_config.get("node_configs_file_path")
message_prefix = loaded_config["message_prefix"]
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"]
@@ -255,6 +189,7 @@ 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
@@ -270,7 +205,6 @@ if __name__ == "__main__":
print("\nLoaded Configuration:")
print(f"Database File Path: {db_file_path}")
print(f"Log File Path: {log_file_path}")
print(f"Configs File Path: {node_configs_file_path}")
print(f"Message Prefix: {message_prefix}")
print(f"Sent Message Prefix: {sent_message_prefix}")
print(f"Notification Symbol: {notification_symbol}")

View File

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

View File

@@ -1,7 +1,5 @@
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
@@ -22,9 +20,9 @@ def get_node_color(node_index: int, reverse: bool = False):
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
MIN_HEIGHT_FOR_HELP = 20
def move_highlight(
@@ -73,8 +71,9 @@ def move_highlight(
# Clear old selection
if show_save_option and old_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
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,
@@ -89,10 +88,9 @@ def move_highlight(
# Highlight new selection
if show_save_option and new_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(
win_h - 2,
(win_w - len(save_option)) // 2,
menu_win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
@@ -124,14 +122,13 @@ def move_highlight(
selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win:
win_h, win_w = menu_win.getmaxyx()
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
win_w,
width,
help_y,
menu_win.getbegyx()[1],
)
@@ -168,46 +165,23 @@ def update_help_window(
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
if curses.LINES < MIN_HEIGHT_FOR_HELP:
return None
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)
# Ensure requested width fits on screen from help_x
max_w_from_x = max(1, curses.COLS - help_x)
safe_width = min(width, max_w_from_x)
# Always leave a minimal border area; enforce a minimum usable width of 3
safe_width = max(3, safe_width)
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines)
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)
# Re-clamp Y to keep the window visible
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = max(0, curses.LINES - help_height)
# If width would overflow the screen, shrink it
if help_x + safe_width > curses.COLS:
safe_width = max(3, curses.COLS - help_x)
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, safe_width, help_y, help_x)
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, safe_width)
try:
help_win.mvwin(help_y, help_x)
except curses.error:
# If moving fails due to edge conditions, pin to (0,0) as a fallback
help_y = 0
help_x = 0
help_win.mvwin(help_y, help_x)
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
@@ -320,17 +294,8 @@ def get_wrapped_help_text(
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 = ""
@@ -339,11 +304,11 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
wrap_width -= margin
for word in words:
word_length = text_width(word)
word_length = len(word)
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer.strip())
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
@@ -351,7 +316,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
continue
if line_length + word_length > wrap_width and word.strip():
wrapped_lines.append(line_buffer.strip())
wrapped_lines.append(line_buffer)
line_buffer = ""
line_length = 0
@@ -359,7 +324,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer.strip())
wrapped_lines.append(line_buffer)
return wrapped_lines

View File

@@ -22,7 +22,6 @@ def draw_splash(stdscr: object) -> None:
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.move(start_y + 5, start_x2)
stdscr.attrset(get_color("window_frame"))
stdscr.box()

View File

@@ -10,7 +10,6 @@ class MenuState:
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
need_redraw: bool = False
@dataclass
@@ -25,14 +24,11 @@ class ChatUIState:
selected_message: int = 0
selected_node: int = 0
current_window: int = 0
last_sent_time: float = 0.0
last_traceroute_time: float = 0.0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
@dataclass

View File

@@ -4,23 +4,16 @@ import curses
from typing import Any, List, Dict
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
import contact.ui.default_config as config
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.ui.nav_utils import move_highlight, draw_arrows
from contact.utilities.input_handlers import get_list_input
from contact.utilities.singleton import menu_state
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
width = 80
max_help_lines = 6
save_option = "Save Changes"
# Compute an effective width that fits the current terminal
def get_effective_width() -> int:
# Leave space for borders; ensure a sane minimum
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
@@ -34,14 +27,13 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
def edit_value(key: str, current_value: str) -> str:
w = get_effective_width()
height = 10
input_width = w - 16 # Allow space for "New Value: "
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = max(0, (curses.COLS - w) // 2)
start_x = (curses.COLS - width) // 2
# Create a centered window
edit_win = curses.newwin(height, w, start_y, start_x)
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
@@ -50,7 +42,7 @@ def edit_value(key: str, current_value: str) -> str:
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = w - 4 # Account for border and padding
wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
@@ -61,23 +53,12 @@ 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 config.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)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input("Single-Pane Mode", current_value, sound_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
@@ -86,64 +67,41 @@ def edit_value(key: str, current_value: str) -> str:
user_input = ""
input_position = (7, 13) # Tuple for row and column
row, col = input_position # Unpack tuple
while True:
if menu_state.need_redraw:
curses.update_lines_cols()
menu_state.need_redraw = False
# Re-create the window to fully reset state
edit_win = curses.newwin(height, w, start_y, start_x)
edit_win.timeout(200)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Redraw static content
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
for i, line in enumerate(wrapped_lines[:4]):
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
visible_text = user_input[scroll_offset : scroll_offset + input_width]
edit_win.addstr(row, col, " " * input_width, get_color("settings_default"))
edit_win.addstr(row, col, visible_text, get_color("settings_default"))
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))
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
try:
key = edit_win.get_wch()
except curses.error:
continue # window not ready — skip this loop
if key in (chr(27), curses.KEY_LEFT):
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value
return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif key in (curses.KEY_BACKSPACE, chr(127)):
if user_input:
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
if len(user_input) > input_width:
if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1
curses.curs_set(0)
return user_input if user_input else current_value
def display_menu() -> 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.
"""
@@ -161,12 +119,11 @@ def display_menu() -> tuple[Any, Any, List[str]]:
max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
w = get_effective_width()
start_y = (curses.LINES - menu_height) // 2
start_x = max(0, (curses.COLS - w) // 2)
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
@@ -174,13 +131,13 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_win.keypad(True)
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, w - 8)
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the menu path
header = " > ".join(menu_state.menu_path)
if len(header) > w - 4:
header = header[: w - 7] + "..."
if len(header) > width - 4:
header = header[: width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
@@ -190,18 +147,18 @@ def display_menu() -> tuple[Any, Any, List[str]]:
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
display_key = f"{key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 2 - 8]
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:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
if menu_state.show_save_option:
save_position = menu_height - 2
menu_win.addstr(
save_position,
(w - len(save_option)) // 2,
(width - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
@@ -227,7 +184,6 @@ def display_menu() -> tuple[Any, Any, List[str]]:
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_state.selected_index = 0 # Track the selected option
made_changes = False # Track if any changes were made
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
@@ -250,18 +206,16 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
menu_state.current_menu = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = display_menu()
menu_state.need_redraw = True
menu_win, menu_pad, options = display_menu(menu_state)
need_redraw = True
while True:
if menu_state.need_redraw:
menu_state.need_redraw = False
menu_win, menu_pad, options = display_menu()
if need_redraw:
menu_win, menu_pad, options = display_menu(menu_state)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
menu_win.timeout(200)
key = menu_win.getch()
if key == curses.KEY_UP:
@@ -289,7 +243,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
menu_state.need_redraw = True
need_redraw = True
menu_win.erase()
menu_win.refresh()
@@ -310,14 +264,11 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
old = selected_data
new_value = edit_color_pair(selected_key, selected_data)
menu_state.menu_path.pop()
menu_state.start_index.pop()
menu_state.menu_index.pop()
menu_state.current_menu[selected_key] = new_value
if new_value != old:
made_changes = True
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
@@ -326,27 +277,22 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
else:
# General value editing
old = selected_data
new_value = edit_value(selected_key, selected_data)
menu_state.menu_path.pop()
menu_state.menu_index.pop()
menu_state.start_index.pop()
menu_state.current_menu[selected_key] = new_value
menu_state.need_redraw = True
if new_value != old:
made_changes = True
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
made_changes = False
stdscr.refresh()
# config.reload() # This isn't refreshing the file paths as expected
break
continue
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
menu_state.need_redraw = True
need_redraw = True
menu_win.erase()
menu_win.refresh()
@@ -367,19 +313,6 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
else:
# Exit the editor
if made_changes:
save_prompt = get_list_input(
"You have unsaved changes. Save before exiting?",
None,
["Yes", "No", "Cancel"],
mandatory=True,
)
if save_prompt == "Cancel":
continue # Stay in the menu without doing anything
elif save_prompt == "Yes":
save_json(file_path, data)
made_changes = False
menu_win.clear()
menu_win.refresh()
@@ -387,7 +320,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
def save_json(file_path: str, data: Dict[str, Any]) -> None:
formatted_json = config.format_json_single_line_arrays(data)
formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
@@ -396,6 +329,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
def main(stdscr: curses.window) -> None:
from contact.ui.ui_state import MenuState
menu_state = MenuState()
if len(menu_state.menu_path) == 0:
menu_state.menu_path = ["App Settings"] # Initialize if not set

View File

@@ -1,6 +1,5 @@
import yaml
import logging
import time
from typing import List
from google.protobuf.json_format import MessageToDict
from meshtastic import mt_config
@@ -134,29 +133,24 @@ def config_import(interface, filename):
logging.info(f"Setting device owner to {configuration['owner']}")
waitForAckNak = True
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']}")
waitForAckNak = True
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']}")
waitForAckNak = True
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"])
time.sleep(0.5)
if "channelUrl" in configuration:
logging.info(f"Setting channel url to {configuration['channelUrl']}")
interface.getNode("^local").setURL(configuration["channelUrl"])
time.sleep(0.5)
if "location" in configuration:
alt = 0
@@ -175,14 +169,12 @@ 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
for section in configuration["config"]:
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
@@ -193,7 +185,6 @@ def config_import(interface, filename):
moduleConfig,
)
interface.getNode("^local").writeConfig(camel_to_snake(section))
time.sleep(0.5)
interface.getNode("^local", False).commitSettingsTransaction()
logging.info("Writing modified configuration to device")

View File

@@ -116,14 +116,7 @@ def load_messages_from_db() -> None:
# Add messages to ui_state.all_messages grouped by hourly timestamp
hourly_messages = {}
for row in db_messages:
user_id, message, timestamp, ack_type = row
# Only ack_type is allowed to be None
if user_id is None or message is None or timestamp is None:
logging.warning(f"Skipping row with NULL required field(s): {row}")
continue
for user_id, message, timestamp, ack_type in db_messages:
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
if hour not in hourly_messages:
hourly_messages[hour] = []
@@ -136,19 +129,12 @@ def load_messages_from_db() -> None:
elif ack_type == "Nak":
ack_str = config.nak_str
ts_str = datetime.fromtimestamp(timestamp).strftime("[%H:%M:%S]")
if user_id == str(interface_state.myNodeNum):
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.sent_message_prefix}{ack_str}: ",
sanitized_message,
)
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
else:
sanitized_message = message.replace("\x00", "")
formatted_message = (
f"{ts_str} {config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
sanitized_message,
f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
message,
)
hourly_messages[hour].append(formatted_message)

View File

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

View File

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

View File

@@ -1,90 +0,0 @@
import datetime
sensors = {
'temperature': {'icon':'🌡️ ','unit':'°'},
'relative_humidity': {'icon':'💧','unit':'%'},
'barometric_pressure': {'icon':'','unit': 'hPa'},
'lux': {'icon':'🔦 ','unit': 'lx'},
'uv_lux': {'icon':'uv🔦 ','unit': 'lx'},
'wind_speed': {'icon':'💨 ','unit': 'm/s'},
'wind_direction': {'icon':'','unit': ''},
'battery_level': {'icon':'🔋 ', 'unit':'%'},
'voltage': {'icon':'', 'unit':'V'},
'channel_utilization': {'icon':'ChUtil:', 'unit':'%'},
'air_util_tx': {'icon':'AirUtil:', 'unit':'%'},
'uptime_seconds': {'icon':'🆙 ', 'unit':'h'},
'latitude_i': {'icon':'🌍 ', 'unit':''},
'longitude_i': {'icon':'', 'unit':''},
'altitude': {'icon':'⬆️ ', 'unit':'m'},
'time': {'icon':'🕔 ', 'unit':''}
}
def humanize_wind_direction(degrees):
""" Convert degrees to Eest-West-Nnoth-Ssouth directions """
if not 0 <= degrees <= 360:
return None
directions = [
("N", 337.5, 22.5),
("NE", 22.5, 67.5),
("E", 67.5, 112.5),
("SE", 112.5, 157.5),
("S", 157.5, 202.5),
("SW", 202.5, 247.5),
("W", 247.5, 292.5),
("NW", 292.5, 337.5),
]
if degrees >= directions[0][1] or degrees < directions[0][2]:
return directions[0][0]
# Check for all other directions
for direction, lower_bound, upper_bound in directions[1:]:
if lower_bound <= degrees < upper_bound:
return direction
# This part should ideally not be reached with valid input
return None
def get_chunks(data):
""" Breakdown telemetry data and assign emojis for more visual appeal of the payloads """
reading = data.split('\n')
# remove empty list lefover from the split
reading = list(filter(None, reading))
parsed=""
for item in reading:
key, value = item.split(":")
# If value is float, round it to the 1 digit after point
# else make it int
if "." in value:
value = round(float(value.strip()),1)
else:
try:
value = int(value.strip())
except Exception:
# Leave it string as last resort
value = value
match key:
# convert seconds to hours, for our sanity
case "uptime_seconds":
value = round(value / 60 / 60, 1)
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
# truncate to 6th digit after floating point, which would be still accurate
case "longitude_i" | "latitude_i":
value = round(value * 1e-7, 6)
# Convert wind direction from degrees to abbreviation
case "wind_direction":
value = humanize_wind_direction(value)
case "time":
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
if key in sensors:
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
else:
# just pass through if we haven't added the particular telemetry key:value to the sensor dict
parsed+=f"{key}:{value} "
return parsed

View File

@@ -1,13 +1,8 @@
import datetime
import time
from typing import Optional, Union
from google.protobuf.message import DecodeError
from meshtastic import protocols
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
from meshtastic.protobuf import config_pb2
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state
import contact.utilities.telemetry_beautifier as tb
def get_channels():
@@ -139,81 +134,3 @@ def get_time_ago(timestamp):
if unit != "s":
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
ts_str = time.strftime("[%H:%M:%S] ")
ui_state.all_messages[channel_id].append((f"{ts_str}{prefix}", message))
def parse_protobuf(packet: dict) -> Union[str, dict]:
"""Attempt to parse a decoded payload using the registered protobuf handler."""
try:
decoded = packet.get("decoded") or {}
portnum = decoded.get("portnum")
payload = decoded.get("payload")
if isinstance(payload, str):
return payload
# These portnumbers carry information visible elswhere in the app, so we just note them in the logs
if portnum == "TEXT_MESSAGE_APP":
return "✉️"
elif portnum == "NODEINFO_APP":
return "Name identification payload"
elif portnum == "TRACEROUTE_APP":
return "Traceroute payload"
handler = protocols.get(portnums_pb2.PortNum.Value(portnum)) if portnum is not None else None
if handler is not None and handler.protobufFactory is not None:
try:
pb = handler.protobufFactory()
pb.ParseFromString(bytes(payload))
# If we have position payload
if portnum == "POSITION_APP":
return tb.get_chunks(str(pb))
# Part of TELEMETRY_APP portnum
if hasattr(pb, "device_metrics") and pb.HasField("device_metrics"):
return tb.get_chunks(str(pb.device_metrics))
# Part of TELEMETRY_APP portnum
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
return tb.get_chunks(str(pb.environment_metrics))
# For other data, without implemented beautification, fallback to just printing the object
return str(pb).replace("\n", " ").replace("\r", " ").strip()
except DecodeError:
return payload
# return payload
except Exception:
return payload

View File

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

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.4.4"
version = "1.3.8"
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"}